Allow configured public sites in annoNet viewer proxy

This commit is contained in:
Admin 2026-09-21 22:33:01 +00:00
parent 9dcc2099a0
commit 21e9af268b
3 changed files with 229 additions and 9 deletions

View File

@ -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.

View File

@ -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`

219
proxyPolicyPatch.js Normal file
View File

@ -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(\"</head>\")) {"
].join("\n"),
[
" cleaned = rewriteUpstreamAbsoluteAssetUrls(cleaned, annotationSite);",
" cleaned = rewriteProxyResourceUrls(cleaned, annotationUrl || annotationSite, apiBase);",
"",
" if (cleaned.includes(\"</head>\")) {"
].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");