From 21e9af268b3d76c0146a319713584a3e02e5af5e Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 21 Sep 2026 22:33:01 +0000 Subject: [PATCH] Allow configured public sites in annoNet viewer proxy --- Dockerfile | 4 +- README.md | 15 ++- proxyPolicyPatch.js | 219 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 proxyPolicyPatch.js diff --git a/Dockerfile b/Dockerfile index 897c9eb..92c8c50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ -FROM alpine:3.22 AS source +FROM node:22-alpine AS source RUN apk add --no-cache unzip WORKDIR /src COPY annonet-arrowstack-ready.zip /tmp/annonet.zip RUN unzip -q /tmp/annonet.zip -d /src COPY mcpAuthMiddleware.js /src/src/auth/mcpAuthMiddleware.js +COPY proxyPolicyPatch.js /tmp/proxyPolicyPatch.js +RUN node /tmp/proxyPolicyPatch.js /src/src/server.js # Arrowstack's database-per-tenant runtime role intentionally cannot install # extensions. PostgreSQL 13+ provides gen_random_uuid() in core, and annoNet # does not use any other pgcrypto functions. diff --git a/README.md b/README.md index 64aa6a9..acf24b3 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,14 @@ Arrowstack production deployment for the uploaded `annonet-arrowstack-ready.zip` source of truth. -The Docker image preserves the supplied production layout and applies one minimal -machine-auth override before building: +Production overlays applied during the Docker build: -- Keycloak token signature, issuer and expiry are verified by the supplied OIDC verifier. -- The token's actual authorized party/client identity must be `annonet-mcp`; audience alone is not enough. -- The required machine role remains `annonet-mcp`. -- annoNet org/team/agent authority comes only from `ANNONET_MCP_ORG_ID`, - `ANNONET_MCP_TEAM_IDS` and `ANNONET_MCP_AGENT_NAME`. -- `ANNONET_MCP_TOKEN` remains optional for local/bootstrap compatibility and is empty in production. +- Keycloak MCP tokens must use the configured machine client and required role. +- annoNet org/team/agent authority remains explicit in `ANNONET_MCP_*`. +- `ANNONET_MCP_TOKEN` is optional and empty in production. +- The viewer proxy uses an explicit public-host allowlist via `ANNONET_PROXY_ALLOWED_HOSTS`. +- Private/LAN proxy targets are disabled unless `ANNONET_PROXY_ALLOW_PRIVATE=true` is deliberately set for a trusted local deployment. +- Same-origin proxied HTML/CSS resources are rewritten through annoNet so production Vite/Astro applications render inside the annotation viewer. Runtime port: `8787` diff --git a/proxyPolicyPatch.js b/proxyPolicyPatch.js new file mode 100644 index 0000000..06decfa --- /dev/null +++ b/proxyPolicyPatch.js @@ -0,0 +1,219 @@ +const fs = require("fs"); + +const target = process.argv[2]; +if (!target) { + throw new Error("server.js path is required"); +} + +let source = fs.readFileSync(target, "utf8"); + +const replaceOnce = (label, before, after) => { + const first = source.indexOf(before); + if (first === -1) { + throw new Error(`proxy patch failed: ${label} pattern not found`); + } + if (source.indexOf(before, first + before.length) !== -1) { + throw new Error(`proxy patch failed: ${label} pattern matched more than once`); + } + source = source.slice(0, first) + after + source.slice(first + before.length); +}; + +const replaceAllCount = (label, before, after, expectedCount) => { + const parts = source.split(before); + const count = parts.length - 1; + if (count !== expectedCount) { + throw new Error(`proxy patch failed: ${label} expected ${expectedCount} matches, found ${count}`); + } + source = parts.join(after); +}; + +replaceOnce( + "referer target guard", + [ + " if (!PRIVATE_IP.test(parsedUpstream.hostname)) {", + " return null;", + " }" + ].join("\n"), + [ + " if (!isProxyTargetAllowed(parsedUpstream)) {", + " return null;", + " }" + ].join("\n") +); + +replaceOnce( + "cookie target guard", + [ + " if (!PRIVATE_IP.test(parsed.hostname)) {", + " return null;", + " }" + ].join("\n"), + [ + " if (!isProxyTargetAllowed(parsed)) {", + " return null;", + " }" + ].join("\n") +); + +replaceOnce( + "resource rewrite hook", + [ + " cleaned = rewriteUpstreamAbsoluteAssetUrls(cleaned, annotationSite);", + "", + " if (cleaned.includes(\"\")) {" + ].join("\n"), + [ + " cleaned = rewriteUpstreamAbsoluteAssetUrls(cleaned, annotationSite);", + " cleaned = rewriteProxyResourceUrls(cleaned, annotationUrl || annotationSite, apiBase);", + "", + " if (cleaned.includes(\"\")) {" + ].join("\n") +); + +replaceOnce( + "dev asset target guard", + [ + " if (!PRIVATE_IP.test(upstreamAssetUrl.hostname)) {", + " return res.status(403).json({ error: \"only private/LAN addresses allowed\" });", + " }" + ].join("\n"), + [ + " if (!isProxyTargetAllowed(upstreamAssetUrl)) {", + " return res.status(403).json({ error: \"target host is not allowed by proxy policy\" });", + " }" + ].join("\n") +); + +replaceOnce( + "proxy policy block", + [ + "// Server-side proxy — fetches target page and injects the annotation overlay.", + "// Restricted to private/LAN IP ranges only to prevent SSRF abuse.", + "const PRIVATE_IP = /^(localhost|127\\.\\d+\\.\\d+\\.\\d+|10\\.\\d+\\.\\d+\\.\\d+|172\\.(1[6-9]|2\\d|3[01])\\.\\d+\\.\\d+|192\\.168\\.\\d+\\.\\d+)/i;" + ].join("\n"), + [ + "// Server-side proxy target policy. Public production targets must be explicitly", + "// allowlisted. Private/LAN targets are disabled by default and can be enabled", + "// only for trusted local-development deployments.", + "const PRIVATE_IP = /^(localhost|127\\.\\d+\\.\\d+\\.\\d+|10\\.\\d+\\.\\d+\\.\\d+|172\\.(1[6-9]|2\\d|3[01])\\.\\d+\\.\\d+|192\\.168\\.\\d+\\.\\d+|169\\.254\\.\\d+\\.\\d+|::1|\\[::1\\])$/i;", + "const PROXY_ALLOW_PRIVATE = [\"1\", \"true\", \"yes\"].includes(", + " String(process.env.ANNONET_PROXY_ALLOW_PRIVATE || \"\").trim().toLowerCase()", + ");", + "const PROXY_ALLOWED_HOSTS = String(process.env.ANNONET_PROXY_ALLOWED_HOSTS || \"\")", + " .split(\",\")", + " .map((value) => value.trim().toLowerCase())", + " .filter(Boolean);", + "", + "const proxyHostMatches = (hostname, pattern) => {", + " if (pattern === hostname) return true;", + " if (pattern.startsWith(\"*.\")) {", + " const suffix = pattern.slice(1);", + " return hostname.endsWith(suffix) && hostname.length > suffix.length;", + " }", + " return false;", + "};", + "", + "const isProxyTargetAllowed = (parsed) => {", + " if (!parsed || ![\"http:\", \"https:\"].includes(parsed.protocol)) return false;", + " if (parsed.username || parsed.password) return false;", + "", + " const hostname = String(parsed.hostname || \"\").trim().toLowerCase();", + " if (!hostname) return false;", + " if (PRIVATE_IP.test(hostname)) return PROXY_ALLOW_PRIVATE;", + "", + " return PROXY_ALLOWED_HOSTS.some((pattern) => proxyHostMatches(hostname, pattern));", + "};", + "", + "const toProxyResourceUrl = (rawValue, pageUrl, apiBase) => {", + " const raw = String(rawValue || \"\").trim();", + " if (!raw || raw.startsWith(\"#\") || /^(?:data:|blob:|javascript:|mailto:|tel:)/i.test(raw)) return rawValue;", + "", + " try {", + " const base = new URL(pageUrl);", + " const resolved = new URL(raw, base);", + " if (resolved.origin !== base.origin) return rawValue;", + " return apiBase + \"/api/proxy?url=\" + encodeURIComponent(resolved.toString());", + " } catch (_error) {", + " return rawValue;", + " }", + "};", + "", + "const rewriteProxyResourceUrls = (html, pageUrl, apiBase) => {", + " return String(html || \"\").replace(", + " /(<(?:script|link|img|source|video|audio|iframe)\\b[^>]*?\\s(?:src|href|poster)=[\"'])([^\"']+)([\"'])/gi,", + " (_match, prefix, value, suffix) => prefix + toProxyResourceUrl(value, pageUrl, apiBase) + suffix", + " );", + "};", + "", + "const rewriteProxyCssUrls = (css, pageUrl, apiBase) => {", + " return String(css || \"\").replace(", + " /url\\(\\s*([\"']?)([^)\"']+)\\1\\s*\\)/gi,", + " (_match, quote, value) => {", + " const rewritten = toProxyResourceUrl(value, pageUrl, apiBase);", + " return \"url(\" + quote + rewritten + quote + \")\";", + " }", + " );", + "};" + ].join("\n") +); + +replaceOnce( + "redirect target guard", + [ + " if (!PRIVATE_IP.test(next.hostname)) {", + " // Redirect leaves private network — stop following, return current response.", + " return { response, allSetCookies, finalUrl: url };", + " }" + ].join("\n"), + [ + " if (!isProxyTargetAllowed(next)) {", + " const error = new Error(\"redirect target host is not allowed by proxy policy\");", + " error.status = 403;", + " throw error;", + " }" + ].join("\n") +); + +replaceAllCount( + "main proxy target guards", + [ + " if (!PRIVATE_IP.test(parsed.hostname)) {", + " return res.status(403).json({ error: \"only private/LAN addresses allowed\" });", + " }" + ].join("\n"), + [ + " if (!isProxyTargetAllowed(parsed)) {", + " return res.status(403).json({ error: \"target host is not allowed by proxy policy\" });", + " }" + ].join("\n"), + 2 +); + +replaceOnce( + "proxied CSS rewrite", + [ + " if (!contentType.includes(\"text/html\")) {", + " const body = await upstream.arrayBuffer();", + " res.set(\"Content-Type\", contentType);", + " return res.send(Buffer.from(body));", + " }" + ].join("\n"), + [ + " if (!contentType.includes(\"text/html\")) {", + " res.set(\"Content-Type\", contentType);", + " if (contentType.includes(\"text/css\")) {", + " const css = await upstream.text();", + " return res.send(rewriteProxyCssUrls(css, finalUrl, apiBase));", + " }", + " const body = await upstream.arrayBuffer();", + " return res.send(Buffer.from(body));", + " }" + ].join("\n") +); + +if (source.includes("only private/LAN addresses allowed")) { + throw new Error("proxy patch failed: stale LAN-only error remains"); +} + +fs.writeFileSync(target, source); +console.log("Applied annoNet public proxy allowlist patch");