Files
waggle-os/docs/superpowers/plans/2026-05-29-prod-readiness-phase1-network-auth.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

16 KiB

Phase 1 — Network Exposure & Auth Boundary — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Close the critical LAN auth-bypass (R1-001) and its surrounding network-exposure cluster (R2-003, R2-006, R2-004, R6-005) on the shippable sidecar, then restore the two non-functional quality gates (lint, tauri-tsc).

Architecture: Defense-in-depth on the HTTP boundary: (1) bind loopback by default, opt into 0.0.0.0 only via WAGGLE_HOST (already the env the deploys should set); (2) stop the unauthenticated /health from leaking the bearer token; (3) exact-match CORS; (4) a shared same-origin guard reused across the sensitive local-only endpoints; (5) Host-header allowlist to defeat DNS-rebinding against the localhost-trust exemption.

Tech Stack: Fastify 5 sidecar (packages/server), Vitest, React adapter (apps/web/src/lib/adapter.ts), Docker/Render deploy configs.

Verify gate (run after every task):

  • npx tsc --noEmit -p packages/server/tsconfig.json → 0 errors
  • npx vitest run packages/server/tests/local/network-auth.test.ts packages/server/tests/local/security-middleware.test.ts → green
  • Adapter task also: cd apps/web && npx tsc --noEmit (or root npm run build)

File Map

File Action Responsibility
packages/server/src/local/origin-guard.ts Create isLocalOrigin() + isLocalRequest() — shared same-origin gate (URL-parse, no prefix bypass)
packages/server/src/local/net-config.ts Create resolveBindHost(env) — loopback default, WAGGLE_HOST override
packages/server/src/local/routes/vault.ts Modify ~169-186 Use shared isLocalRequest (behavior-preserving)
packages/server/src/local/service.ts Modify :202 Bind via resolveBindHost
packages/server/src/local/index.ts Modify :278, :1904, :1920-1937, :2329 bind default, CORS exact-match, gate /api/debug/logs + drop providerKeys, drop wsToken from /health
packages/server/src/local/security-middleware.ts Modify ~236-294 Host-header allowlist (R2-004)
packages/server/src/local/routes/browse.ts Modify :20-94 Gate /api/browse/* to local request (R6-005)
apps/web/src/lib/adapter.ts Modify :106-117 Stop reading wsToken from /health; connect() returns health
apps/web/src/lib/adapter.ts (SystemHealth type) Modify Drop wsToken field
Dockerfile, render.yaml, docker-compose.production.yml Modify WAGGLE_HOST=0.0.0.0 (preserve cloud reachability)
eslint.config.js (root) Create Restore functional repo lint gate
app/tsconfig.json Modify Point tauri-tsc gate at real TS / stop failing on empty src/
packages/server/tests/local/network-auth.test.ts Create All Phase-1 regression tests

Task 1: Shared same-origin guard (origin-guard.ts)

Files: Create packages/server/src/local/origin-guard.ts; Test packages/server/tests/local/network-auth.test.ts

  • Step 1 — failing test (in network-auth.test.ts):
import { describe, it, expect } from 'vitest';
import { isLocalOrigin } from '../../src/local/origin-guard.js';

describe('isLocalOrigin', () => {
  it('allows local + tauri origins', () => {
    expect(isLocalOrigin('http://127.0.0.1:1420')).toBe(true);
    expect(isLocalOrigin('http://localhost:3333')).toBe(true);
    expect(isLocalOrigin('tauri://localhost')).toBe(true);
    expect(isLocalOrigin('https://tauri.localhost')).toBe(true);
  });
  it('rejects external + prefix-bypass origins', () => {
    expect(isLocalOrigin('https://evil.example.com')).toBe(false);
    expect(isLocalOrigin('http://localhost.evil.com')).toBe(false);
    expect(isLocalOrigin('not-a-url')).toBe(false);
  });
});
  • Step 2 — run, expect fail (module missing): npx vitest run packages/server/tests/local/network-auth.test.ts
  • Step 3 — implement origin-guard.ts:
import type { FastifyRequest } from 'fastify';

const LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);

/** True if the Origin/Referer string denotes the local Waggle app.
 *  URL-parsed to prevent prefix-bypass (http://localhost.evil.com). */
