fix(docker): defer to main's Host allowlist and name an unwritable checkout

Two findings from PoBruno's Docker Engine on Linux run (#208), and one from
re-running the suite in the container after rebasing:

- The launcher pinned its own allowedHosts, including '.local'. Vite reads a
  leading dot as a suffix match, so `Host: anything.local` reached the page and
  the data proxies. Main has since moved the policy into build/allowedHosts.js,
  which drops suffix entries on purpose and applies before the app's routes, so
  the launcher's override is deleted rather than patched. As a side effect
  GEV_ALLOWED_HOSTS now also works from `.env`, as it does under `npm run dev`.
- A checkout the container cannot write - typically a clone made as root on a
  NAS or Proxmox host - made Save Keys answer 500 and never created .gev-cache,
  with nothing logged. The launcher now warns at start, naming the owner, the
  ids it runs as, and the two fixes.
- On Docker Desktop the bind-mounted repo always shows an owner other than the
  container user, so git refuses it as "dubious ownership" and the tests that
  shell out to git fail (src/data/bundledJson.test.mjs, new on main). The image
  now marks /app, and only /app, as a safe directory.

Verified in a container on this base: `anything.local` 403 on the page and on
/api routes, localhost 200, GEV_ALLOWED_HOSTS honoured from the environment and
from .env, the warning on a root-owned /app run as 1000:1000, and
bundledJson.test.mjs 6/6.

Reported-by: PoBruno
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
SM-26andClaude Opus 5.5 committed 2026-10-10 19:48:02 +02:00
1 parent 1c6c291774
commit 79a6a0db5c
4 files changed
+51 -32

No files matched your search

+9 -13
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
defaultGatewayFromRouteTable,
resolveAllowedHosts,
unwritableRootWarning,
resolveTrustedPeers,
} from '../scripts/docker-start.mjs';
@@ -56,18 +56,14 @@ test('an operator list wins, and no gateway trusts nothing', () => {
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('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', () => {