diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..174f7990a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Keep keys and local state out of the build context: compose bind-mounts the +# checkout at run time, so the image never needs them. +.git +node_modules +dist +.env +.env.* +!.env.example +**/ENVIRONMENT +pinokio/ENVIRONMENT +pinokio/.installed +.gev-cache +.gev-logs +qa-artifacts +qa-shots +screenshots +output +3d-models +.gstack +*.log +.DS_Store +# 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 6d3ee3f6f..4e12a8eca 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,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 111d4945f..c885e2caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -290,6 +290,21 @@ [TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling from #653. +- **Docker install path.** `docker compose up` builds on CI's Node 24.14.0 image, + pinned by digest, and 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 + (never root), so `.env`, + `.gev-cache`, and `.gev-logs` persist on the host exactly as they do under + `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 now select one winner; unavailable storage or a busy repair retains the diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..d34f4887a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 +# +# The dev server is the whole app: Vite serves the client AND runs the API +# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there +# is nothing else to containerise. See compose.yaml for the usual entry point. +# Same Node as CI (package.json engines, .github/workflows), pinned by digest. +FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8 + +# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download. +ENV PUPPETEER_SKIP_DOWNLOAD=1 + +# `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/* +# The repo is bind-mounted, so inside the container it can show an owner other +# than the user the container runs as - always, on Docker Desktop. git then +# refuses it as "dubious ownership", which fails the tests that shell out to +# git. It is the user's own checkout, so trust that one path. +RUN git config --system --add safe.directory /app + +# Never run as root. The image's own user is `node` (1000); compose overrides +# it with the host user so bind-mounted files stay yours. +WORKDIR /app +RUN chown node:node /app +USER node + +COPY --chown=node:node package.json package-lock.json ./ +RUN npm ci +# node_modules belongs to `node`, but compose may run as another UID. Vite only +# needs to write its dep-optimizer cache, so open up those two directories +# rather than chowning a few hundred MB to a UID we cannot know at build time. +RUN mkdir -p node_modules/.vite && chmod 777 node_modules node_modules/.vite + +COPY --chown=node:node . . + +# Bind to all interfaces, otherwise the published port hits nothing. This does +# not widen the Host allowlist: IP addresses and localhost are accepted, other +# names only via GEV_ALLOWED_HOSTS (see build/allowedHosts.js). +ENV HOST=0.0.0.0 +ENV PORT=4173 +EXPOSE 4173 +# 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 68da35027..578ccb185 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,72 @@ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md). **macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any configured keys straight from the Keychain. It starts keyless too. +### Path 3 — Docker + +No Node install needed; the container publishes the same dev server. + +```bash +git clone https://github.com/bilawalsidhu/gods-eye-view.git +cd gods-eye-view +docker compose up +``` + +Open **`http://localhost:4173`** as above. Everything the app saves stays in the folder you cloned, so `docker compose down` never loses anything: + +- `.env` - your API keys, whether saved from Provider Settings or typed in by hand +- `.gev-cache/` - downloaded map and satellite data, so later starts are faster +- `.gev-logs/` - logs + +Back up that folder and you've backed up everything; delete `.env` to forget your keys. Source edits still hot-reload. The port is published to `127.0.0.1` only, so the default matches the native one. + +The local MCP server and ChatGPT OAuth voice are not available under Docker; use an OpenAI API key for voice. + +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. + +When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. + +
+Running on Linux, a NAS, or behind a reverse proxy + +Run it as the user who should own the files it writes. `id` shows the numbers; for yourself on Linux: + +```bash +env UID=$(id -u) GID=$(id -g) docker compose up +``` + +On a NAS, clone onto the share you want the data on and use the share owner's ids: + +```bash +cd /volume1/docker +git clone https://github.com/bilawalsidhu/gods-eye-view.git +cd gods-eye-view +env UID=1026 GID=100 docker compose up -d +``` + +Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. + +Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). + +
+ +
+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, +recreate it with `docker compose down -v && docker compose up --build`. The +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 bf88cafb7..cc3f27cfe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -87,6 +87,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/compose.yaml b/compose.yaml new file mode 100644 index 000000000..7826bf376 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +services: + gods-eye-view: + build: . + # Everything the app writes (.env with your keys, .gev-cache, .gev-logs) + # lands in the bind-mounted repo, so run as you, not root. UID/GID are + # unset on Docker Desktop, where the 1000 fallback is ignored anyway. + user: "${UID:-1000}:${GID:-1000}" + ports: + # Publish to loopback only. Docker's default ("4173:4173") would bind + # every host interface, which quietly turns the LAN warning in the README + # into the default: this server brokers whatever provider keys you have + # configured to anyone who can reach it. Widen this deliberately, not by + # accident. + - "127.0.0.1:4173:4173" + volumes: + # Bind the source so edits hot-reload and so keys pasted into the setup + # UI land in the .env on your host instead of vanishing with the + # container. node_modules stays in the image: sharp and friends are + # compiled for Linux and must not be shadowed by a host install. + - .:/app + - /app/node_modules diff --git a/scripts/docker-start.mjs b/scripts/docker-start.mjs new file mode 100644 index 000000000..3935f2b3b --- /dev/null +++ b/scripts/docker-start.mjs @@ -0,0 +1,154 @@ +#!/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 { accessSync, constants, readFileSync, realpathSync, statSync } 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'; + +/** + * 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) ?? ''; +} + +/** + * A checkout the container cannot write fails quietly: Provider Settings + * answers 500 and `.gev-cache` is never created, with nothing in the log. The + * usual cause is a clone made as root on a NAS or Proxmox host while the + * container runs as 1000:1000. Say so at start, naming both sets of ids. + * @param {{root: string, writable: boolean, owner: {uid: number, gid: number}, runAs: {uid: number, gid: number}}} input + * @returns {string|null} A warning, or null when the checkout is writable. + */ +export function unwritableRootWarning({ root, writable, owner, runAs }) { + if (writable) return null; + return ( + `[Docker] ${root} is owned by ${owner.uid}:${owner.gid} but the container runs as ` + + `${runAs.uid}:${runAs.gid}, so saving keys and caching will fail. Either give ` + + `the checkout to that user (chown -R ${runAs.uid}:${runAs.gid} on the host) or ` + + `run as its owner (env UID=${owner.uid} GID=${owner.gid} docker compose up)` + ); +} + +function readRouteTable() { + try { + return readFileSync(ROUTE_TABLE, 'utf8'); + } catch { + return ''; + } +} + +async function start() { + let writable = true; + try { + accessSync(ROOT, constants.W_OK); + } catch { + writable = false; + } + if (!writable) { + const { uid, gid } = statSync(ROOT); + console.warn( + unwritableRootWarning({ + root: ROOT, + writable, + owner: { uid, gid }, + runAs: { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 }, + }), + ); + } + + const fromOperator = Boolean( + String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(), + ); + 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( + fromOperator + ? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).` + : `[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, + }, + }); + 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..ae9987b01 --- /dev/null +++ b/src/dockerLauncher.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + defaultGatewayFromRouteTable, + unwritableRootWarning, + 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('an unwritable checkout is named at start instead of failing quietly', () => { + const ids = { owner: { uid: 0, gid: 0 }, runAs: { uid: 1000, gid: 1000 } }; + assert.equal(unwritableRootWarning({ root: '/app', writable: true, ...ids }), null, 'a writable checkout says nothing'); + const warning = unwritableRootWarning({ root: '/app', writable: false, ...ids }); + assert.match(warning, /owned by 0:0/, 'names the owner'); + assert.match(warning, /runs as 1000:1000/, 'names the ids the container runs as'); + assert.match(warning, /chown -R 1000:1000/, 'offers handing the checkout to the container user first'); + assert.match(warning, /env UID=0 GID=0 docker compose up/, 'or the env form, which bash does not ignore'); +}); + +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 60910a66c..d455f1ee4 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -126,6 +126,32 @@ 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 +250,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 +302,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 235deaa36..c734fe766 100644 --- a/src/keySetupCore.test.mjs +++ b/src/keySetupCore.test.mjs @@ -259,6 +259,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');