export function isLocalOrigin(raw: string): boolean {
  try {
    const u = new URL(raw);
    if (u.protocol === 'tauri:') return true;
    if (u.protocol === 'https:' && u.hostname === 'tauri.localhost') return true;
    if ((u.protocol === 'http:' || u.protocol === 'https:') && LOCAL_HOSTS.has(u.hostname)) return true;
    return false;
  } catch { return false; }
}

/** Same-origin gate for sensitive local-only endpoints. A request with no
 *  origin/referer is treated as local (same-host curl / server inject); the
 *  127.0.0.1 bind is the primary control, this is defense in depth. */
export function isLocalRequest(request: FastifyRequest): boolean {
  const origin = request.headers.origin;
  if (origin) return isLocalOrigin(origin);
  const referer = request.headers.referer;
  if (referer) return isLocalOrigin(referer);
  return true;
}
  • Step 4 — run, expect pass
  • Step 5 — refactor vault.ts to use the shared helper (replace the inline isLocalOrigin closure at lines 169-186 with an import + if (!isLocalRequest(request)) return reply.code(403).send({ error: 'Forbidden: external origin not allowed for vault reveal' });). Keep the 403 message. Run npx vitest run packages/server/tests/local/security-middleware.test.ts (vault origin test) → still green.
  • Step 6 — commit: fix(server): extract shared same-origin guard (origin-guard.ts), reuse in vault [R2-006/R6-005 prep]

Task 2: Bind loopback by default (R1-001a)

Files: Create net-config.ts; Modify service.ts:202, index.ts:278, Dockerfile, render.yaml, docker-compose.production.yml

  • Step 1 — failing test (network-auth.test.ts):
import { resolveBindHost } from '../../src/local/net-config.js';
describe('resolveBindHost', () => {
  it('defaults to loopback', () => expect(resolveBindHost({})).toBe('127.0.0.1'));
  it('honors WAGGLE_HOST', () => expect(resolveBindHost({ WAGGLE_HOST: '0.0.0.0' })).toBe('0.0.0.0'));
  it('ignores blank WAGGLE_HOST', () => expect(resolveBindHost({ WAGGLE_HOST: '  ' })).toBe('127.0.0.1'));
});
  • Step 2 — run, expect fail
  • Step 3 — implement net-config.ts:
/** Host the sidecar binds to. Loopback by default (desktop product is
 *  localhost-only); deploys that must accept external traffic set WAGGLE_HOST. */
export function resolveBindHost(env: NodeJS.ProcessEnv = process.env): string {
  const h = env.WAGGLE_HOST?.trim();
  return h && h.length > 0 ? h : '127.0.0.1';
}
  • Step 4 — wire it: service.ts:202await server.listen({ port, host: resolveBindHost() }); (import it). index.ts:278host: resolveBindHost(),.
  • Step 5 — preserve cloud reachability (CRITICAL — these deploys are public and currently rely on the old default):
    • Dockerfile (after line 99 ENV WAGGLE_DATA_DIR=/data): add ENV WAGGLE_HOST=0.0.0.0
    • render.yaml envVars: add - key: WAGGLE_HOST / value: 0.0.0.0
    • docker-compose.production.yml: add WAGGLE_HOST=0.0.0.0 to the server service environment: block (read file first to place correctly)
  • Step 6 — run resolveBindHost test + npx tsc --noEmit -p packages/server/tsconfig.json → green
  • Step 7 — commit: fix(server): bind 127.0.0.1 by default; deploys opt into 0.0.0.0 via WAGGLE_HOST [R1-001a]

Task 3: Stop /health leaking the bearer token (R1-001b)

