From 7758f533bd3219bfab5000d0081c3156c3f1184e Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Wed, 10 Jun 2026 20:36:57 +0200 Subject: [PATCH] implement private host allow list --- apps/ignis-server/README.md | 1 + apps/ignis-server/server/routes/proxy.js | 75 ++++++++++++++++++- .../ignis-server/server/routes/proxy.test.mjs | 24 +++++- apps/ignis-server/server/settings.js | 8 +- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/apps/ignis-server/README.md b/apps/ignis-server/README.md index 22b26dc..70a4a7f 100644 --- a/apps/ignis-server/README.md +++ b/apps/ignis-server/README.md @@ -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. diff --git a/apps/ignis-server/server/routes/proxy.js b/apps/ignis-server/server/routes/proxy.js index fdf2360..4e92fa2 100644 --- a/apps/ignis-server/server/routes/proxy.js +++ b/apps/ignis-server/server/routes/proxy.js @@ -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; diff --git a/apps/ignis-server/server/routes/proxy.test.mjs b/apps/ignis-server/server/routes/proxy.test.mjs index d402ac8..c9c9c80 100644 --- a/apps/ignis-server/server/routes/proxy.test.mjs +++ b/apps/ignis-server/server/routes/proxy.test.mjs @@ -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); + }); +}); diff --git a/apps/ignis-server/server/settings.js b/apps/ignis-server/server/settings.js index 854d06d..7c1644c 100644 --- a/apps/ignis-server/server/settings.js +++ b/apps/ignis-server/server/settings.js @@ -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; }