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 '' + '' + id8 + '' + '' + pk12 + '' + - '' + (ev.kind != null ? ev.kind : '—') + '' + - '' + preview + '' + + '' + kind + '' + + '' + preview + '' + '' + pill + '' + '' + ts + '' + '' + approve + reject + '' + @@ -621,21 +647,26 @@ async function loadAccounts() { } function renderAccountRow(ac) { - var pk = ac.pubkey || ''; - var pk12 = pk.slice(0,12) + (pk.length > 12 ? '…' : ''); - var ts = ac.grantedAt ? new Date(ac.grantedAt).toLocaleDateString() : '—'; - var level = ac.accessLevel || 'none'; - var tier = ac.trustTier || '—'; - var notes = (ac.notes || '').slice(0,30) + ((ac.notes||'').length > 30 ? '…' : ''); - var isRevoked = ac.revokedAt != null; + var pk = ac.pubkey || ''; + var pk12 = esc(pk.slice(0,12)) + (pk.length > 12 ? '…' : ''); + var ts = ac.grantedAt ? esc(new Date(ac.grantedAt).toLocaleDateString()) : '—'; + // level and tier come from enum/trusted fields, but escape anyway. + var level = esc(ac.accessLevel || 'none'); + var tier = esc(ac.trustTier || '—'); + var grantedBy = esc(ac.grantedBy || '—'); + var rawNotes = (ac.notes || '').slice(0,30) + ((ac.notes||'').length > 30 ? '…' : ''); + var notes = esc(rawNotes); + var isRevoked = ac.revokedAt != null; + // pubkey is server-validated hex; strip non-hex chars for onclick safety. + var safePk = pk.replace(/[^0-9a-f]/gi, ''); var action = isRevoked ? 'revoked' - : ''; + : ''; return '' + - '' + pk12 + '' + + '' + pk12 + '' + '' + level + '' + '' + tier + '' + - '' + (ac.grantedBy||'—') + '' + + '' + grantedBy + '' + '' + notes + '' + '' + ts + '' + '' + action + '' +