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');