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 errorsnpx 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 rootnpm 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.tsto use the shared helper (replace the inlineisLocalOriginclosure 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. Runnpx 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:202→await server.listen({ port, host: resolveBindHost() });(import it).index.ts:278→host: resolveBindHost(),. - Step 5 — preserve cloud reachability (CRITICAL — these deploys are public and currently rely on the old default):
Dockerfile(after line 99ENV WAGGLE_DATA_DIR=/data): addENV WAGGLE_HOST=0.0.0.0render.yamlenvVars: add- key: WAGGLE_HOST/value: 0.0.0.0docker-compose.production.yml: addWAGGLE_HOST=0.0.0.0to the server serviceenvironment:block (read file first to place correctly)
- Step 6 — run
resolveBindHosttest +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/healthshape is heavy; instead assert the contract at the route via a focused integration that mounts security middleware + a/healththat must NOT echo the token. Simplest durable test: assert the/healthhandler object built inindex.tshas nowsToken. Pragmatic approach — add a route-level test usingbuildLocalServeris too heavy; use a contract test on the adapter instead (Step 4). For the server side, the regression guard is: grep-proof + the existingsecurity-middleware.test.tsline 400 expectation must be updated. Update that test's/healthstub to NOT include wsToken and assertres.json().wsTokenisundefined. - Step 2 — server fix: remove
wsToken: server.agentState.wsSessionToken,from the/healthreturn 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): dropthis.authToken = data.wsToken;. Change signature toasync connect(): Promise<SystemHealth>andreturn data;.- Find the
SystemHealthtype def (grep -rn "wsToken\|SystemHealth" apps/web/src) and remove thewsTokenfield. authTokenstays a settable field (kept for explicit out-of-band token config), just no longer auto-harvested from/health.fetch()already guardsif (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 importingALLOWED_ORIGINSfromcors-config.jsand asserting acorsOriginAllowed(origin)helper. Create a tiny exported helper incors-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:1904CORS 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/logshandler add:
if (!isLocalRequest(_request)) return reply.code(403).send({ error: 'Forbidden: external origin' });
(rename _request → request 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 noproviderKeys. (Build a minimal server registering just this handler, or reusebuildLocalServerif 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
onRequesthook, before the localhost-trust exemption, reject requests whoseHostheader is neither a loopback host nor an allowlisted name (defeats DNS-rebinding that would otherwise satisfy the IP-based localhost trust). Add aHOST_ALLOWLIST(127.0.0.1,localhost,::1, plusWAGGLE_ALLOWED_HOSTScomma-env for cloud). Skip the check entirely whenWAGGLE_HOSTis 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. Useserver.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) usingtypescript-eslintrecommended, with stylistic/noisy rules relaxed so current code passes; bug-catching rules (no-undef,no-emptywith allowEmptyCatch,no-unused-varswarn) on. Runnpm 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 loadsapps/webdist). Repointapp/tsconfig.jsonincludeat the TS that actually exists inapp/(scripts,tailwind.config.ts) OR, if those have their own configs, narrow the gate. Runnpx 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);/healthtoken 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
unverifiedin the audit (plausible, evidence cited). The failing-test-first step for each independently re-confirms the issue before the fix lands.