[object Object]

← back to Robet Site

overnight loop item (f): security headers (CSP/helmet-style) + custom 404 + externalize admin script for strict CSP

44d16c072ca9640b878417f3801d93b223f12e2e · 2026-06-29 19:13:08 -0700 · Steve

Files touched

Diff

commit 44d16c072ca9640b878417f3801d93b223f12e2e
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 19:13:08 2026 -0700

    overnight loop item (f): security headers (CSP/helmet-style) + custom 404 + externalize admin script for strict CSP
---
 public/404.html   | 26 ++++++++++++++++++++++++++
 public/admin.html | 38 +-------------------------------------
 public/admin.js   | 36 ++++++++++++++++++++++++++++++++++++
 server.js         | 29 +++++++++++++++++++++++++++++
 4 files changed, 92 insertions(+), 37 deletions(-)

diff --git a/public/404.html b/public/404.html
new file mode 100644
index 0000000..5d2622a
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,26 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>404 · ROBERT</title>
+  <link rel="stylesheet" href="/styles.css" />
+</head>
+<body>
+  <header class="topbar">
+    <a class="logo" href="/">ROBERT</a>
+    <span class="mono" style="text-transform:uppercase">404</span>
+  </header>
+  <section style="min-height:70vh; display:flex; flex-direction:column; justify-content:center;">
+    <span class="kicker">/ dead end</span>
+    <h1 class="sec-h" style="font-size:clamp(56px,18vw,180px); line-height:.85;">404</h1>
+    <p class="mono" style="font-weight:700; font-size:20px; margin-bottom:24px;">
+      This page doesn't exist. The wall is blank here.
+    </p>
+    <div>
+      <a class="cta" href="/">← Back to start</a>
+      <a class="cta" href="/#booking" style="margin-left:12px;">Book a meeting →</a>
+    </div>
+  </section>
+</body>
+</html>
diff --git a/public/admin.html b/public/admin.html
index e207abe..833a8b8 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -32,42 +32,6 @@
     <div class="keytable" id="table"></div>
   </div>
 
-  <script>
-    const $ = (s) => document.querySelector(s);
-    const api = (p, opt) => fetch(p, opt).then((r) => r.json());
-
-    async function load() {
-      const rows = await api('/api/keys');
-      const t = $('#table');
-      if (!rows.length) { t.innerHTML = '<p class="mono" style="font-weight:600">No keys stored yet.</p>'; return; }
-      t.innerHTML =
-        '<div class="keyrow head"><div>Name</div><div>Value</div><div>Action</div></div>' +
-        rows.map((r) =>
-          `<div class="keyrow">
-             <div class="mono">${r.name}</div>
-             <div class="mono">${r.masked}</div>
-             <div><button class="btn warn" data-del="${r.name}">Delete</button></div>
-           </div>`).join('');
-      t.querySelectorAll('[data-del]').forEach((b) =>
-        b.addEventListener('click', async () => {
-          await api('/api/keys/' + encodeURIComponent(b.dataset.del), { method: 'DELETE' });
-          load();
-        }));
-    }
-
-    $('#saveBtn').addEventListener('click', async () => {
-      const name = $('#kName').value.trim(), value = $('#kVal').value;
-      const msg = $('#msg');
-      if (!name || !value) { msg.textContent = '✗ name and value required'; return; }
-      const j = await api('/api/keys', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ name, value }),
-      });
-      msg.textContent = j.ok ? `✓ Saved ${j.name} (${j.masked})` : '✗ ' + (j.error || 'Failed');
-      if (j.ok) { $('#kName').value = ''; $('#kVal').value = ''; load(); }
-    });
-
-    load();
-  </script>
+  <script src="/admin.js"></script>
 </body>
 </html>
diff --git a/public/admin.js b/public/admin.js
new file mode 100644
index 0000000..7bd705b
--- /dev/null
+++ b/public/admin.js
@@ -0,0 +1,36 @@
+// ROBERT admin — API-key entry (externalized so CSP can stay strict, no inline script).
+const $ = (s) => document.querySelector(s);
+const api = (p, opt) => fetch(p, opt).then((r) => r.json());
+
+async function load() {
+  const rows = await api('/api/keys');
+  const t = $('#table');
+  if (!rows.length) { t.innerHTML = '<p class="mono" style="font-weight:600">No keys stored yet.</p>'; return; }
+  t.innerHTML =
+    '<div class="keyrow head"><div>Name</div><div>Value</div><div>Action</div></div>' +
+    rows.map((r) =>
+      `<div class="keyrow">
+         <div class="mono">${r.name}</div>
+         <div class="mono">${r.masked}</div>
+         <div><button class="btn warn" data-del="${r.name}">Delete</button></div>
+       </div>`).join('');
+  t.querySelectorAll('[data-del]').forEach((b) =>
+    b.addEventListener('click', async () => {
+      await api('/api/keys/' + encodeURIComponent(b.dataset.del), { method: 'DELETE' });
+      load();
+    }));
+}
+
+$('#saveBtn').addEventListener('click', async () => {
+  const name = $('#kName').value.trim(), value = $('#kVal').value;
+  const msg = $('#msg');
+  if (!name || !value) { msg.textContent = '✗ name and value required'; return; }
+  const j = await api('/api/keys', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ name, value }),
+  });
+  msg.textContent = j.ok ? `✓ Saved ${j.name} (${j.masked})` : '✗ ' + (j.error || 'Failed');
+  if (j.ok) { $('#kName').value = ''; $('#kVal').value = ''; load(); }
+});
+
+load();
diff --git a/server.js b/server.js
index c069890..7bf21d0 100644
--- a/server.js
+++ b/server.js
@@ -32,6 +32,29 @@ const app = express();
 app.use(express.json({ limit: '64kb' }));
 app.use(express.urlencoded({ extended: false }));
 
+// ── security headers (defense-in-depth; nginx also sets some on prod) ──
+const CSP = [
+  "default-src 'self'",
+  "script-src 'self' https://venturacorridor.com",
+  "style-src 'self' 'unsafe-inline'",            // inline style="" attrs on the hero/admin
+  "img-src 'self' data: https://venturacorridor.com",
+  "font-src 'self'",
+  "connect-src 'self' https://venturacorridor.com",
+  "frame-src https://venturacorridor.com",        // Smart Scheduling booking iframe
+  "object-src 'none'",
+  "base-uri 'self'",
+  "form-action 'self'",
+  "frame-ancestors 'self'",
+].join('; ');
+app.use((req, res, next) => {
+  res.set('Content-Security-Policy', CSP);
+  res.set('X-Content-Type-Options', 'nosniff');
+  res.set('X-Frame-Options', 'SAMEORIGIN');
+  res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+  res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+  next();
+});
+
 // ── helpers ──
 const readJson = (f, fallback) => {
   try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fallback; }
@@ -113,4 +136,10 @@ app.get('/admin', adminAuth, (_req, res) => res.sendFile(path.join(__dirname, 'p
 
 app.use(express.static(path.join(__dirname, 'public')));
 
+// ── custom 404 (after static) ──
+app.use((req, res) => {
+  if (req.accepts('html')) return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
+  res.status(404).json({ ok: false, error: 'Not found' });
+});
+
 app.listen(PORT, () => console.log(`robet-site → http://127.0.0.1:${PORT}`));

← f55316d loop ledger: item (e) done + deployed  ·  back to Robet Site  ·  loop ledger: item (f) done + deployed 906d6de →