diff --git a/artifacts/api-server/src/routes/admin-relay-panel.ts b/artifacts/api-server/src/routes/admin-relay-panel.ts index 09d0089..55d626e 100644 --- a/artifacts/api-server/src/routes/admin-relay-panel.ts +++ b/artifacts/api-server/src/routes/admin-relay-panel.ts @@ -14,11 +14,21 @@ * Stats bar: pending, approved today, total accounts, live relay connections. */ -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; const router = Router(); -router.get("/admin/relay", (_req, res) => { +// Gate: in production the panel returns 403 when ADMIN_TOKEN is not configured. +// In development (no token set), the auth gate still renders and the API uses +// localhost-only fallback, so ops can still access the panel locally. +const ADMIN_TOKEN = process.env["ADMIN_TOKEN"] ?? process.env["ADMIN_SECRET"] ?? ""; +const IS_PROD = process.env["NODE_ENV"] === "production"; + +router.get("/admin/relay", (_req: Request, res: Response) => { + if (IS_PROD && !ADMIN_TOKEN) { + res.status(403).send("Relay admin panel disabled: ADMIN_TOKEN is not configured."); + return; + } res.setHeader("Content-Type", "text/html"); res.send(ADMIN_PANEL_HTML); }); @@ -563,19 +573,35 @@ async function loadQueue() { } } +// esc() — HTML-escape a value before injection into innerHTML. +// Prevents XSS from any user-controlled field (contentPreview, notes, pubkey, etc.). +function esc(v) { + return String(v == null ? '' : v) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function renderQueueRow(ev) { - var id8 = ev.eventId ? ev.eventId.slice(0,8) + '…' : '—'; - var pk12 = ev.pubkey ? ev.pubkey.slice(0,12) + '…' : '—'; - var preview = ev.contentPreview ? ev.contentPreview : '(empty)'; - var ts = ev.createdAt ? new Date(ev.createdAt).toLocaleString() : '—'; - var pill = '' + (ev.status||'—') + ''; - var approve = ''; - var reject = ''; + // eventId and pubkey are server-validated hex strings, but still escape defensively. + var id8 = ev.eventId ? esc(ev.eventId.slice(0,8)) + '…' : '—'; + var pk12 = ev.pubkey ? esc(ev.pubkey.slice(0,12)) + '…' : '—'; + var preview = esc(ev.contentPreview || '(empty)'); + var ts = ev.createdAt ? esc(new Date(ev.createdAt).toLocaleString()) : '—'; + var status = esc(ev.status || 'none'); + // onclick uses hex event IDs validated server-side — still sanitise to be safe. + var safeId = (ev.eventId || '').replace(/[^0-9a-f]/gi, ''); + var pill = '' + status + ''; + var approve = ''; + var reject = ''; + var kind = ev.kind != null ? esc(String(ev.kind)) : '—'; return '