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

+33 -18
View File
@@ -10,7 +10,7 @@
* 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 { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDirectInvocation } from './pinokio-install.mjs';
@@ -19,8 +19,6 @@ 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
@@ -63,21 +61,21 @@ export function resolveTrustedPeers({ env = process.env, 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[]}
* 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 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])];
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() {
@@ -89,6 +87,24 @@ function readRouteTable() {
}
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 trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
if (trustedPeers) {
// Set before Vite is imported: the dev server snapshots process.env during
@@ -109,7 +125,6 @@ async function start() {
host: '0.0.0.0',
port,
strictPort: true,
allowedHosts: resolveAllowedHosts(),
},
});
await server.listen();