fix: enforce server trust boundaries
This commit is contained in:
@@ -513,6 +513,9 @@ verify_server_guide() {
|
||||
"core" \
|
||||
"UID/GID 10001" \
|
||||
"thothii-ops" \
|
||||
"-m 2770 /srv/thothii/operator" \
|
||||
"chmod 0660 /srv/thothii/operator/server.env" \
|
||||
"THT_THOTHCTL_OUTPUT_DIRECTORY=/srv/thothii/operator/build-output" \
|
||||
"/srv/thothii" \
|
||||
"example operator root" \
|
||||
"/run/secrets" \
|
||||
@@ -655,19 +658,47 @@ const identities = [
|
||||
["admin", "Is-Admin", "thoth_is_admin", "x_thoth_is_admin"],
|
||||
];
|
||||
function escaped(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function directiveBlock(text, marker) {
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) throw new Error(`Nginx proxy lacks scoped block: ${marker}`);
|
||||
const opening = text.indexOf("{", start);
|
||||
let depth = 0;
|
||||
for (let index = opening; index < text.length; index++) {
|
||||
if (text[index] === "{") depth++;
|
||||
if (text[index] === "}" && --depth === 0) return text.slice(opening + 1, index);
|
||||
}
|
||||
throw new Error(`Nginx proxy has unterminated scoped block: ${marker}`);
|
||||
}
|
||||
const authLocation = directiveBlock(block, "location = /_authenticate {");
|
||||
const frontendLocation = directiveBlock(block, "location / {");
|
||||
for (const [label, publicName, variable, upstream] of identities) {
|
||||
const trustedName = publicName === "Is-Admin" ? "Is-Admin" : publicName;
|
||||
const publicClears = block.match(new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`, "g")) || [];
|
||||
if (publicClears.length < 2) throw new Error(`Nginx proxy does not clear inbound ${label} identity`);
|
||||
const publicClear = new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`);
|
||||
const trustedHeader = `X-Thoth-Trusted-${trustedName}`;
|
||||
if (!new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+"";`).test(block)) {
|
||||
throw new Error(`Nginx proxy does not clear inbound trusted ${label} identity`);
|
||||
const trustedClear = new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+"";`);
|
||||
const authPublicAt = authLocation.search(publicClear);
|
||||
const authTrustedAt = authLocation.search(trustedClear);
|
||||
if (authPublicAt < 0) {
|
||||
throw new Error(`Nginx auth location does not clear inbound ${label} identity`);
|
||||
}
|
||||
if (!new RegExp(`auth_request_set\\s+\\$${variable}\\s+\\$upstream_http_${upstream};`, "m").test(block.replace(/\s+/g, " "))) {
|
||||
throw new Error(`Nginx proxy does not capture authenticated ${label} identity`);
|
||||
if (authTrustedAt < 0) {
|
||||
throw new Error(`Nginx auth location does not clear inbound trusted ${label} identity`);
|
||||
}
|
||||
if (!new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`).test(block)) {
|
||||
throw new Error(`Nginx proxy does not map authenticated ${label} identity`);
|
||||
const frontendPublicAt = frontendLocation.search(publicClear);
|
||||
if (frontendPublicAt < 0) {
|
||||
throw new Error(`Nginx frontend location does not clear inbound ${label} identity`);
|
||||
}
|
||||
const normalizedFrontend = frontendLocation.replace(/\s+/g, " ");
|
||||
const captureAt = normalizedFrontend.search(new RegExp(`auth_request_set\\s+\\$${variable}\\s+\\$upstream_http_${upstream};`));
|
||||
if (captureAt < 0) {
|
||||
throw new Error(`Nginx frontend location does not capture authenticated ${label} identity`);
|
||||
}
|
||||
const mapAt = normalizedFrontend.search(new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`));
|
||||
if (mapAt < 0) {
|
||||
throw new Error(`Nginx frontend location does not map authenticated ${label} identity`);
|
||||
}
|
||||
if (new RegExp(`auth_request_set\\s+\\$${variable}|proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`).test(authLocation)) {
|
||||
throw new Error(`Nginx auth location performs a forbidden ${label} capture or mapping`);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
@@ -706,26 +737,148 @@ if (/127\.0\.0\.1:8787|\bcore:8787\b/.test(block) || !block.includes("127.0.0.1:
|
||||
for (const token of tokens) {
|
||||
if (!block.includes(token)) throw new Error(`Caddy proxy lacks structural token: ${token}`);
|
||||
}
|
||||
function directiveBlock(text, marker) {
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) throw new Error(`Caddy proxy lacks scoped block: ${marker}`);
|
||||
const opening = text.indexOf("{", start);
|
||||
let depth = 0;
|
||||
for (let index = opening; index < text.length; index++) {
|
||||
if (text[index] === "{") depth++;
|
||||
if (text[index] === "}" && --depth === 0) return {start, end: index, body: text.slice(opening + 1, index)};
|
||||
}
|
||||
throw new Error(`Caddy proxy has unterminated scoped block: ${marker}`);
|
||||
}
|
||||
const route = directiveBlock(block, "route {");
|
||||
const forward = directiveBlock(route.body, "forward_auth auth-gateway:4180 {");
|
||||
const forwardAt = route.body.indexOf("forward_auth auth-gateway:4180 {");
|
||||
for (const [label, publicName, trustedName] of [
|
||||
["issuer", "X-Thoth-Principal-Issuer", "X-Thoth-Trusted-Principal-Issuer"],
|
||||
["subject", "X-Thoth-Principal-Subject", "X-Thoth-Trusted-Principal-Subject"],
|
||||
["display", "X-Thoth-Principal-Display-Name", "X-Thoth-Trusted-Principal-Display-Name"],
|
||||
["admin", "X-Thoth-Is-Admin", "X-Thoth-Trusted-Is-Admin"],
|
||||
]) {
|
||||
if (!block.includes(`request_header -${publicName}`)) {
|
||||
const publicClearAt = route.body.indexOf(`request_header -${publicName}`);
|
||||
if (publicClearAt < 0) {
|
||||
throw new Error(`Caddy proxy does not clear inbound ${label} identity`);
|
||||
}
|
||||
if (!block.includes(`request_header -${trustedName}`)) {
|
||||
const trustedClearAt = route.body.indexOf(`request_header -${trustedName}`);
|
||||
if (trustedClearAt < 0) {
|
||||
throw new Error(`Caddy proxy does not clear inbound trusted ${label} identity`);
|
||||
}
|
||||
if (!block.includes(`${publicName}>${trustedName}`)) {
|
||||
if (publicClearAt > forwardAt || trustedClearAt > forwardAt) {
|
||||
throw new Error("Caddy identity clears must precede forward_auth");
|
||||
}
|
||||
if (!forward.body.includes(`${publicName}>${trustedName}`)) {
|
||||
throw new Error(`Caddy proxy does not map authenticated ${label} identity`);
|
||||
}
|
||||
}
|
||||
const outsideForward = route.body.slice(0, forward.start) + route.body.slice(forward.end + 1);
|
||||
if (/X-Thoth-(?:Principal-[^\s>]+|Is-Admin)>X-Thoth-Trusted-/.test(outsideForward)) {
|
||||
throw new Error("Caddy maps identity outside the authenticated response stage");
|
||||
}
|
||||
NODE
|
||||
echo "Caddy reverse-proxy guide contract passed"
|
||||
}
|
||||
|
||||
verify_caddy_adapted_identity_order() {
|
||||
local adapted="$1"
|
||||
node - "$adapted" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const document = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const publicHeaders = [
|
||||
"X-Thoth-Principal-Issuer", "X-Thoth-Principal-Subject",
|
||||
"X-Thoth-Principal-Display-Name", "X-Thoth-Is-Admin",
|
||||
];
|
||||
const trustedHeaders = [
|
||||
"X-Thoth-Trusted-Principal-Issuer", "X-Thoth-Trusted-Principal-Subject",
|
||||
"X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Trusted-Is-Admin",
|
||||
];
|
||||
function authUpstream(handler) {
|
||||
return handler?.handler === "reverse_proxy" &&
|
||||
(handler.upstreams || []).some((upstream) => upstream.dial === "auth-gateway:4180");
|
||||
}
|
||||
function frontendUpstream(handler) {
|
||||
return handler?.handler === "reverse_proxy" &&
|
||||
(handler.upstreams || []).some((upstream) => upstream.dial === "127.0.0.1:8080");
|
||||
}
|
||||
function findHandlerArray(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.some(authUpstream) && value.some(frontendUpstream)) return value;
|
||||
for (const child of value) {
|
||||
const found = findHandlerArray(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
const found = findHandlerArray(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function collectTrustedSets(value, collected = new Map()) {
|
||||
if (!value || typeof value !== "object") return collected;
|
||||
if (value.handler === "headers") {
|
||||
for (const [name, replacement] of Object.entries(value.request?.set || {})) {
|
||||
if (trustedHeaders.includes(name)) collected.set(name, replacement);
|
||||
}
|
||||
}
|
||||
for (const child of Object.values(value)) collectTrustedSets(child, collected);
|
||||
return collected;
|
||||
}
|
||||
const handlers = findHandlerArray(document);
|
||||
if (!handlers) throw new Error("Caddy adapted config lacks the ordered auth/frontend handler chain");
|
||||
const authAt = handlers.findIndex(authUpstream);
|
||||
const frontendAt = handlers.findIndex(frontendUpstream);
|
||||
if (authAt < 0 || frontendAt <= authAt) throw new Error("Caddy adapted auth/frontend handler order is invalid");
|
||||
const expectedClears = [...publicHeaders, ...trustedHeaders];
|
||||
for (const header of expectedClears) {
|
||||
const clearAt = handlers.findIndex((handler) =>
|
||||
handler?.handler === "headers" && (handler.request?.delete || []).includes(header));
|
||||
if (clearAt < 0 || clearAt >= authAt) {
|
||||
throw new Error("Caddy adapted identity clears must execute before authentication");
|
||||
}
|
||||
}
|
||||
const auth = handlers[authAt];
|
||||
const successResponse = (auth.handle_response || []).find((response) =>
|
||||
(response.match?.status_code || []).map(Number).includes(2));
|
||||
if (!successResponse) throw new Error("Caddy adapted identity mapping is not restricted to auth 2xx");
|
||||
const mappings = collectTrustedSets(successResponse);
|
||||
for (let index = 0; index < trustedHeaders.length; index++) {
|
||||
const replacement = mappings.get(trustedHeaders[index]);
|
||||
const expected = `{http.reverse_proxy.header.${publicHeaders[index]}}`;
|
||||
if (!Array.isArray(replacement) || replacement.length !== 1 || replacement[0] !== expected) {
|
||||
throw new Error(`Caddy adapted authenticated mapping is invalid for ${trustedHeaders[index]}`);
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < handlers.length; index++) {
|
||||
if (index === authAt) continue;
|
||||
if (collectTrustedSets(handlers[index]).size !== 0) {
|
||||
throw new Error("Caddy adapted config maps trusted identity outside auth success");
|
||||
}
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
verify_caddy_effective_proxy_guide() {
|
||||
local adapted
|
||||
adapted="$(mktemp "${TMPDIR:-/tmp}/thoth-caddy-adapted.XXXXXX")"
|
||||
if ! awk '
|
||||
/^```caddyfile$/ { code=1; next }
|
||||
code && /^```$/ { exit }
|
||||
code { print }
|
||||
' "$root/docs/install/reverse-proxy-caddy.md" \
|
||||
| docker run --rm -i caddy:2.10.2-alpine caddy adapt --config - --adapter caddyfile >"$adapted"; then
|
||||
rm -f "$adapted"
|
||||
echo "Caddy documented configuration could not be adapted" >&2
|
||||
return 1
|
||||
fi
|
||||
verify_caddy_adapted_identity_order "$adapted"
|
||||
rm -f "$adapted"
|
||||
echo "Caddy adapted trust-stage contract passed"
|
||||
}
|
||||
|
||||
verify_manual() {
|
||||
local profile="$1" manual
|
||||
manual="$root/docs/install/$profile-workspace-registry.md"
|
||||
@@ -1236,6 +1389,8 @@ case "$mode" in
|
||||
verify_server_guide
|
||||
verify_reverse_proxy_nginx_guide
|
||||
verify_reverse_proxy_caddy_guide
|
||||
verify_caddy_effective_proxy_guide
|
||||
"$root/scripts/test-server-operator-permissions.sh"
|
||||
verify_server_installation_example
|
||||
fi
|
||||
verify_manual "$profile"
|
||||
|
||||
Reference in New Issue
Block a user