diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 1c13187f1..a74c08212 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -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 diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index ffd452f36..33143e044 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -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; diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index 813849d52..21f19b2cd 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -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" + }); diff --git a/server/lib/traefik/browserGateway.ts b/server/lib/traefik/browserGateway.ts index 4537bd6cc..8e647f3c5 100644 --- a/server/lib/traefik/browserGateway.ts +++ b/server/lib/traefik/browserGateway.ts @@ -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({ diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index a6e76464a..1060d6bd4 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -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. diff --git a/server/lib/traefik/rule.test.ts b/server/lib/traefik/rule.test.ts index 47e21f85a..a7f9884c2 100644 --- a/server/lib/traefik/rule.test.ts +++ b/server/lib/traefik/rule.test.ts @@ -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 { diff --git a/server/lib/traefik/rule.ts b/server/lib/traefik/rule.ts index e304e9ef9..cf46dc8e7 100644 --- a/server/lib/traefik/rule.ts +++ b/server/lib/traefik/rule.ts @@ -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; } diff --git a/server/lib/traefik/siteResourceAlias.ts b/server/lib/traefik/siteResourceAlias.ts index 3c7392064..b74123ff7 100644 --- a/server/lib/traefik/siteResourceAlias.ts +++ b/server/lib/traefik/siteResourceAlias.ts @@ -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 }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 77b099c2b..54886262a 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -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 }; } diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index de682e740..23c1dc994 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -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(), diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 31038a74c..f22080cbe 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -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(), diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index fa304fd9e..1804dbef4 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -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 { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index fbcaa8439..101be10d9 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -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() diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index e1ca95f5f..da1ab41ca 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -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()