Files: index.ts:2329, apps/web/src/lib/adapter.ts

  • Step 1 — failing test (network-auth.test.ts) — build a minimal server exposing the real /health shape is heavy; instead assert the contract at the route via a focused integration that mounts security middleware + a /health that must NOT echo the token. Simplest durable test: assert the /health handler object built in index.ts has no wsToken. Pragmatic approach — add a route-level test using buildLocalServer is too heavy; use a contract test on the adapter instead (Step 4). For the server side, the regression guard is: grep-proof + the existing security-middleware.test.ts line 400 expectation must be updated. Update that test's /health stub to NOT include wsToken and assert res.json().wsToken is undefined.
  • Step 2 — server fix: remove wsToken: server.agentState.wsSessionToken, from the /health return object (index.ts:2329). Leave the rest of the health payload intact.
  • Step 3 — adapter fix (apps/web/src/lib/adapter.ts):
    • connect() (106-117): drop this.authToken = data.wsToken;. Change signature to async connect(): Promise<SystemHealth> and return data;.
    • Find the SystemHealth type def (grep -rn "wsToken\|SystemHealth" apps/web/src) and remove the wsToken field.
    • authToken stays a settable field (kept for explicit out-of-band token config), just no longer auto-harvested from /health. fetch() already guards if (this.authToken), so an unset token simply sends no header — correct for the localhost-trusted desktop.
  • Step 4 — verify: cd apps/web && npx tsc --noEmit → 0 errors (proves no caller depended on the removed field). npx vitest run packages/server/tests/local/security-middleware.test.ts → green.
  • Step 5 — commit: fix(server,web): drop wsToken from unauthenticated /health; adapter no longer harvests it [R1-001b]

Task 4: Exact-match CORS (R2-003)

Files: index.ts:1904

  • Step 1 — failing test: unit-test the origin predicate. Extract the CORS check is overkill; instead assert via the existing ALLOWED_ORIGINS + .includes. Add test importing ALLOWED_ORIGINS from cors-config.js and asserting a corsOriginAllowed(origin) helper. Create a tiny exported helper in cors-config.ts:
export function corsOriginAllowed(origin: string | undefined): boolean {
  return !origin || ALLOWED_ORIGINS.includes(origin);
}

Test: corsOriginAllowed('http://localhost:1420')===true; corsOriginAllowed('http://localhost:1420.evil.com')===false; corsOriginAllowed(undefined)===true.

  • Step 2 — run, expect fail
  • Step 3 — implement the helper, then change index.ts:1904 CORS callback to:
origin: (origin, cb) => {
  if (corsOriginAllowed(origin)) cb(null, true);
  else cb(new Error('CORS: origin not allowed'), false);
},
  • Step 4 — run, expect pass + tsc -p packages/server
  • Step 5 — commit: fix(server): CORS exact-origin match, no startsWith prefix bypass [R2-003]

Task 5: Gate /api/debug/logs + drop key names (R2-006)

Files: index.ts:1920-1937

  • Step 1 — fix: at the top of the /api/debug/logs handler add:
if (!isLocalRequest(_request)) return reply.code(403).send({ error: 'Forbidden: external origin' });

(rename _requestrequest since it is now used; import isLocalRequest). Remove the payload.providerKeys = ... block entirely (vault key names are recon material; the health + audit rows are enough for support).

  • Step 2 — test (network-auth.test.ts): mount a Fastify server with the route + assert external origin → 403, and that the payload has no providerKeys. (Build a minimal server registering just this handler, or reuse buildLocalServer if cheap; prefer a focused mini-server.)
  • Step 3 — run green + tsc
  • Step 4 — commit: fix(server): same-origin gate /api/debug/logs; drop vault key names [R2-006]

