Sanitize values before including in treafik config

This commit is contained in:
Owen
2026-10-27 15:10:08 -04:00
parent a85b633ca0
commit f4410cdbeb
14 changed files with 157 additions and 26 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import config from "@server/lib/config";
import { applyMtlsConfig } from "./mtls";
import { buildHostRule } from "./rule";
import { sanitize } from "./utils";
export type BrowserGatewayResourceRow = {
@@ -174,7 +175,7 @@ export function buildBrowserGatewayConfig(params: {
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
const hostRule = buildHostRule(fullDomain);
// Build TLS config
let tls = resolveTls({
+7 -2
View File
@@ -50,7 +50,12 @@ import {
import { applyPathRewriteMiddleware } from "./middleware";
import { applyMtlsConfig, getMtlsCaCertificatesByResource } from "./mtls";
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
import {
appendPathMatch,
buildHostRule,
computeRoutePriority,
ruleValue
} from "./rule";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
import { TargetWithSite } from "./types";
import { encodePath, sanitize, validatePathRewriteConfig } from "./utils";
@@ -857,7 +862,7 @@ export async function getTraefikConfig(
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
const rule = `${buildHostRule(fullDomain)} && ClientIP(${ruleValue(exitNode.address)})`; // restrict to coming from the exit node ip range that the client is connected to
// siteResource aliases don't have a per-domain cert resolver
// stored, so always fall back to the global defaults.
+56 -1
View File
@@ -1,8 +1,11 @@
import { assertEquals } from "../../../test/assert";
import {
appendPathMatch,
buildHostRule,
computeRedirectPriority,
computeRoutePriority,
matchesPath
matchesPath,
ruleValue
} from "./rule";
function runTests() {
@@ -128,6 +131,58 @@ function runTests() {
);
console.log("All priority tests passed!");
console.log("Running rule quoting tests...");
// Ordinary values keep the backtick form
assertEquals(ruleValue("/api"), "`/api`", "plain value");
assertEquals(
buildHostRule("app.example.com"),
"Host(`app.example.com`)",
"plain host"
);
assertEquals(
buildHostRule("*.example.com", true),
"HostRegexp(`^[^.]+\\.example\\.com$`)",
"wildcard host"
);
assertEquals(
appendPathMatch("Host(`a.com`)", "/x", "exact"),
"Host(`a.com`) && Path(`/x`)",
"plain exact path"
);
assertEquals(
appendPathMatch("Host(`a.com`)", "^/v[0-9]+/", "regex"),
"Host(`a.com`) && PathRegexp(`^/v[0-9]+/`)",
"plain regex path"
);
// A backtick must not be able to close the literal and inject matchers
const injectedPath = "/a`) || Host(`victim.example.com`) && PathPrefix(`/";
assertEquals(
appendPathMatch("Host(`attacker.example.com`)", injectedPath, "exact"),
'Host(`attacker.example.com`) && Path("/a`) || Host(`victim.example.com`) && PathPrefix(`/")',
"injected exact path stays one literal"
);
assertEquals(
appendPathMatch("Host(`a.com`)", "`) || HostRegexp(`.+", "regex"),
'Host(`a.com`) && PathRegexp("`) || HostRegexp(`.+")',
"injected regex path stays one literal"
);
assertEquals(
buildHostRule("x`) || Host(`victim.example.com"),
'Host("x`) || Host(`victim.example.com")',
"injected host stays one literal"
);
// Double-quoted form escapes quotes, backslashes and control characters
assertEquals(
ruleValue('`"\\\n'),
'"`\\"\\\\\\n"',
"escapes in quoted form"
);
console.log("All rule quoting tests passed!");
}
try {
+29 -5
View File
@@ -1,3 +1,27 @@
// Characters that cannot appear inside a Go raw (backtick) string literal:
// the backtick itself would terminate it, and control characters are
// rejected by the scanner Traefik uses to parse rules.
const UNSAFE_RAW_RULE_VALUE = /[`\u0000-\u001f\u007f]/;
/**
* Quote a value for use as a matcher argument in a Traefik rule.
*
* Every value interpolated into a rule must go through this. Rules are
* parsed as Go expressions, so a value containing a backtick could otherwise
* close its literal and inject arbitrary matchers (e.g. a Host() clause for
* another org's domain). Ordinary values keep the conventional backtick form;
* anything else is emitted as a double-quoted Go string literal, which
* Traefik unquotes back to exactly the original value.
*/
export function ruleValue(value: string): string {
if (!UNSAFE_RAW_RULE_VALUE.test(value)) {
return `\`${value}\``;
}
// JSON string escapes are a subset of Go's interpreted string escapes.
// Lone surrogates are replaced since Go rejects them in \u escapes.
return JSON.stringify(value.toWellFormed());
}
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
// Wildcard resources match any single subdomain via HostRegexp.
export function buildHostRule(
@@ -7,9 +31,9 @@ export function buildHostRule(
if (wildcard && fullDomain.startsWith("*.")) {
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
return `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
return `HostRegexp(${ruleValue(`^[^.]+\\.${escaped}$`)})`;
}
return `Host(\`${fullDomain}\`)`;
return `Host(${ruleValue(fullDomain)})`;
}
// Append a path-matching clause to a Traefik rule based on the resource's
@@ -27,11 +51,11 @@ export function appendPathMatch(
}
if (pathMatchType === "exact") {
return `${rule} && Path(\`${p}\`)`;
return `${rule} && Path(${ruleValue(p)})`;
} else if (pathMatchType === "prefix") {
return `${rule} && PathPrefix(\`${p}\`)`;
return `${rule} && PathPrefix(${ruleValue(p)})`;
} else if (pathMatchType === "regex") {
return `${rule} && PathRegexp(\`${path}\`)`; // this is the raw path because it's a regex
return `${rule} && PathRegexp(${ruleValue(path)})`; // this is the raw path because it's a regex
}
return rule;
}
+4 -3
View File
@@ -1,4 +1,5 @@
import config from "@server/lib/config";
import { buildHostRule } from "./rule";
export type SiteResourceAliasRow = {
siteResourceId: number;
@@ -82,7 +83,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 100
};
@@ -97,7 +98,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 100,
tls
};
@@ -106,7 +107,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
rule: `${buildHostRule(fullDomain)} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
tls
};