mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-13 08:09:04 +02:00
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
const segmentRegexCache = new Map<string, RegExp>();
|
|
|
|
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);
|
|
}
|
|
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/*`.
|
|
//
|
|
// 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("*")) {
|
|
const regex = getSegmentRegex(currentPatternPart);
|
|
dp[i][j] = regex.test(pathParts[j]) && dp[i + 1][j + 1];
|
|
} else {
|
|
dp[i][j] =
|
|
currentPatternPart === pathParts[j] && dp[i + 1][j + 1];
|
|
}
|
|
}
|
|
}
|
|
|
|
return dp[0][0];
|
|
}
|