Task 6: Gate /api/browse/* to local (R6-005)

Files: routes/browse.ts:20-94

  • Step 1 — fix: at the start of BOTH /api/browse/local (GET) and /api/browse/local/mkdir (POST) handlers add:
if (!isLocalRequest(request)) return reply.status(403).send({ error: 'Forbidden: external origin' });

(import isLocalRequest). Filesystem browse legitimately needs host access (workspace path picker) — confine by origin, not by path.

  • Step 2 — test: mini-server with browseRoutes; external-origin GET + mkdir → 403; no-origin GET → 200.
  • Step 3 — run green + tsc
  • Step 4 — commit: fix(server): same-origin gate /api/browse/* [R6-005]

Task 7: Host-header allowlist (R2-004)

Files: security-middleware.ts:236-294

  • Step 1 — fix: in the onRequest hook, before the localhost-trust exemption, reject requests whose Host header is neither a loopback host nor an allowlisted name (defeats DNS-rebinding that would otherwise satisfy the IP-based localhost trust). Add a HOST_ALLOWLIST (127.0.0.1, localhost, ::1, plus WAGGLE_ALLOWED_HOSTS comma-env for cloud). Skip the check entirely when WAGGLE_HOST is explicitly set to a non-loopback (cloud deploy behind its own proxy) to avoid breaking Render's host header — gate it: only enforce when bound to loopback.
// after requestPath is computed
const hostHeader = (request.headers.host ?? '').split(':')[0];
const boundLoopback = resolveBindHost() === '127.0.0.1';
if (boundLoopback && hostHeader && !HOST_ALLOWLIST.has(hostHeader)) {
  return reply.code(403).send({ error: 'Forbidden', code: 'BAD_HOST' });
}
  • Step 2 — test: server with middleware; Host: evil.com → 403; Host: 127.0.0.1 / localhost → pass. Use server.inject({ headers: { host: 'evil.com' } }).
  • Step 3 — run green (and re-run full security-middleware.test.ts) + tsc
  • Step 4 — commit: fix(server): Host-header allowlist when bound loopback (anti DNS-rebind) [R2-004]

Task 8: Repair the dead quality gates (cross-cutting)

Files: Create root eslint.config.js; Modify app/tsconfig.json

  • Step 1 — lint: create a root eslint.config.js (flat) scoped to shippable source (packages/server/src, apps/web/src) using typescript-eslint recommended, with stylistic/noisy rules relaxed so current code passes; bug-catching rules (no-undef, no-empty with allowEmptyCatch, no-unused-vars warn) on. Run npm run lint; iterate rule severities until exit 0. (Goal: gate provides signal + is green, not a lint-cleanup campaign.)
  • Step 2 — tauri-tsc: app/src/ is empty (CLAUDE.md §2 drift — cockpit UI no longer there; desktop loads apps/web dist). Repoint app/tsconfig.json include at the TS that actually exists in app/ (scripts, tailwind.config.ts) OR, if those have their own configs, narrow the gate. Run npx tsc --noEmit -p app/tsconfig.json → exit 0.
  • Step 3 — reconcile docs: fix the CLAUDE.md §2 line claiming app/src/components/cockpit/ ships UI (it does not). One-line factual correction.
  • Step 4 — commit: fix(build): restore lint + tauri-tsc gates; reconcile CLAUDE.md app/src drift

Self-Review

  • Spec coverage: R1-001 (Tasks 2+3), R2-003 (Task 4), R2-006 (Task 5), R6-005 (Task 6), R2-004 (Task 7), gate repair (Task 8). all Phase-1 finding ids covered.
  • Cross-cutting safety: bind flip paired with deploy WAGGLE_HOST (Task 2 Step 5); /health token removal paired with adapter + type fix (Task 3); Host-allowlist only enforced when loopback-bound so Render is unaffected (Task 7).
  • Type consistency: isLocalRequest/isLocalOrigin (origin-guard.ts) reused in vault/debug/browse; resolveBindHost (net-config.ts) reused in service.ts/index.ts/security-middleware.ts; corsOriginAllowed/ALLOWED_ORIGINS (cors-config.ts).
  • Verdict caveat: R2-004, R2-006, R6-005 were unverified in the audit (plausible, evidence cited). The failing-test-first step for each independently re-confirms the issue before the fix lands.