mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-13 07:19:38 +00:00
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
// 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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
while (p < pattern.length && pattern[p] === "*") {
|
|
p++;
|
|
}
|
|
return p === pattern.length;
|
|
}
|
|
|
|
// 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/*`.
|
|
//
|
|
// Applied to both the request path and the rule pattern: the pattern
|
|
// validator only accepts spaces / non-ASCII in percent-encoded form, so a
|
|
// rule like `/my%20docs/*` must be compared against the decoded segment
|
|
// `my docs`, not the literal text `my%20docs`.
|
|
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 patternParts = decodeAndResolvePath(pattern);
|
|
const pathParts = decodeAndResolvePath(path);
|
|
|
|
// Iterative dynamic-programming matcher. The previous recursive
|
|
// implementation forked on every bare `*`, which backtracked
|
|
// exponentially and let an unauthenticated path rule pin the event loop.
|
|
// `dp[i][j]` is true when patternParts[i..] matches pathParts[j..].
|
|
const m = patternParts.length;
|
|
const n = pathParts.length;
|
|
const dp: boolean[][] = Array.from({ length: m + 1 }, () =>
|
|
new Array<boolean>(n + 1).fill(false)
|
|
);
|
|
|
|
dp[m][n] = true;
|
|
for (let j = 0; j < n; j++) {
|
|
dp[m][j] = false;
|
|
}
|
|
for (let i = m - 1; i >= 0; i--) {
|
|
dp[i][n] = patternParts.slice(i).every((p) => p === "*");
|
|
}
|
|
|
|
for (let i = m - 1; i >= 0; i--) {
|
|
const currentPatternPart = patternParts[i];
|
|
for (let j = n - 1; j >= 0; j--) {
|
|
if (currentPatternPart === "*") {
|
|
// match zero segments, or consume one path segment
|
|
dp[i][j] = dp[i + 1][j] || dp[i][j + 1];
|
|
} else if (currentPatternPart.includes("*")) {
|
|
dp[i][j] =
|
|
dp[i + 1][j + 1] &&
|
|
matchSegmentGlob(currentPatternPart, pathParts[j]);
|
|
} else {
|
|
dp[i][j] =
|
|
currentPatternPart === pathParts[j] && dp[i + 1][j + 1];
|
|
}
|
|
}
|
|
}
|
|
|
|
return dp[0][0];
|
|
}
|