diff --git a/server/lib/pathMatch.ts b/server/lib/pathMatch.ts index ebc03783a..900073d69 100644 --- a/server/lib/pathMatch.ts +++ b/server/lib/pathMatch.ts @@ -1,16 +1,37 @@ -const segmentRegexCache = new Map(); +// Matches a single segment against a glob where `*` matches any run of +// characters and `?` matches exactly one. Greedy two-pointer matching that +// only ever backtracks to the most recent `*`, so it runs in +// O(pattern * text) worst case. Compiling to a `.*` regex instead +// backtracks catastrophically on patterns like `*a*a*a*a*b`. +function matchSegmentGlob(pattern: string, text: string): boolean { + let p = 0; + let t = 0; + let starP = -1; + let starT = 0; -function getSegmentRegex(patternPart: string): RegExp { - let regex = segmentRegexCache.get(patternPart); - if (!regex) { - const regexPattern = patternPart - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - regex = new RegExp(`^${regexPattern}$`); - segmentRegexCache.set(patternPart, regex); + while (t < text.length) { + if (p < pattern.length && pattern[p] === "*") { + starP = p++; + starT = t; + } else if ( + p < pattern.length && + (pattern[p] === "?" || pattern[p] === text[t]) + ) { + p++; + t++; + } else if (starP !== -1) { + // let the last `*` swallow one more character and retry + p = starP + 1; + t = ++starT; + } else { + return false; + } } - return regex; + + while (p < pattern.length && pattern[p] === "*") { + p++; + } + return p === pattern.length; } // Decodes percent-encoding (so an encoded slash like `%2F` is treated as a @@ -79,8 +100,9 @@ export function isPathAllowed(pattern: string, path: string): boolean { // match zero segments, or consume one path segment dp[i][j] = dp[i + 1][j] || dp[i][j + 1]; } else if (currentPatternPart.includes("*")) { - const regex = getSegmentRegex(currentPatternPart); - dp[i][j] = regex.test(pathParts[j]) && dp[i + 1][j + 1]; + dp[i][j] = + dp[i + 1][j + 1] && + matchSegmentGlob(currentPatternPart, pathParts[j]); } else { dp[i][j] = currentPatternPart === pathParts[j] && dp[i + 1][j + 1]; diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 6254de025..eb4eed44e 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -383,6 +383,30 @@ function runSpecialCharacterTests() { "Deeply nested paths should still match" ); + assertEquals( + isPathAllowed("*a*b", "xaxyb"), + true, + "Multiple wildcards within a segment should match" + ); + assertEquals( + isPathAllowed("*a*b", "xaxyba"), + false, + "In-segment wildcard pattern must still be anchored at the end" + ); + + // a `.*`-style regex backtracks catastrophically here and never returns + const start = Date.now(); + isPathAllowed("/" + "*a".repeat(10) + "b", "/" + "a".repeat(5000)); + isPathAllowed( + "/" + Array(10).fill("*a*a*a*").join("/") + "/nevermatch", + "/" + Array(25).fill("a".repeat(200)).join("/") + ); + assertEquals( + Date.now() - start < 1000, + true, + "In-segment wildcards should not backtrack exponentially" + ); + console.log("All special character tests passed!"); }