From 5f18c06e03695b2567ccd802c830d6533d825773 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Mon, 23 Feb 2026 12:25:24 +0530 Subject: [PATCH] fix: use collision-free path encoding for Traefik router key generation --- server/lib/traefik/getTraefikConfig.ts | 6 +- server/lib/traefik/pathEncoding.test.ts | 170 ++++++++++++++++++ server/lib/traefik/utils.ts | 20 +++ .../private/lib/traefik/getTraefikConfig.ts | 8 +- 4 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 server/lib/traefik/pathEncoding.test.ts diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 06754ffa2..f503b88a3 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -14,7 +14,7 @@ import logger from "@server/logger"; import config from "@server/lib/config"; import { resources, sites, Target, targets } from "@server/db"; import createPathRewriteMiddleware from "./middleware"; -import { sanitize, validatePathRewriteConfig } from "./utils"; +import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; @@ -44,7 +44,7 @@ export async function getTraefikConfig( filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, - allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE + allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE ): Promise { // 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 @@ -127,7 +127,7 @@ export async function getTraefikConfig( resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; 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 rewritePath = row.rewritePath || ""; const rewritePathType = row.rewritePathType || ""; diff --git a/server/lib/traefik/pathEncoding.test.ts b/server/lib/traefik/pathEncoding.test.ts new file mode 100644 index 000000000..7e6d9266f --- /dev/null +++ b/server/lib/traefik/pathEncoding.test.ts @@ -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(); + 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); +} diff --git a/server/lib/traefik/utils.ts b/server/lib/traefik/utils.ts index ec0eae5b3..da876fa07 100644 --- a/server/lib/traefik/utils.ts +++ b/server/lib/traefik/utils.ts @@ -13,6 +13,26 @@ export function sanitize(input: string | null | undefined): string | undefined { .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( path: string | null, pathMatchType: string | null, diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index f0343c5d4..1652427b2 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -34,7 +34,11 @@ import { import logger from "@server/logger"; import config from "@server/lib/config"; 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 createPathRewriteMiddleware from "@server/lib/traefik/middleware"; import { @@ -170,7 +174,7 @@ export async function getTraefikConfig( resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; 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 rewritePath = row.rewritePath || ""; const rewritePathType = row.rewritePathType || "";