220 lines
7.4 KiB
JavaScript
220 lines
7.4 KiB
JavaScript
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");
|