mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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 <[email protected]> Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
10 files changed
+328
-9
No files matched your search
@@ -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');
|
||||
});
|
||||
+30
-2
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user