mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-27 22:19:07 +01:00
Sanitize values before including in treafik config
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user