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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
114
tests/e7-3-nosecret.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user