implement private host allow list

This commit is contained in:
Nystik
2026-06-10 20:36:57 +02:00
parent 911ebc00af
commit 7758f533bd
4 changed files with 105 additions and 3 deletions

View File

@@ -80,6 +80,7 @@ To build from source instead of pulling the image, clone the repo and run `docke
| `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` |
| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset |
| `PROXY_ALLOW_PRIVATE_HOSTS` | Comma-separated IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block, for LAN services. Matched against the resolved IP. Reopens SSRF to the listed targets. | unset |
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment.

View File

@@ -52,8 +52,79 @@ function isPrivateIp(ip) {
return false;
}
function ipv4ToInt(ip) {
return ip
.split(".")
.reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0);
}
// Parse PROXY_ALLOW_PRIVATE_HOSTS into matchers.
// Exact IPs (v4 and v6) and IPv4 CIDRs are supported; IPv6 CIDR and malformed entries are ignored.
function buildAllowList(entries) {
const exact = new Set();
const cidrV4 = [];
for (const entry of entries) {
const slash = entry.indexOf("/");
if (slash === -1) {
if (net.isIP(entry)) {
exact.add(entry);
} else {
console.warn(
"[proxy] ignoring invalid PROXY_ALLOW_PRIVATE_HOSTS entry:",
entry,
);
}
continue;
}
const base = entry.slice(0, slash);
const prefix = Number(entry.slice(slash + 1));
if (
net.isIP(base) === 4 &&
Number.isInteger(prefix) &&
prefix >= 0 &&
prefix <= 32
) {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
cidrV4.push({ network: (ipv4ToInt(base) & mask) >>> 0, mask });
} else {
console.warn(
"[proxy] ignoring unsupported PROXY_ALLOW_PRIVATE_HOSTS entry:",
entry,
);
}
}
return { exact, cidrV4 };
}
function allowsAddress(allow, ip) {
if (allow.exact.has(ip)) {
return true;
}
if (net.isIP(ip) === 4) {
const value = ipv4ToInt(ip);
for (const { network, mask } of allow.cidrV4) {
if ((value & mask) >>> 0 === network) {
return true;
}
}
}
return false;
}
const privateAllowList = buildAllowList(settings.get("proxyAllowPrivate"));
// A public address always passes; a private one passes only when listed it in PROXY_ALLOW_PRIVATE_HOSTS.
function addressAllowed(ip) {
return !isPrivateIp(ip);
return !isPrivateIp(ip) || allowsAddress(privateAllowList, ip);
}
function httpError(status, message) {
@@ -354,3 +425,5 @@ router.post("/", async (req, res) => {
module.exports = router;
module.exports.isPrivateIp = isPrivateIp;
module.exports.proxyRequest = proxyRequest;
module.exports.buildAllowList = buildAllowList;
module.exports.allowsAddress = allowsAddress;

View File

@@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { isPrivateIp, proxyRequest } = require("./proxy.js");
const { isPrivateIp, proxyRequest, buildAllowList, allowsAddress } =
require("./proxy.js");
describe("isPrivateIp", () => {
it("flags private and link-local IPv4", () => {
@@ -74,3 +75,24 @@ describe("proxyRequest guard", () => {
).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("proxy private-host allow list", () => {
it("allows exact IPs and IPv4 CIDRs, rejects everything else", () => {
const allow = buildAllowList(["192.168.0.0/16", "10.1.2.3", "::1"]);
expect(allowsAddress(allow, "192.168.1.5")).toBe(true);
expect(allowsAddress(allow, "192.169.0.1")).toBe(false);
expect(allowsAddress(allow, "10.1.2.3")).toBe(true);
expect(allowsAddress(allow, "10.1.2.4")).toBe(false);
expect(allowsAddress(allow, "::1")).toBe(true);
expect(allowsAddress(allow, "8.8.8.8")).toBe(false);
});
it("ignores IPv6 CIDR and malformed entries", () => {
const allow = buildAllowList(["fd00::/8", "garbage", "192.168.0.0/33"]);
expect(allow.exact.size).toBe(0);
expect(allow.cidrV4.length).toBe(0);
expect(allowsAddress(allow, "fd00::1")).toBe(false);
});
});

View File

@@ -17,6 +17,8 @@ const DEFAULTS = {
// Empty allows any public host.
proxyAllowlist: [],
wsOrigins: [],
// Private IPs/CIDRs the proxy may reach despite the SSRF guard.
proxyAllowPrivate: [],
};
const PROXY_MODES = ["any", "allowlist", "disabled"];
@@ -24,7 +26,7 @@ const PROXY_MODES = ["any", "allowlist", "disabled"];
const KEYS = Object.keys(DEFAULTS);
// Env vars only; never persisted to the settings file.
const ENV_ONLY_KEYS = ["wsOrigins"];
const ENV_ONLY_KEYS = ["wsOrigins", "proxyAllowPrivate"];
// Hard ceiling for request bodies.
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
@@ -51,6 +53,10 @@ function fromEnv() {
env.wsOrigins = parseList(process.env.WS_ORIGINS);
}
if (process.env.PROXY_ALLOW_PRIVATE_HOSTS) {
env.proxyAllowPrivate = parseList(process.env.PROXY_ALLOW_PRIVATE_HOSTS);
}
return env;
}