mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-22 15:22:12 +00:00
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03d3522a3 |
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:26-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
const MAX_RECURSION_DEPTH = 100;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPathAllowed(pattern: string, path: string): boolean {
|
|
||||||
const normalize = (p: string) => p.split("/").filter(Boolean);
|
|
||||||
const patternParts = normalize(pattern);
|
|
||||||
const pathParts = normalize(path);
|
|
||||||
|
|
||||||
function matchSegments(
|
|
||||||
patternIndex: number,
|
|
||||||
pathIndex: number,
|
|
||||||
depth: number = 0
|
|
||||||
): boolean {
|
|
||||||
if (depth > MAX_RECURSION_DEPTH) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPatternPart = patternParts[patternIndex];
|
|
||||||
const currentPathPart = pathParts[pathIndex];
|
|
||||||
|
|
||||||
if (patternIndex >= patternParts.length) {
|
|
||||||
return pathIndex >= pathParts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathIndex >= pathParts.length) {
|
|
||||||
return patternParts.slice(patternIndex).every((p) => p === "*");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPatternPart === "*") {
|
|
||||||
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPatternPart.includes("*")) {
|
|
||||||
const regex = getSegmentRegex(currentPatternPart);
|
|
||||||
|
|
||||||
if (regex.test(currentPathPart)) {
|
|
||||||
return matchSegments(
|
|
||||||
patternIndex + 1,
|
|
||||||
pathIndex + 1,
|
|
||||||
depth + 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPatternPart !== currentPathPart) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchSegments(0, 0, 0);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { assertEquals } from "@test/assert";
|
import { assertEquals } from "@test/assert";
|
||||||
import { REGIONS } from "@server/db/regions";
|
import { REGIONS } from "@server/db/regions";
|
||||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
|
||||||
|
|
||||||
function isIpInRegion(
|
function isIpInRegion(
|
||||||
ipCountryCode: string | undefined,
|
ipCountryCode: string | undefined,
|
||||||
@@ -34,6 +33,76 @@ function isIpInRegion(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
|
// Normalize and split paths into segments
|
||||||
|
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||||
|
const patternParts = normalize(pattern);
|
||||||
|
const pathParts = normalize(path);
|
||||||
|
|
||||||
|
// Recursive function to try different wildcard matches
|
||||||
|
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||||
|
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||||
|
const currentPatternPart = patternParts[patternIndex];
|
||||||
|
const currentPathPart = pathParts[pathIndex];
|
||||||
|
|
||||||
|
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||||
|
if (patternIndex >= patternParts.length) {
|
||||||
|
const result = pathIndex >= pathParts.length;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've consumed all path parts but still have pattern parts
|
||||||
|
if (pathIndex >= pathParts.length) {
|
||||||
|
// The only way this can match is if all remaining pattern parts are wildcards
|
||||||
|
const remainingPattern = patternParts.slice(patternIndex);
|
||||||
|
const result = remainingPattern.every((p) => p === "*");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For full segment wildcards, try consuming different numbers of path segments
|
||||||
|
if (currentPatternPart === "*") {
|
||||||
|
// Try consuming 0 segments (skip the wildcard)
|
||||||
|
if (matchSegments(patternIndex + 1, pathIndex)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try consuming current segment and recursively try rest
|
||||||
|
if (matchSegments(patternIndex, pathIndex + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||||
|
if (currentPatternPart.includes("*")) {
|
||||||
|
// Convert the pattern segment to a regex pattern
|
||||||
|
const regexPattern = currentPatternPart
|
||||||
|
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||||
|
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||||
|
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`);
|
||||||
|
|
||||||
|
if (regex.test(currentPathPart)) {
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular segments, they must match exactly
|
||||||
|
if (currentPatternPart !== currentPathPart) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next segments in both pattern and path
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = matchSegments(0, 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
console.log("Running path matching tests...");
|
console.log("Running path matching tests...");
|
||||||
|
|
||||||
@@ -239,121 +308,6 @@ function runTests() {
|
|||||||
console.log("All path matching tests passed!");
|
console.log("All path matching tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
function runSpecialCharacterTests() {
|
|
||||||
console.log("\nRunning special character tests...");
|
|
||||||
|
|
||||||
let threw = false;
|
|
||||||
try {
|
|
||||||
isPathAllowed("(api*", "anything");
|
|
||||||
isPathAllowed("a(b*", "a(bc");
|
|
||||||
isPathAllowed("c[d*", "c[de");
|
|
||||||
isPathAllowed("x{2}*", "x{2}y");
|
|
||||||
isPathAllowed("a|b*", "a|bc");
|
|
||||||
isPathAllowed("back\\slash*", "back\\slashed");
|
|
||||||
} catch (e) {
|
|
||||||
threw = true;
|
|
||||||
console.error(
|
|
||||||
"Patterns accepted by isValidUrlGlobPattern crashed the matcher:",
|
|
||||||
e instanceof Error ? e.message : e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertEquals(
|
|
||||||
threw,
|
|
||||||
false,
|
|
||||||
"Patterns with regex metacharacters must not throw"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("(api*", "(api-v1"),
|
|
||||||
true,
|
|
||||||
"Parenthesis should be treated as a literal character"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("(api*", "xapi-v1"),
|
|
||||||
false,
|
|
||||||
"Parenthesis should not match other characters"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("a(b)*", "a(b)c"),
|
|
||||||
true,
|
|
||||||
"Parentheses pair should be treated as literal characters"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("*.png", "image.png"),
|
|
||||||
true,
|
|
||||||
"Dot should match a literal dot"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("*.png", "imageXpng"),
|
|
||||||
false,
|
|
||||||
"Dot should not act as a regex wildcard"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("v1.0*", "v1.0.1"),
|
|
||||||
true,
|
|
||||||
"Version-like literal should match itself"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("v1.0*", "v1x0-beta"),
|
|
||||||
false,
|
|
||||||
"Version-like literal should not match arbitrary characters"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("a+b*", "a+bc"),
|
|
||||||
true,
|
|
||||||
"Plus should be treated as a literal character"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("a+b*", "aaabc"),
|
|
||||||
false,
|
|
||||||
"Plus should not act as a regex quantifier"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("$ref*", "$refs"),
|
|
||||||
true,
|
|
||||||
"Dollar sign should be treated as a literal character"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("price$*", "price$100"),
|
|
||||||
true,
|
|
||||||
"Dollar sign mid-pattern should be treated as a literal character"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("^start*", "^started"),
|
|
||||||
true,
|
|
||||||
"Caret should be treated as a literal character"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("a|b*", "a|bc"),
|
|
||||||
true,
|
|
||||||
"Pipe should be treated as a literal character"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("a|b*", "a"),
|
|
||||||
false,
|
|
||||||
"Pipe should not act as regex alternation"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("file?*", "fileX"),
|
|
||||||
true,
|
|
||||||
"Question mark should still act as a single-character wildcard"
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
isPathAllowed("api/*", "api/" + "x/".repeat(50)),
|
|
||||||
true,
|
|
||||||
"Deeply nested paths should still match"
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("All special character tests passed!");
|
|
||||||
}
|
|
||||||
|
|
||||||
function runRegionTests() {
|
function runRegionTests() {
|
||||||
console.log("\nRunning isIpInRegion tests...");
|
console.log("\nRunning isIpInRegion tests...");
|
||||||
|
|
||||||
@@ -413,7 +367,6 @@ function runRegionTests() {
|
|||||||
// Run all tests
|
// Run all tests
|
||||||
try {
|
try {
|
||||||
runTests();
|
runTests();
|
||||||
runSpecialCharacterTests();
|
|
||||||
runRegionTests();
|
runRegionTests();
|
||||||
console.log("\n✅ All tests passed!");
|
console.log("\n✅ All tests passed!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -1091,7 +1090,143 @@ async function checkRules(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isPathAllowed };
|
export function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
|
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||||
|
|
||||||
|
// Normalize and split paths into segments
|
||||||
|
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||||
|
const patternParts = normalize(pattern);
|
||||||
|
const pathParts = normalize(path);
|
||||||
|
|
||||||
|
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
|
||||||
|
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
|
||||||
|
|
||||||
|
// Maximum recursion depth to prevent stack overflow and memory issues
|
||||||
|
const MAX_RECURSION_DEPTH = 100;
|
||||||
|
|
||||||
|
// Recursive function to try different wildcard matches
|
||||||
|
function matchSegments(
|
||||||
|
patternIndex: number,
|
||||||
|
pathIndex: number,
|
||||||
|
depth: number = 0
|
||||||
|
): boolean {
|
||||||
|
// Check recursion depth limit
|
||||||
|
if (depth > MAX_RECURSION_DEPTH) {
|
||||||
|
logger.warn(
|
||||||
|
`Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = " ".repeat(depth); // Indent based on recursion depth
|
||||||
|
const currentPatternPart = patternParts[patternIndex];
|
||||||
|
const currentPathPart = pathParts[pathIndex];
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||||
|
if (patternIndex >= patternParts.length) {
|
||||||
|
const result = pathIndex >= pathParts.length;
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've consumed all path parts but still have pattern parts
|
||||||
|
if (pathIndex >= pathParts.length) {
|
||||||
|
// The only way this can match is if all remaining pattern parts are wildcards
|
||||||
|
const remainingPattern = patternParts.slice(patternIndex);
|
||||||
|
const result = remainingPattern.every((p) => p === "*");
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For full segment wildcards, try consuming different numbers of path segments
|
||||||
|
if (currentPatternPart === "*") {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try consuming 0 segments (skip the wildcard)
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to skip wildcard (consume 0 segments)`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by skipping wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try consuming current segment and recursively try rest
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by consuming segment for wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`${indent}Failed to match wildcard`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||||
|
if (currentPatternPart.includes("*")) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert the pattern segment to a regex pattern
|
||||||
|
const regexPattern = currentPatternPart
|
||||||
|
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||||
|
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||||
|
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`);
|
||||||
|
|
||||||
|
if (regex.test(currentPathPart)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return matchSegments(
|
||||||
|
patternIndex + 1,
|
||||||
|
pathIndex + 1,
|
||||||
|
depth + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular segments, they must match exactly
|
||||||
|
if (currentPatternPart !== currentPathPart) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
// Move to next segments in both pattern and path
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = matchSegments(0, 0, 0);
|
||||||
|
logger.debug(`Final result: ${result}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function isIpInGeoIP(
|
async function isIpInGeoIP(
|
||||||
ipCountryCode: string | undefined,
|
ipCountryCode: string | undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user