From 1c6c291774e0efd479ad9b54d71e6813f87d73cf Mon Sep 17 00:00:00 2001 From: SM-26 <39513660+SM-26@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:31:07 +0200 Subject: [PATCH] fix(docker): let Provider Settings admit the host through the published port Docker delivers the host's own browser to the container from the container network's gateway, never from 127.0.0.1, so the loopback-only admission gate answered every POWER UP request with 403 and no key could be saved. The README and CHANGELOG claimed otherwise; both are corrected here. - keySetupCore: GEV_KEY_SETUP_TRUSTED_PEERS, an exact-address list (no wildcards, prefixes or hostnames) admitted alongside loopback. Unset trusts nothing, and the sharing, Host, Origin, Content-Type and proxy-header checks all still apply to a listed address. Twelve assertions pin each refusal. - scripts/docker-start.mjs: the container entry point, shaped after scripts/pinokio-start.mjs. It reads the default gateway from /proc/net/route, trusts exactly that address, and starts Vite. No gateway (--network none) trusts nothing. The pure helpers are unit-tested and a contract test pins the Dockerfile CMD and the loopback-only publish. - Dockerfile: install git. `npm test` builds fixtures with `git init`, so the suite failed 71 tests inside the image and the README's claim that it runs in the container was wrong. - SECURITY.md and .env.example document the setting and why it is safe only behind a 127.0.0.1 publish; README gains the Linux/NAS id note and .dockerignore drops macOS AppleDouble sidecars, both reported on the PR. Verified in Docker on a clean build: saving a key from the panel writes the bind-mounted .env (0600 in the container), it survives `compose down`/`up`, and REMOVE comments it back out. A foreign Origin, a foreign Host, a proxy header and a sibling container on the same network are each refused. `npm run doctor`, `npm test` (5067 passed, 1 skipped, 0 failed) and `npm run build` are green inside the container. Reported-by: @jklaze Co-Authored-By: Bogdan Petrut Co-Authored-By: Claude Opus 5 --- .dockerignore | 3 + .env.example | 7 ++ CHANGELOG.md | 10 ++- Dockerfile | 9 ++- README.md | 35 ++++++++-- SECURITY.md | 11 +++ scripts/docker-start.mjs | 132 ++++++++++++++++++++++++++++++++++++ src/dockerLauncher.test.mjs | 81 ++++++++++++++++++++++ src/keySetupCore.mjs | 32 ++++++++- src/keySetupCore.test.mjs | 17 +++++ 10 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 scripts/docker-start.mjs create mode 100644 src/dockerLauncher.test.mjs diff --git a/.dockerignore b/.dockerignore index 493b503c9..3f5bc8a9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,6 @@ node_modules .gev-cache .gev-logs dist +# AppleDouble sidecars from a macOS `tar` copy; `npm test` would otherwise +# collect `._*.test.mjs` as test files. +._* diff --git a/.env.example b/.env.example index c89680b30..825910d3d 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,13 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano # spend protection set a provider-side budget: OpenAI platform -> Settings -> # Limits (usage limits). # GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it +# Optional: exact socket addresses (comma-separated, no wildcards or prefixes) +# that Provider Settings treats as this machine in addition to loopback. Leave +# unset. The Docker launcher sets it to the container's gateway at start, +# because that is where Docker delivers your own browser from; it is safe only +# while the port is published on 127.0.0.1. Read SECURITY.md before setting it +# by hand. +# GEV_KEY_SETUP_TRUSTED_PEERS= # Optional: Mapillary imagery for the Street Level layer. Free client token from # https://www.mapillary.com/dashboard/developers (register an app, copy its diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d88e9dc..658749625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,7 +111,15 @@ serves the app on `http://localhost:4173` with no local Node install. The repo is bind-mounted and the container runs as the host user, so `.env`, `.gev-cache`, and `.gev-logs` persist on the host exactly as they do under - `npm run dev`. + `npm run dev` — Provider Settings included. The launcher + (`scripts/docker-start.mjs`) trusts the container's gateway address for that + panel, since that is where Docker delivers the host's own browser from; the + opt-in behind it, `GEV_KEY_SETUP_TRUSTED_PEERS`, takes exact addresses only + and is unset (so loopback-only, unchanged) under every other launcher. + Thanks to [jklaze](https://github.com/jklaze) for finding and diagnosing the + container-to-host 403, and to [14traits](https://github.com/14traits), + [PoBruno](https://github.com/PoBruno) and [milu70hub](https://github.com/milu70hub) + for testing on Apple silicon, Docker Engine on Linux, and NAS hosts. - Stdio servers from one install share a panel key so a page read from one process can make requests through another. Concurrent malformed-key repairs diff --git a/Dockerfile b/Dockerfile index b4706ffdb..de89347f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,10 @@ FROM node:24-slim ENV PUPPETEER_SKIP_DOWNLOAD=1 WORKDIR /app +# `npm test` builds fixtures with `git init`, so the suite cannot run in a +# container without it (71 failures on node:24-slim, all ENOENT on git). +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + COPY package.json package-lock.json ./ RUN npm ci # compose runs the container as the host user so bind-mounted files stay @@ -25,4 +29,7 @@ COPY . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 -CMD ["npm", "run", "dev"] +# The launcher is `npm run dev` plus one step: it trusts the container's +# gateway address for Provider Settings, because that is where Docker delivers +# the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. +CMD ["node", "scripts/docker-start.mjs"] diff --git a/README.md b/README.md index 3a21bd189..5ab3ae693 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,34 @@ docker compose up ``` Open **`http://localhost:4173`** as above. The repo is bind-mounted, so `.env`, -`.gev-cache` and `.gev-logs` live on the host and source edits still hot-reload. -The container binds `0.0.0.0` *inside* the container, but the port is published -to `127.0.0.1` only, so the default matches the native one: nobody else can -reach your server. Widening that mapping puts you under -[Sharing an instance](#-sharing-an-instance) — keys and all. +`.gev-cache` and `.gev-logs` live on the host, edits still hot-reload, and keys +you save from Provider Settings survive `docker compose down`. The port is +published to `127.0.0.1` only, so the default matches the native one. + +Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, +and it hands out **Provider Settings** too: the launcher trusts the container +gateway as this machine, and a wider publish delivers everyone else from that +same address. For an instance you mean to share, put the keys in `.env` by hand +instead. [SECURITY.md](SECURITY.md) has the detail. + +
+File ownership on Linux and NAS hosts + +On Linux, export your ids first so the files the container writes stay yours: +`export UID GID` before `docker compose up`. On a NAS or anywhere the share has +its own owner, set them explicitly instead (`UID=1026 GID=100 docker compose +up`) — Docker Desktop on macOS and Windows maps ownership for you. + +
+ +
+IPv6-only networks, rebuilding, and the test suites + +The launcher reads the IPv4 default gateway, so an IPv6-only Docker network +finds none: it logs `No default gateway; Provider Settings stays loopback-only` +and the panel refuses every save. Put your keys in `.env` by hand there, or name +the gateway yourself with `GEV_KEY_SETUP_TRUSTED_PEERS` after reading +[SECURITY.md](SECURITY.md). `node_modules` deliberately stays inside the image, so it survives in an anonymous volume across restarts: after a `git pull` that changes dependencies, @@ -169,6 +192,8 @@ image also skips Puppeteer's Chromium download, which keeps it small but leaves `npm run test:track` to the host — `npm run build` and `npm test` run fine in the container. +
+ ### Then power it up — in the app, not in a file Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** diff --git a/SECURITY.md b/SECURITY.md index 53437a83c..23dfee4e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -84,6 +84,17 @@ The dev server is a **key broker**: every server-side key above is spendable by - **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored. - **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust. - **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**. +- **Docker trusts one address, and only because the port is on loopback.** + Docker delivers the host's own browser to the container from the container + network's gateway, never from `127.0.0.1`, so a loopback-only Provider + Settings refuses the machine running it. `scripts/docker-start.mjs` therefore + sets `GEV_KEY_SETUP_TRUSTED_PEERS` to exactly that gateway address. The list + takes exact addresses only (no wildcards, prefixes or hostnames), is unset + under every other launcher, and relaxes none of the sharing, Host, Origin, + Content-Type or proxy-header checks. It is safe precisely because + `compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN + client arrives from the same gateway address, which hands them Provider + Settings too.** Treat publishing beyond loopback as the LAN opt-in above. - **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider. - **Pinokio LAN and Cloudflare sharing are refused.** The current supported Pinokio release re-reads sharing state when an app registers its Open URL and diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs new file mode 100644 index 000000000..25ddf76ba --- /dev/null +++ b/scripts/docker-start.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Docker launcher — the container's entry point (see Dockerfile). + * + * `compose.yaml` publishes the port on the host's loopback, but Docker still + * delivers the host's own browser to the container FROM the container + * network's gateway address, never from 127.0.0.1. Provider Settings admits + * loopback only, so without this the POWER UP panel refuses the very machine + * running the container. This launcher reads the gateway out of the kernel + * routing table and declares exactly that one address trusted, then starts + * Vite the way scripts/pinokio-start.mjs does. + */ +import { readFileSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isDirectInvocation } from './pinokio-install.mjs'; +import { loadViteFromCanonicalRoot } from './pinokio-start.mjs'; + +const MODULE_PATH = fileURLToPath(import.meta.url); +const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..')); +const ROUTE_TABLE = '/proc/net/route'; +/** Host names the native launcher accepts; the container matches it. */ +const LOCAL_ALLOWED_HOSTS = Object.freeze(['localhost', '127.0.0.1', '.local']); + +/** + * Extract the IPv4 default gateway from Linux `/proc/net/route` text. The + * addresses there are little-endian hex and only the default route (a + * destination of 00000000 on an interface flagged up) names a gateway. + * Returns null when there is none — the `--network none` case, which must + * never fabricate an address. + * @param {string} text Raw routing-table contents. + * @returns {string|null} Dotted-quad gateway, or null. + */ +export function defaultGatewayFromRouteTable(text) { + const lines = String(text || '').split(/\r?\n/).slice(1); + for (const line of lines) { + const fields = line.trim().split(/\s+/); + if (fields.length < 4) continue; + const [, destination, gateway, flags] = fields; + if (destination !== '00000000' || !/^[0-9a-f]{8}$/i.test(gateway)) continue; + if ((Number.parseInt(flags, 16) & 0x1) === 0) continue; // RTF_UP + const octets = [6, 4, 2, 0].map((offset) => + Number.parseInt(gateway.slice(offset, offset + 2), 16), + ); + if (octets.every((octet) => octet === 0)) continue; + return octets.join('.'); + } + return null; +} + +/** + * Decide this launch's trusted-peer list. An operator value always wins, so a + * compose override stays authoritative; otherwise the gateway alone is + * trusted. No gateway trusts nothing and Provider Settings stays loopback-only, + * exactly as under `npm run dev`. + * @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input + * @returns {string} Value for GEV_KEY_SETUP_TRUSTED_PEERS ('' trusts nothing). + */ +export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) { + const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(); + if (explicit) return explicit; + return defaultGatewayFromRouteTable(routeTable) ?? ''; +} + +/** + * Host headers this container answers to. A wildcard bind is unavoidable + * inside a container, but build/vite.js reads that as "allow every Host" + * (allowedHosts: true), dropping the DNS-rebinding protection `npm run dev` + * keeps. The published port only ever delivers the host's own browser, so pin + * the same names the native launcher uses, and still honour an operator's + * GEV_ALLOWED_HOSTS so a LAN hostname stays addable here too. See issue #21. + * @param {NodeJS.ProcessEnv} [env] + * @returns {string[]} + */ +export function resolveAllowedHosts(env = process.env) { + const configured = String(env.GEV_ALLOWED_HOSTS ?? '') + .split(',') + .map((host) => host.trim()) + .filter(Boolean); + return [...new Set([...LOCAL_ALLOWED_HOSTS, ...configured])]; +} + +function readRouteTable() { + try { + return readFileSync(ROUTE_TABLE, 'utf8'); + } catch { + return ''; + } +} + +async function start() { + const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() }); + if (trustedPeers) { + // Set before Vite is imported: the dev server snapshots process.env during + // configuration, and the admission gate reads it per request. + process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; + console.log(`[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`); + } else { + console.log('[Docker] No default gateway; Provider Settings stays loopback-only.'); + } + + const port = Number.parseInt(process.env.PORT, 10) || 4173; + const { createServer } = await loadViteFromCanonicalRoot(ROOT); + const server = await createServer({ + root: ROOT, + server: { + // The container's own namespace. compose publishes this to the host's + // 127.0.0.1 only; widening that mapping is the LAN opt-in. + host: '0.0.0.0', + port, + strictPort: true, + allowedHosts: resolveAllowedHosts(), + }, + }); + await server.listen(); + server.printUrls(); + console.log(`[Docker] Ready at http://localhost:${port}/`); + + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, async () => { + await server.close(); + process.exit(0); + }); + } +} + +if (isDirectInvocation(process.argv[1], MODULE_PATH)) { + start().catch((error) => { + console.error(`[Docker] Start refused: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs new file mode 100644 index 000000000..85ac525a4 --- /dev/null +++ b/src/dockerLauncher.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + defaultGatewayFromRouteTable, + resolveAllowedHosts, + resolveTrustedPeers, +} from '../scripts/docker-start.mjs'; + +// The shape of /proc/net/route inside a bridge-networked container: +// little-endian hex addresses, the default route first, an interface route +// after. +const BRIDGE_ROUTES = [ + 'Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT', + 'eth0\t00000000\t0100A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0', + 'eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00F0FFFF\t0\t0\t0', + '', +].join('\n'); + +test('the container gateway is decoded from the kernel routing table', () => { + assert.equal(defaultGatewayFromRouteTable(BRIDGE_ROUTES), '192.168.0.1', 'little-endian hex, default route only'); + assert.equal( + defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '011FAC')), + null, + 'a malformed gateway field is not an address', + ); + assert.equal( + defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('\t0003\t', '\t0002\t')), + null, + 'a default route that is not up is ignored', + ); + assert.equal( + defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '00000000')), + null, + 'a zero gateway is not an address', + ); + assert.equal( + defaultGatewayFromRouteTable( + BRIDGE_ROUTES.split('\n').filter((line) => !line.includes('00000000\t0100A8C0')).join('\n'), + ), + null, + '--network none has no default route and must not invent one', + ); + assert.equal(defaultGatewayFromRouteTable(''), null); + assert.equal(defaultGatewayFromRouteTable(undefined), null); +}); + +test('an operator list wins, and no gateway trusts nothing', () => { + assert.equal(resolveTrustedPeers({ env: {}, routeTable: BRIDGE_ROUTES }), '192.168.0.1'); + assert.equal(resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.9.8.7 ' }, routeTable: BRIDGE_ROUTES }), '10.9.8.7'); + assert.equal( + resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' ' }, routeTable: BRIDGE_ROUTES }), + '192.168.0.1', + 'a blank override falls through to the gateway', + ); + assert.equal(resolveTrustedPeers({ env: {}, routeTable: '' }), '', 'loopback-only when there is no gateway'); +}); + +test('the container answers the same host names as the native launcher', () => { + assert.deepEqual(resolveAllowedHosts({}), ['localhost', '127.0.0.1', '.local'], 'a wildcard bind never means "any Host header"'); + assert.deepEqual( + resolveAllowedHosts({ GEV_ALLOWED_HOSTS: ' globe.lan , nas.internal ' }), + ['localhost', '127.0.0.1', '.local', 'globe.lan', 'nas.internal'], + 'an operator can still add a LAN hostname', + ); + assert.deepEqual( + resolveAllowedHosts({ GEV_ALLOWED_HOSTS: ' , localhost , ' }), + ['localhost', '127.0.0.1', '.local'], + 'blank and duplicate entries change nothing', + ); +}); + +test('the Docker install path keeps its loopback-only, launcher-owned contract', () => { + const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8'); + assert.match(dockerfile, /^CMD \["node", "scripts\/docker-start\.mjs"\]$/m, 'the launcher is the container entry point'); + + const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8'); + assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only'); + assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded'); + assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); +}); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index 6c3cecb2e..73044f003 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -126,6 +126,29 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); /** Socket addresses that count as this machine. */ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); +/** + * Whether a socket address is one the operator has explicitly declared to be + * this machine through GEV_KEY_SETUP_TRUSTED_PEERS: a comma-separated list of + * EXACT addresses, never wildcards, prefixes or hostnames. Unset trusts + * nothing, which is every launcher but one. + * + * The shipped use is the Docker launcher (scripts/docker-start.mjs). A port + * published on the host's loopback still reaches the container FROM the + * container network's gateway, never from 127.0.0.1, so the loopback check + * alone refuses the very machine running the server. Admitting that one + * address relaxes nothing else: the sharing, Host, Origin, Content-Type and + * proxy-header checks all still apply to it. + */ +function isTrustedPeerAddress(remoteAddress, env) { + const normalize = (value) => + String(value || '').trim().toLowerCase().replace(/^::ffff:/, ''); + const address = normalize(remoteAddress); + if (!address) return false; + return String(env?.GEV_KEY_SETUP_TRUSTED_PEERS || '') + .split(',') + .some((entry) => entry.trim() !== '' && normalize(entry) === address); +} + /** Parse an exact local request authority from a Host header. */ function localAuthority(hostHeader, protocol) { const raw = String(hostHeader || '') @@ -224,7 +247,9 @@ export function isSharingEnabled(env = {}) { * outright — a credential-writing endpoint has no business existing on a * shared instance, and tunnel traffic reaches the server FROM loopback, so * the socket check below cannot carry that boundary alone; - * - loopback socket: refuses LAN peers when the server is bound wide; + * - loopback socket: refuses LAN peers when the server is bound wide. The + * only exception is an exact address listed in + * GEV_KEY_SETUP_TRUSTED_PEERS, and every check below still applies to it; * - local Host header: tunnel and DNS-rebinding traffic carries a foreign * Host even when the socket says loopback; * - exact same Origin on POST: a hostile web page can make a browser POST to @@ -274,7 +299,10 @@ export function admitKeySetupRequest({ error: 'Provider Settings is disabled while sharing is enabled', }; } - if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) { + if ( + !LOOPBACK_ADDRESSES.has(String(remoteAddress || '')) && + !isTrustedPeerAddress(remoteAddress, env) + ) { return { ok: false, status: 403, diff --git a/src/keySetupCore.test.mjs b/src/keySetupCore.test.mjs index 05f406009..0b66e59b8 100644 --- a/src/keySetupCore.test.mjs +++ b/src/keySetupCore.test.mjs @@ -258,6 +258,23 @@ test('the admission gate refuses every non-local shape, one assertion per refusa assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface'); // A LAN peer reaching a wide-bound server. assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused'); + // A container publishes its port on the host's loopback, yet the host's own + // browser still arrives from the container network's gateway. Only an exact + // address the operator listed admits it, and nothing else is relaxed. + const trusted = { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.0.0.9 , 172.29.73.1 ' }; + const gateway = { ...local, remoteAddress: '172.29.73.1' }; + assert.equal(admitKeySetupRequest(gateway).ok, false, 'container gateway refused without the opt-in'); + assert.equal(admitKeySetupRequest({ ...gateway, env: trusted }).ok, true, 'listed peer admitted'); + assert.equal(admitKeySetupRequest({ ...gateway, remoteAddress: '::ffff:172.29.73.1', env: trusted }).ok, true, 'IPv4-mapped form of a listed peer admitted'); + assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20', env: trusted }).ok, false, 'unlisted peer still refused'); + assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '*' } }).ok, false, 'wildcards are not addresses'); + assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '172.29.73.0/24' } }).ok, false, 'prefixes are not addresses'); + assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '' } }).ok, false, 'an empty list trusts nothing'); + assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' , , ' } }).ok, false, 'blank entries trust nothing'); + assert.equal(admitKeySetupRequest({ ...gateway, env: { ...trusted, PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'sharing still disables the surface for a listed peer'); + assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'a listed peer still needs a local Host'); + assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, origin: 'https://evil.example' }).ok, false, 'a listed peer still needs the exact Origin'); + assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, proxyHeaders: { 'x-forwarded-for': '203.0.113.5' } }).ok, false, 'a listed peer still refuses proxied requests'); // Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket. assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused'); assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');