fix: close server bypass edge cases
This commit is contained in:
@@ -547,6 +547,10 @@ verify_server_guide() {
|
||||
"docker compose down --volumes" \
|
||||
"reverse-proxy-nginx.md" \
|
||||
"reverse-proxy-caddy.md"
|
||||
if ! grep -Eq '^sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii$' "$guide"; then
|
||||
echo "server installation guide does not set parent traversal boundary" >&2
|
||||
return 1
|
||||
fi
|
||||
node - "$guide" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const source = fs.readFileSync(process.argv[2], "utf8");
|
||||
@@ -658,19 +662,49 @@ 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);
|
||||
function nginxLocations(text) {
|
||||
const locations = [];
|
||||
const pattern = /\blocation\s+([^\n{]+)\{/g;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const opening = match.index + match[0].lastIndexOf("{");
|
||||
let depth = 0;
|
||||
let closing = -1;
|
||||
for (let index = opening; index < text.length; index++) {
|
||||
if (text[index] === "{") depth++;
|
||||
if (text[index] === "}" && --depth === 0) {
|
||||
closing = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closing < 0) throw new Error(`Nginx proxy has unterminated location: ${match[1].trim()}`);
|
||||
locations.push({selector: match[1].trim(), body: text.slice(opening + 1, closing)});
|
||||
}
|
||||
return locations;
|
||||
}
|
||||
const locations = nginxLocations(block);
|
||||
const authLocations = locations.filter((location) => location.selector === "= /_authenticate");
|
||||
if (authLocations.length !== 1) {
|
||||
throw new Error("Nginx proxy must define exactly one authentication location");
|
||||
}
|
||||
const authLocation = authLocations[0].body;
|
||||
const frontendLocations = locations.filter((location) =>
|
||||
/proxy_pass\s+http:\/\/127\.0\.0\.1:8080\s*;/.test(location.body));
|
||||
if (frontendLocations.length === 0) {
|
||||
throw new Error("Nginx proxy lacks a frontend upstream location");
|
||||
}
|
||||
for (const location of locations) {
|
||||
const upstreams = [...location.body.matchAll(/proxy_pass\s+([^;]+);/g)].map((match) => match[1].trim());
|
||||
for (const upstream of upstreams) {
|
||||
if (location.selector === "= /_authenticate" && upstream === "http://auth-gateway:4180/verify") continue;
|
||||
if (upstream === "http://127.0.0.1:8080") continue;
|
||||
throw new Error(`Nginx location proxies to an unreviewed upstream: ${upstream}`);
|
||||
}
|
||||
}
|
||||
for (const frontendLocation of frontendLocations) {
|
||||
if (!/auth_request\s+\/_authenticate\s*;/.test(frontendLocation.body)) {
|
||||
throw new Error("Nginx frontend upstream location bypasses complete authentication contract");
|
||||
}
|
||||
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 publicClear = new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`);
|
||||
@@ -684,18 +718,20 @@ for (const [label, publicName, variable, upstream] of identities) {
|
||||
if (authTrustedAt < 0) {
|
||||
throw new Error(`Nginx auth location does not clear inbound trusted ${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`);
|
||||
for (const frontendLocation of frontendLocations) {
|
||||
const frontendPublicAt = frontendLocation.body.search(publicClear);
|
||||
if (frontendPublicAt < 0) {
|
||||
throw new Error(`Nginx frontend location does not clear inbound ${label} identity`);
|
||||
}
|
||||
const normalizedFrontend = frontendLocation.body.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`);
|
||||
@@ -801,22 +837,6 @@ 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") {
|
||||
@@ -827,36 +847,73 @@ function collectTrustedSets(value, collected = new Map()) {
|
||||
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");
|
||||
function validateAuthenticatedMappings(auth) {
|
||||
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]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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]}`);
|
||||
function validateFrontendPath(handlers) {
|
||||
let authAt = -1;
|
||||
for (let index = handlers.length - 1; index >= 0; index--) {
|
||||
if (authUpstream(handlers[index])) {
|
||||
authAt = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (authAt < 0) {
|
||||
throw new Error("Caddy adapted frontend path bypasses complete authentication contract");
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
validateAuthenticatedMappings(handlers[authAt]);
|
||||
for (let index = 0; index < handlers.length; index++) {
|
||||
if (index !== authAt && collectTrustedSets(handlers[index]).size !== 0) {
|
||||
throw new Error("Caddy adapted config maps trusted identity outside auth success");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
let frontendPaths = 0;
|
||||
function walk(value, inherited = []) {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) walk(child, inherited);
|
||||
return;
|
||||
}
|
||||
if (frontendUpstream(value)) {
|
||||
frontendPaths++;
|
||||
validateFrontendPath(inherited);
|
||||
}
|
||||
if (Array.isArray(value.handle)) {
|
||||
const previous = [];
|
||||
for (const handler of value.handle) {
|
||||
walk(handler, [...inherited, ...previous]);
|
||||
previous.push(handler);
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key !== "handle") walk(child, inherited);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const childContext = authUpstream(value) ? [...inherited, value] : inherited;
|
||||
for (const child of Object.values(value)) walk(child, childContext);
|
||||
}
|
||||
walk(document);
|
||||
if (frontendPaths === 0) {
|
||||
throw new Error("Caddy adapted config lacks a frontend handler path");
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user