mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-09 20:26:40 +00:00
fix: use collision-free path encoding for Traefik router key generation
This commit is contained in:
committed by
Shreyas Papinwar
parent
66c377a5c9
commit
5f18c06e03
@@ -14,7 +14,7 @@ import logger from "@server/logger";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { resources, sites, Target, targets } from "@server/db";
|
import { resources, sites, Target, targets } from "@server/db";
|
||||||
import createPathRewriteMiddleware from "./middleware";
|
import createPathRewriteMiddleware from "./middleware";
|
||||||
import { sanitize, validatePathRewriteConfig } from "./utils";
|
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
@@ -44,7 +44,7 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
@@ -127,7 +127,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
const targetPath = encodePath(row.path); // Encode path preserving uniqueness for key generation
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
|
|||||||
170
server/lib/traefik/pathEncoding.test.ts
Normal file
170
server/lib/traefik/pathEncoding.test.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { assertEquals } from "@test/assert";
|
||||||
|
import { encodePath, sanitize } from "./utils";
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log("Running path encoding tests...\n");
|
||||||
|
|
||||||
|
// Test 1: null and empty return empty string
|
||||||
|
{
|
||||||
|
assertEquals(encodePath(null), "", "null should return empty");
|
||||||
|
assertEquals(
|
||||||
|
encodePath(undefined),
|
||||||
|
"",
|
||||||
|
"undefined should return empty"
|
||||||
|
);
|
||||||
|
assertEquals(encodePath(""), "", "empty string should return empty");
|
||||||
|
console.log(" PASS: null/undefined/empty return empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: root path "/" encodes to something non-empty
|
||||||
|
{
|
||||||
|
const result = encodePath("/");
|
||||||
|
assertEquals(result !== "", true, "root path should not be empty");
|
||||||
|
assertEquals(result, "2f", "root path should encode to hex of '/'");
|
||||||
|
console.log(" PASS: root path encodes to non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: different paths produce different encoded values
|
||||||
|
{
|
||||||
|
const paths = [
|
||||||
|
"/",
|
||||||
|
"/api",
|
||||||
|
"/a/b",
|
||||||
|
"/a-b",
|
||||||
|
"/a.b",
|
||||||
|
"/a_b",
|
||||||
|
"/api/v1",
|
||||||
|
"/api/v2"
|
||||||
|
];
|
||||||
|
const encoded = new Set<string>();
|
||||||
|
let collision = false;
|
||||||
|
for (const p of paths) {
|
||||||
|
const e = encodePath(p);
|
||||||
|
if (encoded.has(e)) {
|
||||||
|
collision = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
encoded.add(e);
|
||||||
|
}
|
||||||
|
assertEquals(collision, false, "no two different paths should collide");
|
||||||
|
console.log(" PASS: all different paths produce unique encodings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: alphanumeric characters pass through unchanged
|
||||||
|
{
|
||||||
|
assertEquals(
|
||||||
|
encodePath("/api"),
|
||||||
|
"2fapi",
|
||||||
|
"/api should encode slash only"
|
||||||
|
);
|
||||||
|
assertEquals(encodePath("/v1"), "2fv1", "/v1 should encode slash only");
|
||||||
|
console.log(" PASS: alphanumeric characters preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: special characters are hex-encoded
|
||||||
|
{
|
||||||
|
const dotEncoded = encodePath("/a.b");
|
||||||
|
const dashEncoded = encodePath("/a-b");
|
||||||
|
const slashEncoded = encodePath("/a/b");
|
||||||
|
const underscoreEncoded = encodePath("/a_b");
|
||||||
|
|
||||||
|
// all should be different
|
||||||
|
const set = new Set([
|
||||||
|
dotEncoded,
|
||||||
|
dashEncoded,
|
||||||
|
slashEncoded,
|
||||||
|
underscoreEncoded
|
||||||
|
]);
|
||||||
|
assertEquals(
|
||||||
|
set.size,
|
||||||
|
4,
|
||||||
|
"dot, dash, slash, underscore paths should all be unique"
|
||||||
|
);
|
||||||
|
console.log(" PASS: special characters produce unique encodings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: full key generation - different paths create different keys
|
||||||
|
{
|
||||||
|
function makeKey(
|
||||||
|
resourceId: number,
|
||||||
|
path: string | null,
|
||||||
|
pathMatchType: string | null
|
||||||
|
) {
|
||||||
|
const targetPath = encodePath(path);
|
||||||
|
const pmt = pathMatchType || "";
|
||||||
|
const pathKey = [targetPath, pmt, "", ""].filter(Boolean).join("-");
|
||||||
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
|
return sanitize(mapKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keySlash = makeKey(1, "/", "prefix");
|
||||||
|
const keyApi = makeKey(1, "/api", "prefix");
|
||||||
|
const keyNull = makeKey(1, null, null);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
keySlash !== keyApi,
|
||||||
|
true,
|
||||||
|
"/ and /api should have different keys"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
keySlash !== keyNull,
|
||||||
|
true,
|
||||||
|
"/ and null should have different keys"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
keyApi !== keyNull,
|
||||||
|
true,
|
||||||
|
"/api and null should have different keys"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" PASS: different paths create different resource map keys"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: same path always produces same key (deterministic)
|
||||||
|
{
|
||||||
|
assertEquals(
|
||||||
|
encodePath("/api"),
|
||||||
|
encodePath("/api"),
|
||||||
|
"same input should produce same output"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
encodePath("/a/b/c"),
|
||||||
|
encodePath("/a/b/c"),
|
||||||
|
"same input should produce same output"
|
||||||
|
);
|
||||||
|
console.log(" PASS: encoding is deterministic");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: encoded result is alphanumeric (valid for Traefik names after sanitize)
|
||||||
|
{
|
||||||
|
const paths = [
|
||||||
|
"/",
|
||||||
|
"/api",
|
||||||
|
"/a/b",
|
||||||
|
"/a-b",
|
||||||
|
"/a.b",
|
||||||
|
"/complex/path/here"
|
||||||
|
];
|
||||||
|
for (const p of paths) {
|
||||||
|
const e = encodePath(p);
|
||||||
|
const isAlphanumeric = /^[a-zA-Z0-9]+$/.test(e);
|
||||||
|
assertEquals(
|
||||||
|
isAlphanumeric,
|
||||||
|
true,
|
||||||
|
`encodePath("${p}") = "${e}" should be alphanumeric`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(" PASS: encoded values are alphanumeric");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nAll path encoding tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -13,6 +13,26 @@ export function sanitize(input: string | null | undefined): string | undefined {
|
|||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a URL path into a collision-free alphanumeric string suitable for use
|
||||||
|
* in Traefik router/service names and map keys.
|
||||||
|
*
|
||||||
|
* Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric
|
||||||
|
* character as its hex code. Different paths always produce different outputs.
|
||||||
|
*
|
||||||
|
* encodePath("/api") => "2fapi"
|
||||||
|
* encodePath("/a/b") => "2fa2fb"
|
||||||
|
* encodePath("/a-b") => "2fa2db" (different from /a/b)
|
||||||
|
* encodePath("/") => "2f"
|
||||||
|
* encodePath(null) => ""
|
||||||
|
*/
|
||||||
|
export function encodePath(path: string | null | undefined): string {
|
||||||
|
if (!path) return "";
|
||||||
|
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
||||||
|
return ch.charCodeAt(0).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function validatePathRewriteConfig(
|
export function validatePathRewriteConfig(
|
||||||
path: string | null,
|
path: string | null,
|
||||||
pathMatchType: string | null,
|
pathMatchType: string | null,
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ import {
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||||
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
|
import {
|
||||||
|
sanitize,
|
||||||
|
encodePath,
|
||||||
|
validatePathRewriteConfig
|
||||||
|
} from "@server/lib/traefik/utils";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
||||||
import {
|
import {
|
||||||
@@ -170,7 +174,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
const targetPath = encodePath(row.path); // Encode path preserving uniqueness for key generation
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
|
|||||||
Reference in New Issue
Block a user