Allow configured public sites in annoNet viewer proxy
This commit is contained in:
parent
9dcc2099a0
commit
21e9af268b
@ -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.
|
||||
|
||||
15
README.md
15
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`
|
||||
|
||||
|
||||
219
proxyPolicyPatch.js
Normal file
219
proxyPolicyPatch.js
Normal 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");
|
||||
Loading…
x
Reference in New Issue
Block a user