mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 03:21:38 +00:00
refactor: share gateway security path canonicalization
This commit is contained in:
@@ -9,23 +9,24 @@ import {
|
|||||||
describe("security-path canonicalization", () => {
|
describe("security-path canonicalization", () => {
|
||||||
it("canonicalizes decoded case/slash variants", () => {
|
it("canonicalizes decoded case/slash variants", () => {
|
||||||
expect(canonicalizePathForSecurity("/API/channels//nostr/default/profile/")).toEqual({
|
expect(canonicalizePathForSecurity("/API/channels//nostr/default/profile/")).toEqual({
|
||||||
path: "/api/channels/nostr/default/profile",
|
canonicalPath: "/api/channels/nostr/default/profile",
|
||||||
candidates: ["/api/channels/nostr/default/profile"],
|
candidates: ["/api/channels/nostr/default/profile"],
|
||||||
malformedEncoding: false,
|
malformedEncoding: false,
|
||||||
rawNormalizedPath: "/api/channels/nostr/default/profile",
|
rawNormalizedPath: "/api/channels/nostr/default/profile",
|
||||||
});
|
});
|
||||||
const encoded = canonicalizePathForSecurity("/api/%63hannels%2Fnostr%2Fdefault%2Fprofile");
|
const encoded = canonicalizePathForSecurity("/api/%63hannels%2Fnostr%2Fdefault%2Fprofile");
|
||||||
expect(encoded.path).toBe("/api/channels/nostr/default/profile");
|
expect(encoded.canonicalPath).toBe("/api/channels/nostr/default/profile");
|
||||||
expect(encoded.candidates).toContain("/api/%63hannels%2fnostr%2fdefault%2fprofile");
|
expect(encoded.candidates).toContain("/api/%63hannels%2fnostr%2fdefault%2fprofile");
|
||||||
expect(encoded.candidates).toContain("/api/channels/nostr/default/profile");
|
expect(encoded.candidates).toContain("/api/channels/nostr/default/profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves traversal after repeated decoding", () => {
|
it("resolves traversal after repeated decoding", () => {
|
||||||
expect(canonicalizePathForSecurity("/api/foo/..%2fchannels/nostr/default/profile").path).toBe(
|
|
||||||
"/api/channels/nostr/default/profile",
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
canonicalizePathForSecurity("/api/foo/%252e%252e%252fchannels/nostr/default/profile").path,
|
canonicalizePathForSecurity("/api/foo/..%2fchannels/nostr/default/profile").canonicalPath,
|
||||||
|
).toBe("/api/channels/nostr/default/profile");
|
||||||
|
expect(
|
||||||
|
canonicalizePathForSecurity("/api/foo/%252e%252e%252fchannels/nostr/default/profile")
|
||||||
|
.canonicalPath,
|
||||||
).toBe("/api/channels/nostr/default/profile");
|
).toBe("/api/channels/nostr/default/profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type SecurityPathCanonicalization = {
|
export type SecurityPathCanonicalization = {
|
||||||
path: string;
|
canonicalPath: string;
|
||||||
candidates: string[];
|
candidates: string[];
|
||||||
malformedEncoding: boolean;
|
malformedEncoding: boolean;
|
||||||
rawNormalizedPath: string;
|
rawNormalizedPath: string;
|
||||||
@@ -31,32 +31,26 @@ function normalizePathForSecurity(pathname: string): string {
|
|||||||
return normalizePathSeparators(resolveDotSegments(pathname).toLowerCase()) || "/";
|
return normalizePathSeparators(resolveDotSegments(pathname).toLowerCase()) || "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefixMatch(pathname: string, prefix: string): boolean {
|
function pushNormalizedCandidate(candidates: string[], seen: Set<string>, value: string): void {
|
||||||
return (
|
const normalized = normalizePathForSecurity(value);
|
||||||
pathname === prefix ||
|
if (seen.has(normalized)) {
|
||||||
pathname.startsWith(`${prefix}/`) ||
|
return;
|
||||||
// Fail closed when malformed %-encoding follows the protected prefix.
|
}
|
||||||
pathname.startsWith(`${prefix}%`)
|
seen.add(normalized);
|
||||||
);
|
candidates.push(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canonicalizePathForSecurity(pathname: string): SecurityPathCanonicalization {
|
export function buildCanonicalPathCandidates(
|
||||||
|
pathname: string,
|
||||||
|
maxDecodePasses = MAX_PATH_DECODE_PASSES,
|
||||||
|
): { candidates: string[]; malformedEncoding: boolean } {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const pushCandidate = (value: string) => {
|
pushNormalizedCandidate(candidates, seen, pathname);
|
||||||
const normalized = normalizePathForSecurity(value);
|
|
||||||
if (seen.has(normalized)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seen.add(normalized);
|
|
||||||
candidates.push(normalized);
|
|
||||||
};
|
|
||||||
|
|
||||||
pushCandidate(pathname);
|
|
||||||
|
|
||||||
let decoded = pathname;
|
let decoded = pathname;
|
||||||
let malformedEncoding = false;
|
let malformedEncoding = false;
|
||||||
for (let pass = 0; pass < MAX_PATH_DECODE_PASSES; pass++) {
|
for (let pass = 0; pass < maxDecodePasses; pass++) {
|
||||||
let nextDecoded = decoded;
|
let nextDecoded = decoded;
|
||||||
try {
|
try {
|
||||||
nextDecoded = decodeURIComponent(decoded);
|
nextDecoded = decodeURIComponent(decoded);
|
||||||
@@ -68,20 +62,51 @@ export function canonicalizePathForSecurity(pathname: string): SecurityPathCanon
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
decoded = nextDecoded;
|
decoded = nextDecoded;
|
||||||
pushCandidate(decoded);
|
pushNormalizedCandidate(candidates, seen, decoded);
|
||||||
}
|
}
|
||||||
|
return { candidates, malformedEncoding };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizePathVariant(pathname: string): string {
|
||||||
|
const { candidates } = buildCanonicalPathCandidates(pathname);
|
||||||
|
return candidates[candidates.length - 1] ?? "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixMatch(pathname: string, prefix: string): boolean {
|
||||||
|
return (
|
||||||
|
pathname === prefix ||
|
||||||
|
pathname.startsWith(`${prefix}/`) ||
|
||||||
|
// Fail closed when malformed %-encoding follows the protected prefix.
|
||||||
|
pathname.startsWith(`${prefix}%`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizePathForSecurity(pathname: string): SecurityPathCanonicalization {
|
||||||
|
const { candidates, malformedEncoding } = buildCanonicalPathCandidates(pathname);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: candidates[candidates.length - 1] ?? "/",
|
canonicalPath: candidates[candidates.length - 1] ?? "/",
|
||||||
candidates,
|
candidates,
|
||||||
malformedEncoding,
|
malformedEncoding,
|
||||||
rawNormalizedPath: normalizePathSeparators(pathname.toLowerCase()) || "/",
|
rawNormalizedPath: normalizePathSeparators(pathname.toLowerCase()) || "/",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedPrefixesCache = new WeakMap<readonly string[], readonly string[]>();
|
||||||
|
|
||||||
|
function getNormalizedPrefixes(prefixes: readonly string[]): readonly string[] {
|
||||||
|
const cached = normalizedPrefixesCache.get(prefixes);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const normalized = prefixes.map(normalizeProtectedPrefix);
|
||||||
|
normalizedPrefixesCache.set(prefixes, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function isPathProtectedByPrefixes(pathname: string, prefixes: readonly string[]): boolean {
|
export function isPathProtectedByPrefixes(pathname: string, prefixes: readonly string[]): boolean {
|
||||||
const canonical = canonicalizePathForSecurity(pathname);
|
const canonical = canonicalizePathForSecurity(pathname);
|
||||||
const normalizedPrefixes = prefixes.map(normalizeProtectedPrefix);
|
const normalizedPrefixes = getNormalizedPrefixes(prefixes);
|
||||||
if (
|
if (
|
||||||
canonical.candidates.some((candidate) =>
|
canonical.candidates.some((candidate) =>
|
||||||
normalizedPrefixes.some((prefix) => prefixMatch(candidate, prefix)),
|
normalizedPrefixes.some((prefix) => prefixMatch(candidate, prefix)),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import type { HooksConfigResolved } from "./hooks.js";
|
import type { HooksConfigResolved } from "./hooks.js";
|
||||||
|
import { canonicalizePathVariant } from "./security-path.js";
|
||||||
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
|
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
|
||||||
import { withTempConfig } from "./test-temp-config.js";
|
import { withTempConfig } from "./test-temp-config.js";
|
||||||
|
|
||||||
@@ -87,30 +88,7 @@ function createHooksConfig(): HooksConfigResolved {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizePluginPath(pathname: string): string {
|
function canonicalizePluginPath(pathname: string): string {
|
||||||
let decoded = pathname;
|
return canonicalizePathVariant(pathname);
|
||||||
for (let pass = 0; pass < 3; pass++) {
|
|
||||||
let nextDecoded = decoded;
|
|
||||||
try {
|
|
||||||
nextDecoded = decodeURIComponent(decoded);
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (nextDecoded === decoded) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
decoded = nextDecoded;
|
|
||||||
}
|
|
||||||
let resolved = decoded;
|
|
||||||
try {
|
|
||||||
resolved = new URL(decoded, "http://localhost").pathname;
|
|
||||||
} catch {
|
|
||||||
resolved = decoded;
|
|
||||||
}
|
|
||||||
const collapsed = resolved.toLowerCase().replace(/\/{2,}/g, "/");
|
|
||||||
if (collapsed.length <= 1) {
|
|
||||||
return collapsed;
|
|
||||||
}
|
|
||||||
return collapsed.replace(/\/+$/, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteVariant = {
|
type RouteVariant = {
|
||||||
|
|||||||
Reference in New Issue
Block a user