feat(E7.3): no-secret-egress — gate data routes + masked presence + regression guard

The dashboard never leaks secrets or sensitive data to the browser, even on a
public bind. /api/auth/status is the only unauthenticated data endpoint in
public mode.

- src/server.ts ROUTES: gate the data-leaking GET routes (/api/index, /api/status,
  /api/file, /api/entries, /api/autosync, /api/autosync/conflicts,
  /api/autosync/last-push) → requireAuth:true. Only / (login page) + /api/auth/*
  stay open in public mode. /api/status leaks dir paths, /api/file leaks file
  contents, /api/index the full index — all gated. (On 127.0.0.1, authenticate
  doesn't enforce, so localhost stays open — the gate only bites a 0.0.0.0 bind.)
- src/dashboard.html: checkAuth reordered — fetch /api/auth/status (open) first;
  only fetch /api/status (gated) if not (authRequired && no token), avoiding a
  pre-login 401-throws. Masked presence chip ("Relay ✓/✗ · Foundry ✓/✗") driven
  by /api/auth/status booleans (relayConfigured/foundryConfigured), never env
  values.
- tests/e7-1-dispatch.test.ts: flag-on read tests updated — /api/auth/status open
  (200 without token); /api/status + /api/autosync gated (401 without token in
  public mode).
- tests/e7-3-nosecret.test.ts: regression guard — server with distinctive secrets
  (relay API key + dashboard token), hits endpoints incl. error paths
  (/api/refresh relay-failure 500, login 401, gated 401, the login page HTML) and
  asserts JSON.stringify of NO response contains either secret substring. Errors
  name env vars, never values; the relay key is a header, never serialized.

tsc clean; 202 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 01:17:45 +00:00
parent fe925d4aec
commit 32ed68eb4f
4 changed files with 149 additions and 14 deletions

View File

@@ -91,6 +91,7 @@
<h1>Foundry ⇄ Obsidian merge</h1>
<div class="counts" id="counts">loading…</div>
<span class="mode-tag" id="modeTag">dev</span>
<span class="mode-tag" id="presenceChip" style="display:none" title="Relay / Foundry host configuration presence (masked — no secrets shown)."></span>
<button onclick="refreshIndex()" title="Re-scan the vault + cc from disk so edits made in Obsidian show as changed. Also happens automatically when you switch back to this tab.">Re-scan</button>
<div class="spacer"></div>
<label><input type="checkbox" id="dryRun" checked /> dry-run</label>
@@ -137,7 +138,7 @@
<section class="detail" id="detail">Select a row to inspect.</section>
</main>
<script>
let INDEX = null, STATUS = null, SEL = null, REC_FILTER = null, AUTO = null, autoPoll = null, migrationDismissed = false, AUTH_REQUIRED = false;
let INDEX = null, STATUS = null, SEL = null, REC_FILTER = null, AUTO = null, autoPoll = null, migrationDismissed = false, AUTH_REQUIRED = false, AUTH_STATUS = null;
const dryEl = () => document.getElementById('dryRun');
// E7.2: shared request wrapper. Attaches the stored auth token (set on login) as
@@ -166,11 +167,27 @@ async function doLogin() {
else { document.getElementById('loginErr').textContent = (r && r.error) ? r.error : 'invalid credentials'; }
}
async function doLogout() { localStorage.removeItem('ofs_token'); await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); showLogin(); }
// E7.3: masked presence chip — "Relay: ✓ / ✗ · Foundry: ✓ / ✗" driven by
// /api/auth/status booleans (never env values / secrets).
function renderPresenceChip() {
const chip = document.getElementById('presenceChip');
if (!chip || !AUTH_STATUS) { if (chip) chip.style.display = 'none'; return; }
const r = AUTH_STATUS.relayConfigured ? 'Relay ✓' : 'Relay ✗';
const f = AUTH_STATUS.foundryConfigured ? 'Foundry ✓' : 'Foundry ✗';
chip.textContent = `${r} · ${f}`;
chip.style.color = AUTH_STATUS.relayConfigured ? 'var(--ok)' : 'var(--warn)';
chip.style.display = '';
}
async function checkAuth() {
STATUS = await apiFetch('/api/status').then(r => r.json()).catch(() => null);
// E7.3: /api/auth/status is the only always-open data endpoint. Fetch it first;
// if auth is required and no token is stored, show the login card and DON'T
// fetch /api/status (which is gated in public mode and would 401).
const a = await fetch('/api/auth/status').then(r => r.json()).catch(() => null);
if (STATUS && a) STATUS.bound = a.bound; // merge the bind address for showLogin
AUTH_STATUS = a; // E7.3: masked presence (relayConfigured/foundryConfigured) for the chip
if (a && a.authRequired && !authToken()) { AUTH_REQUIRED = true; showLogin(); return false; }
STATUS = await apiFetch('/api/status').then(r => r.json()).catch(() => null);
if (STATUS && a) STATUS.bound = a.bound; // merge the bind address for showLogin
renderPresenceChip();
return true;
}

View File

@@ -1417,7 +1417,7 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
},
},
"GET /api/index": {
method: "GET", requireAuth: false, requireCSRF: false,
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks the full index — gate in public mode
// Rebuild on every request (apply-mode sources change after an action; dev
// mirror too). Hash-only indexing, so this stays cheap.
handler: async (_s, _req, res) => {
@@ -1427,11 +1427,11 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
},
},
"GET /api/status": {
method: "GET", requireAuth: false, requireCSRF: false,
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks dir paths — gate in public mode
handler: async (_s, _req, res) => send(res, 200, { mode: state.cfg.mode, refinedDir: state.cfg.refinedDir, ccDir: state.cfg.ccDir, outDir: state.cfg.outDir }),
},
"GET /api/file": {
method: "GET", requireAuth: false, requireCSRF: false,
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks file contents — gate in public mode
handler: async (_s, _req, res, url) => {
const name = url.searchParams.get("name");
if (!name) return send(res, 400, { error: "missing name" });
@@ -1439,11 +1439,11 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
},
},
"GET /api/entries": {
method: "GET", requireAuth: false, requireCSRF: false,
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks entry data — gate in public mode
handler: async (_s, _req, res) => handleEntries(state, res),
},
"GET /api/autosync": {
method: "GET", requireAuth: false, requireCSRF: false,
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks sync status/conflicts — gate in public mode
handler: async (_s, _req, res) => send(res, 200, state.autosync.status()),
},
// E7.2: auth status (the login prompt polls this; always open — booleans
@@ -1482,11 +1482,11 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
},
},
"GET /api/autosync/conflicts": {
method: "GET", requireAuth: false, requireCSRF: false,
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks conflict rows — gate in public mode
handler: async (_s, _req, res) => send(res, 200, { conflicts: state.autosync.conflicts }),
},
"GET /api/autosync/last-push": {
method: "GET", requireAuth: false, requireCSRF: false,
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks last-push records — gate in public mode
handler: async (_s, _req, res, url) => {
const uuid = url.searchParams.get("uuid") ?? "";
const rec = state.autosync.lastPushes.get(uuid);

View File

@@ -91,13 +91,17 @@ describe("E7.1c dispatch (flag on + token set)", () => {
// (safe for the test), so override __authState.bound to simulate a public bind.
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; __authState.bound = "0.0.0.0"; });
it("GET /api/status (read) passes WITHOUT a token", async () => {
const r = await req("/api/status");
it("GET /api/auth/status (always-open) passes WITHOUT a token", async () => {
const r = await req("/api/auth/status");
expect(r.code).toBe(200);
});
it("GET /api/autosync (read) passes without a token", async () => {
it("GET /api/status (gated — E7.3) → 401 WITHOUT a token in public mode", async () => {
const r = await req("/api/status");
expect(r.code).toBe(401); // E7.3: /api/status leaks dir paths → gated
});
it("GET /api/autosync (gated — E7.3) → 401 WITHOUT a token in public mode", async () => {
const r = await req("/api/autosync");
expect(r.code).toBe(200);
expect(r.code).toBe(401);
});
it("POST /api/autosync (mutation) WITHOUT a token → 401", async () => {
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: false }) });

114
tests/e7-3-nosecret.test.ts Normal file
View File

@@ -0,0 +1,114 @@
// E7.3b — no-secret-egress regression guard.
//
// Starts a server with distinctive secrets (a relay API key + a dashboard token)
// and asserts NO response body (incl. error paths) contains either secret
// substring. /api/auth/status returns booleans only; error strings name env vars,
// never values; the relay client sends the key as a header (never serialized).
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "node:http";
import { startServer, __authState } from "../src/server.js";
const RELAY_SECRET = "RELAY_SECRET_abc123";
const DASH_SECRET = "DASH_SECRET_xyz789";
let dir: string;
let server: Server;
let baseURL: string;
const savedEnabled = __authState.enabled;
const savedToken = __authState.token;
const savedBound = __authState.bound;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "e7-3-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "cc"), { recursive: true });
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open(); await jdb.close();
// Distinctive secrets: a relay API key + a dashboard token. The relay URL points
// at a port nothing listens on so /api/refresh errors out (a relay-failure path).
__authState.enabled = true; __authState.token = DASH_SECRET;
const { server: srv } = await startServer({
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir: join(dir, "out"), mode: "dev", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://127.0.0.1:1", apiKey: RELAY_SECRET, clientId: "c" },
});
// startServer sets __authState.bound = cfg.host (127.0.0.1); override AFTER to
// simulate a public bind so enforcement runs (the actual socket stays 127.0.0.1).
__authState.bound = "0.0.0.0";
server = srv;
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
afterAll(async () => {
__authState.enabled = savedEnabled; __authState.token = savedToken; __authState.bound = savedBound;
await new Promise<void>((r) => server.close(() => r()));
await rm(dir, { recursive: true, force: true });
});
function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
return { authorization: `Bearer ${DASH_SECRET}`, ...extra };
}
function assertNoSecret(label: string, body: unknown) {
const text = typeof body === "string" ? body : JSON.stringify(body);
expect(text, `${label} leaked RELAY_SECRET`).not.toContain(RELAY_SECRET);
expect(text, `${label} leaked DASH_SECRET`).not.toContain(DASH_SECRET);
}
async function req(path: string, init: RequestInit = {}): Promise<{ code: number; body: unknown }> {
const r = await fetch(`${baseURL}${path}`, init);
const t = await r.text();
let body: unknown = t;
try { body = t ? JSON.parse(t) : null; } catch { /* non-JSON (e.g. the dashboard HTML) */ }
return { code: r.status, body };
}
describe("E7.3 no-secret-egress regression guard", () => {
it("/api/auth/status (open, no token) → booleans only, no secret", async () => {
const r = await req("/api/auth/status");
expect(r.code).toBe(200);
assertNoSecret("auth/status", r.body);
expect(JSON.stringify(r.body)).toMatch(/relayConfigured/); // booleans, no values
});
it("/ (login page, no token) → HTML, no secret", async () => {
const r = await req("/");
expect(r.code).toBe(200);
assertNoSecret("login page", r.body);
});
it("/api/status (gated) with a valid token → dir paths, no secret", async () => {
const r = await req("/api/status", { headers: authHeaders() });
expect(r.code).toBe(200);
assertNoSecret("status", r.body);
});
it("/api/status (gated) WITHOUT a token → 401, no secret", async () => {
const r = await req("/api/status");
expect(r.code).toBe(401);
assertNoSecret("status-401", r.body);
});
it("/api/refresh (relay-failure path) → 500 with a relay error, no secret", async () => {
const r = await req("/api/refresh", { method: "POST", headers: authHeaders({ "content-type": "application/json" }), body: "{}" });
expect(r.code).toBe(500); // the relay fetch fails (nothing on :1)
assertNoSecret("refresh-error", r.body); // the error must NOT echo the API key
});
it("/api/auth/login with a WRONG token → 401 invalid credentials, no secret", async () => {
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "wrong" }) });
expect(r.code).toBe(401);
assertNoSecret("login-401", r.body);
});
it("favicon → 204 (no body)", async () => {
const r = await req("/favicon.ico");
expect(r.code).toBe(204);
});
});