diff --git a/server/lib/pathMatch.ts b/server/lib/pathMatch.ts index a007f9ec3..8ef579607 100644 --- a/server/lib/pathMatch.ts +++ b/server/lib/pathMatch.ts @@ -15,10 +15,41 @@ function getSegmentRegex(patternPart: string): RegExp { return regex; } +// Decodes percent-encoding (so an encoded slash like `%2F` is treated as a +// real path separator, matching what most backends will do) and then +// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` +// or `/public/../admin/` is matched as `/admin/`, not as a literal segment +// or a wildcard-swallowed sequence under `/public/*`. +function decodeAndResolvePath(p: string): string[] { + const rawParts = p.split("/").filter(Boolean); + + const resolved: string[] = []; + for (const rawPart of rawParts) { + let part: string; + try { + part = decodeURIComponent(rawPart); + } catch { + part = rawPart; + } + + // an encoded slash can turn one raw segment into several real ones + for (const segment of part.split("/").filter(Boolean)) { + if (segment === ".") { + continue; + } else if (segment === "..") { + resolved.pop(); + } else { + resolved.push(segment); + } + } + } + + return resolved; +} + export function isPathAllowed(pattern: string, path: string): boolean { - const normalize = (p: string) => p.split("/").filter(Boolean); - const patternParts = normalize(pattern); - const pathParts = normalize(path); + const patternParts = pattern.split("/").filter(Boolean); + const pathParts = decodeAndResolvePath(path); function matchSegments( patternIndex: number, diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 681717f9c..c91805f8e 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -236,6 +236,38 @@ function runTests() { "Root path should not match non-root path" ); + // Path traversal / encoded-slash bypass regression tests + assertEquals( + isPathAllowed("public/*", "public/../admin"), + false, + "Literal .. traversal out of an allowed prefix must not match" + ); + assertEquals( + isPathAllowed("public/*", "public%2F..%2Fadmin"), + false, + "Encoded-slash traversal out of an allowed prefix must not match" + ); + assertEquals( + isPathAllowed("public/*", "public%2f..%2fadmin%2ffile"), + false, + "Encoded-slash traversal is case-insensitively decoded before matching" + ); + assertEquals( + isPathAllowed("admin/*", "public/../admin/secret"), + true, + ".. traversal INTO a restricted path should still be caught by its own rule" + ); + assertEquals( + isPathAllowed("public/*", "public%2Ffoo"), + true, + "Encoded slash without traversal should still resolve and match normally" + ); + assertEquals( + isPathAllowed("public/*", "public/./foo"), + true, + "Single-dot segments are a no-op and should not affect matching" + ); + console.log("All path matching tests passed!"); }