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
+22 -4
View File
@@ -6,7 +6,7 @@ import { MtlsSchema } from "#dynamic/lib/blueprints/MtlsSchema";
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
import { isValidRegionId } from "@server/db/regions";
import { isValidHttpMethodList } from "@server/lib/validators";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import { routePathSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import {
aiBudgetEnforcementSchema,
@@ -91,7 +91,7 @@ export const TargetSchema = z.object({
port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true),
"internal-port": z.int().min(1).max(65535).optional(),
path: z.string().optional(),
path: routePathSchema.optional(),
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(),
healthcheck: TargetHealthCheckSchema.optional(),
rewritePath: z.string().optional(), // deprecated alias
@@ -328,6 +328,24 @@ export const AuthDaemonSchema = z
}
);
// A full-domain must be made of hostname labels ("*" is allowed as a label;
// where it may appear is checked by the resource schemas). Anything else
// would end up in a Traefik Host() rule and certificate requests verbatim.
const fullDomainSchema = z
.string()
.refine(
(val) =>
val === "" ||
val
.split(".")
.every(
(label) =>
label === "*" ||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label)
),
{ message: "full-domain must be a valid hostname" }
);
// Schema for individual resource
export const PublicResourceSchema = z
.object({
@@ -341,7 +359,7 @@ export const PublicResourceSchema = z
policy: z.string().optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(),
"full-domain": fullDomainSchema.optional(),
"proxy-port": z.int().min(1).max(65535).optional(),
enabled: z.boolean().optional(),
targets: z.array(TargetSchema.nullable()).optional().default([]),
@@ -713,7 +731,7 @@ export const PrivateResourceSchema = z
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false),
"full-domain": z.string().optional(),
"full-domain": fullDomainSchema.optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional().nullable(),
alias: z
+10
View File
@@ -124,6 +124,16 @@ export async function validateAndConstructDomain(
if (domainRes.domains.type === "ns") {
if (subdomain) {
if (!isWildcard) {
const parsedSubdomain =
subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return {
success: false,
error: fromError(parsedSubdomain.error).toString()
};
}
}
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
+11
View File
@@ -62,3 +62,14 @@ export const privateNamespaceSubdomainSchema = z
.min(1, "Namespace subdomain must be at least 1 character long")
.max(32, "Namespace subdomain must be at most 32 characters long")
.transform((val) => val.toLowerCase());
/**
* A path matched by a Traefik router (target path, redirect match path).
* Backticks and control characters are never valid in a URL path and are
* rejected so the value can't break out of its literal in the router rule.
*/
export const routePathSchema = z
.string()
.refine((val) => !/[`\u0000-\u001f\u007f]/.test(val), {
message: "Path cannot contain backticks or control characters"
});
+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
};
@@ -71,7 +71,8 @@ import {
import {
appendPathMatch,
buildHostRule,
computeRoutePriority
computeRoutePriority,
ruleValue
} from "@server/lib/traefik/rule";
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
import { ResourceWithTargets } from "@server/lib/traefik/types";
@@ -1189,7 +1190,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
let tls: any = {};
if (!pangolinCertModeEnabled) {
@@ -1345,7 +1346,7 @@ export async function getTraefikConfig(
],
middlewares: [landingRateLimitMiddlewareName],
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
rule: `${buildHostRule(fullDomain)} && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 203,
tls: tls
};
@@ -1367,7 +1368,7 @@ export async function getTraefikConfig(
redirectToRootMiddlewareName
],
service: "landing-service",
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 202,
tls: tls
};
@@ -1379,7 +1380,7 @@ export async function getTraefikConfig(
],
middlewares: [redirectHttpsMiddlewareName],
service: "landing-service",
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 201
};
}
+2 -1
View File
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/lib/schemas";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import {
@@ -35,7 +36,7 @@ const bodySchema = z
name: z.string().nonempty(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
subdomain: subdomainSchema.optional().nullable(),
destinationHost: redirectDestinationHostSchema,
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
+2 -1
View File
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/lib/schemas";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import {
@@ -36,7 +37,7 @@ const bodySchema = z.strictObject({
niceId: redirectNiceIdSchema.optional(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
subdomain: subdomainSchema.optional().nullable(),
destinationHost: redirectDestinationHostSchema.optional(),
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
import { routePathSchema } from "@server/lib/schemas";
export const redirectNiceIdSchema = z
.string()
@@ -20,7 +21,7 @@ export const redirectRewritePathTypeSchema = z.enum([
"stripPrefix"
]);
export const redirectMatchPathSchema = z.string().nonempty();
export const redirectMatchPathSchema = routePathSchema.nonempty();
export function isValidRegex(pattern: string): boolean {
try {
+2 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { routePathSchema } from "@server/lib/schemas";
import {
db,
statusHistory,
@@ -76,7 +77,7 @@ const createTargetSchema = z
hcTlsServerName: z.string().optional().nullable(),
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
path: z.string().optional().nullable(),
path: routePathSchema.optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
+2 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { routePathSchema } from "@server/lib/schemas";
import { db, targetHealthCheck } from "@server/db";
import { aiProviders, newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm";
@@ -54,7 +55,7 @@ const updateTargetBodySchema = z
hcTlsServerName: z.string().optional().nullable(),
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
path: z.string().optional().nullable(),
path: routePathSchema.optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()