fix(auth): close the JWT_SECRET bootstrap chain — real-peer loopback, obsidian always-protected, DATA_DIR vault refusal (#13791)

GHSA-7pq4-8pvv-rx7r (critical). Every link of the reported chain held on the
release tip:

1. First boot without JWT_SECRET generates one and writes it in cleartext to
   $DATA_DIR/server.env.
2. With no password configured, isAuthRequired() returned false for
   POST /api/settings/require-login unconditionally — before the loopback
   check — so any network peer could switch requireLogin off.
3. With requireLogin off, POST /api/settings/obsidian/webdav accepted an
   arbitrary vault root and echoed freshly minted Basic credentials.
4. The WebDAV file service is served by the custom Node layer before Next.js,
   outside the authz pipeline.
5. Pointing it at DATA_DIR reads server.env, and JWT_SECRET forges an
   `{"authenticated":true}` admin session.

A second, worse problem surfaced while verifying: isLoopbackRequest() decided
"loopback" from nextUrl.hostname / the Host header, which the client controls.
`Host: localhost` from a remote address made the whole fresh-install bootstrap
reachable, not just the write path.

Three cuts, plus the root cause:

- isLoopbackRequest() now reads the trusted peer: the token-stamped real TCP
  peer the custom server writes (peerStamp), then the pipeline's own locality
  verdict once a stamp token exists, then a real socket peer. The bootstrap
  write path honours the same constraint instead of returning false, and
  managementPolicy hands down the peerContext verdict explicitly, because at
  policy time the original request still carries client-supplied headers.
- Host is consulted only when the process has no stamp token at all — no
  stamping server in front, which in practice means route handlers invoked
  directly by the unit-test harness. Every supported runtime (run-next dev and
  start, standalone-server-ws for Docker, the npm CLI and Electron) calls
  ensurePeerStampToken() at boot, so there a signal-less request fails closed.
  Without this fallback ~340 route tests that call handlers with
  `new Request("http://localhost/…")` turned into 401s.
- /api/settings/obsidian joins ALWAYS_PROTECTED_API_PATHS: issuing and rotating
  reusable WebDAV credentials is credential export, the same rationale as the
  GHSA-62vw entry for the password reveal.
- enableObsidianVaultSync() refuses a vault that is, sits inside, or contains
  DATA_DIR, comparing realpath-resolved paths so a symlink cannot dodge it.

Tests are red-first: remote stamped peer → auth required on the bootstrap
write; Host: localhost plus a forged locality header from a non-loopback
stamped peer → 401 through the full pipeline; the local operator keeps the
first-password flow; obsidian inventory and DATA_DIR overlap cases.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-15 16:58:24 -03:00
committed by GitHub
parent 87d9d82b37
commit df87e9363b
13 changed files with 715 additions and 62 deletions

View File

@@ -57,11 +57,11 @@ async function main() {
OMNIROUTE_BASE_URL: baseUrl,
}),
OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open",
// Pin the custom server's bind address to loopback (#11535): under the
// programmatic next() entry the middleware's nextUrl.hostname mirrors the
// configured HOST (default "0.0.0.0"), and apiAuth.isLoopbackRequest() reads
// nextUrl.hostname FIRST — an unpinned boot makes every request look remote,
// so the anonymous open-bootstrap allow never fires (401 green-shallow).
// Pin the custom server's bind address to loopback (#11535). The bootstrap
// loopback verdict (apiAuth.isLoopbackRequest) comes from the peer stamp the
// custom server writes from the real TCP socket (GHSA-7pq4-8pvv-rx7r), never
// from nextUrl.hostname / Host — the pin keeps the harness's own clients on a
// loopback socket so that stamp resolves to 127.0.0.1.
HOST: process.env.HOST || "127.0.0.1",
};