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