[object Object]

← back to Bubbesblock

Security hardening (code-reviewer pass): server-side XSS escaping on all user input, fix inbox IDOR, stop search leaking addresses, opp-response uniqueness, safe notification links, prod SESSION_SECRET required + secure cookie, json limit, unhandledRejection guard

977e8e65a42408de7fc9aa0daeb881765128f028 · 2026-06-19 16:26:55 -0700 · Steve

Files touched

Diff

commit 977e8e65a42408de7fc9aa0daeb881765128f028
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 19 16:26:55 2026 -0700

    Security hardening (code-reviewer pass): server-side XSS escaping on all user input, fix inbox IDOR, stop search leaking addresses, opp-response uniqueness, safe notification links, prod SESSION_SECRET required + secure cookie, json limit, unhandledRejection guard
---
 db/schema.sql             |  7 +++++++
 public/app.js             |  5 ++++-
 public/notifications.html |  2 +-
 public/search.html        |  8 +++++---
 server.js                 | 35 +++++++++++++++++++++--------------
 5 files changed, 38 insertions(+), 19 deletions(-)

diff --git a/db/schema.sql b/db/schema.sql
index 7952e26..ec8c022 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -155,3 +155,10 @@ CREATE TABLE IF NOT EXISTS bookmarks (
 CREATE INDEX IF NOT EXISTS idx_react_target ON reactions(target_type, target_id);
 CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, read);
 CREATE INDEX IF NOT EXISTS idx_oppresp_opp ON opp_responses(opportunity_id);
+
+-- one response per (opportunity,user) — prevents counter inflation via direct API
+DO $$ BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='uq_opp_user') THEN
+    ALTER TABLE opp_responses ADD CONSTRAINT uq_opp_user UNIQUE (opportunity_id, user_id);
+  END IF;
+END $$;
diff --git a/public/app.js b/public/app.js
index a38e805..27db219 100644
--- a/public/app.js
+++ b/public/app.js
@@ -6,6 +6,9 @@
     post: (u, body) => fetch(u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw Object.assign(new Error(j.error || r.status), { status: r.status, data: j }); return j; })
   };
   BB.api = api;
+  const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+  const safeUrl = u => (typeof u === 'string' && /^(\/|https:\/\/)/.test(u)) ? u : '#';
+  BB.esc = esc;
 
   /* ---------- toast ---------- */
   function toast(msg) {
@@ -196,7 +199,7 @@
   BB.openNotifications = async function (btn) {
     const you = await BB.ensureAuth(); if (!you) return;
     const r = await api.get('/api/notifications');
-    const items = r.items.length ? r.items.map(n => `<a href="${n.link || '#'}" class="bb-noti ${n.read ? '' : 'unread'}"><b>${n.actor}</b> ${n.text}</a>`).join('') : '<div class="bb-empty">No notifications yet</div>';
+    const items = r.items.length ? r.items.map(n => `<a href="${safeUrl(n.link)}" class="bb-noti ${n.read ? '' : 'unread'}"><b>${esc(n.actor)}</b> ${esc(n.text)}</a>`).join('') : '<div class="bb-empty">No notifications yet</div>';
     dropdown(btn, `<div class="bb-dd-hd">Notifications<button id="bb-mark">Mark all read</button></div>${items}`);
     const mk = document.getElementById('bb-mark'); if (mk) mk.onclick = async () => { await api.post('/api/notifications/read', {}); document.querySelectorAll('.bb-noti').forEach(x => x.classList.remove('unread')); BB.updateBadges(); };
   };
diff --git a/public/notifications.html b/public/notifications.html
index 04a6b5b..ad7ef70 100644
--- a/public/notifications.html
+++ b/public/notifications.html
@@ -15,7 +15,7 @@ Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fet
   let body;
   if(!m.you){ body='<div class="prose"><h1>🔔 Notifications</h1><p>Sign in to see your notifications.</p><button class="bb-btn" style="max-width:220px" onclick="BB.ensureAuth().then(()=>location.reload())">Sign in</button></div>'; }
   else if(!d.items.length){ body='<div class="prose"><h1>🔔 Notifications</h1><p>Nothing new yet — check back after the next block party.</p></div>'; }
-  else { body='<div class="listhd">🔔 Notifications</div>'+d.items.map(n=>`<a class="tile ${n.read?'':'unread'}" href="${n.link||'#'}" style="${n.read?'':'background:var(--green-tint)'}"><span class="lg" style="background:var(--plum)">${({react:'❤️',comment:'💬',opportunity:'💼',event:'📅'})[n.type]||'🔔'}</span><div class="main"><b>${n.actor}</b> <span class="meta2" style="display:inline">${n.text}</span></div></a>`).join(''); }
+  else { const su=u=>(typeof u==='string'&&/^(\/|https:\/\/)/.test(u))?u:'#'; body='<div class="listhd">🔔 Notifications</div>'+d.items.map(n=>`<a class="tile ${n.read?'':'unread'}" href="${su(n.link)}" style="${n.read?'':'background:var(--green-tint)'}"><span class="lg" style="background:var(--plum)">${({react:'❤️',comment:'💬',opportunity:'💼',event:'📅'})[n.type]||'🔔'}</span><div class="main"><b>${n.actor}</b> <span class="meta2" style="display:inline">${n.text}</span></div></a>`).join(''); }
   document.getElementById('main').innerHTML=body;
   BB.init();
   if(m.you && d.items.length) BB.api.post('/api/notifications/read',{}).then(()=>BB.updateBadges());
diff --git a/public/search.html b/public/search.html
index 0360ce9..6deccb4 100644
--- a/public/search.html
+++ b/public/search.html
@@ -8,10 +8,12 @@
 <script>window.__bbAutoInit = false;</script>
 <script src="/chrome.js"></script><script src="/app.js"></script>
 <script>
-const term = new URLSearchParams(location.search).get('q') || '';
+const rawTerm = new URLSearchParams(location.search).get('q') || '';
+const escH = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const term = escH(rawTerm);
 Promise.all([
   fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()),
-  fetch('/api/search?q='+encodeURIComponent(term),{credentials:'same-origin'}).then(r=>r.json())
+  fetch('/api/search?q='+encodeURIComponent(rawTerm),{credentials:'same-origin'}).then(r=>r.json())
 ]).then(([m,d])=>{
   document.getElementById('hdr').innerHTML=topbar(m.me);
   document.getElementById('left').innerHTML=leftRail(m.me);
@@ -24,6 +26,6 @@ Promise.all([
   document.getElementById('main').innerHTML=`<div class="listhd">Search${term?`: “${term}”`:''}</div>`+
     (any? sec('Posts',posts)+sec('Opportunities',opps)+sec('Neighbors',nb) : '<div class="prose"><h1>No matches</h1><p>Try a different term.</p></div>');
   BB.init();
-  const si=document.querySelector('.search input'); if(si) si.value=term;
+  const si=document.querySelector('.search input'); if(si) si.value=rawTerm;
 });
 </script></body></html>
diff --git a/server.js b/server.js
index c6dd115..99b4e02 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,10 @@ require('dotenv').config({ path: path.join(__dirname, '.env') });
 const app = express();
 const PORT = process.env.PORT || 3201;
 const DATA = path.join(__dirname, 'data', 'posts.json');
-const SECRET = process.env.SESSION_SECRET || 'bubbesblock-dev-secret';
+// require a real secret in production (DB present); allow a dev fallback only in JSON-only mode
+const SECRET = process.env.SESSION_SECRET || (process.env.DATABASE_URL ? null : 'bubbesblock-dev-secret');
+if (process.env.DATABASE_URL && !SECRET) { console.error('SESSION_SECRET is required in production'); process.exit(1); }
+process.on('unhandledRejection', e => console.error('unhandledRejection:', e && e.message));
 
 let pool = null;
 if (process.env.DATABASE_URL) {
@@ -20,7 +23,7 @@ if (process.env.DATABASE_URL) {
 let homeHistory = null;
 try { homeHistory = require('./lib/home-history.js'); } catch { /* not built yet */ }
 
-app.use(express.json());
+app.use(express.json({ limit: '12kb' }));
 app.use(cookieParser(SECRET));
 app.use(express.static(path.join(__dirname, 'public')));
 app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'bubbesblock', db: !!pool }));
@@ -28,6 +31,8 @@ app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'bubbesblock',
 // ---------- helpers ----------
 const q = (text, params) => pool.query(text, params);
 function jsonAll() { return JSON.parse(fs.readFileSync(DATA, 'utf8')); }
+// escape user-supplied text at write-time (client renders via innerHTML; seed content stays trusted)
+const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
 const uid = name => 'u-' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
 
 async function currentUser(req) {
@@ -38,7 +43,7 @@ async function currentUser(req) {
   return r.rows[0] || null;
 }
 function setSession(res, userId) {
-  res.cookie('bb_uid', userId, { signed: true, httpOnly: true, sameSite: 'lax', maxAge: 1000 * 60 * 60 * 24 * 90 });
+  res.cookie('bb_uid', userId, { signed: true, httpOnly: true, sameSite: 'lax', secure: !!process.env.DATABASE_URL, maxAge: 1000 * 60 * 60 * 24 * 90 });
 }
 function need(res, code, msg) { res.status(code).json({ error: msg }); return null; }
 
@@ -149,8 +154,8 @@ app.post('/api/session/login', async (req, res) => {
   const name = (req.body.name || '').trim();
   if (!name) return need(res, 400, 'name required');
   const id = uid(name);
-  const initials = name.split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase();
-  await q(`INSERT INTO users(id,name,initials,color) VALUES($1,$2,$3,'a1') ON CONFLICT(id) DO NOTHING`, [id, name, initials]);
+  const initials = esc(name.split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase());
+  await q(`INSERT INTO users(id,name,initials,color) VALUES($1,$2,$3,'a1') ON CONFLICT(id) DO NOTHING`, [id, esc(name), initials]);
   setSession(res, id);
   res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [id])).rows[0] });
 });
@@ -160,7 +165,7 @@ app.post('/api/claim', async (req, res) => {
   if (!pool) return need(res, 503, 'db required');
   const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
   const address = (req.body.address || '').trim(); if (!address) return need(res, 400, 'address required');
-  await q('UPDATE users SET address=$1, verified=true WHERE id=$2', [address, me.id]);
+  await q('UPDATE users SET address=$1, verified=true WHERE id=$2', [esc(address), me.id]);
   res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [me.id])).rows[0] });
 });
 
@@ -170,15 +175,15 @@ app.post('/api/posts', async (req, res) => {
   const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
   if (!me.verified) return need(res, 403, 'claim your address to post');
   const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
-  const category = req.body.category || '📣 Block Post';
-  const catColor = req.body.catColor || 'plum';
+  const category = esc(req.body.category || '📣 Block Post');
+  const catColor = ['green', 'plum', 'amber'].includes(req.body.catColor) ? req.body.catColor : 'plum';
   const id = 'p-' + Date.now().toString(36);
   try {
     const pos = (await q('SELECT coalesce(min(position),0)-1 AS n FROM posts')).rows[0].n; // new posts sort to top
     await q(`INSERT INTO posts(id,position,author,initials,color,address,time_label,edited,category,cat_color,body,photo,reactions,reacted_by,shares,more_comments,source)
              VALUES($1,$2,$3,$4,$5,$6,'just now',false,$7,$8,$9,$10,'[]'::jsonb,'',0,0,'user')`,
       [id, pos, me.name, me.initials, me.color, me.address, category, catColor,
-       JSON.stringify(body.split(/\n\n+/).filter(Boolean)), req.body.photo ? JSON.stringify(req.body.photo) : null]);
+       JSON.stringify(body.split(/\n\n+/).filter(Boolean).map(esc)), req.body.photo ? JSON.stringify(req.body.photo) : null]);
     res.json({ ok: true, id });
   } catch (e) { console.error('create post fail:', e.message); need(res, 500, 'could not post'); }
 });
@@ -191,7 +196,7 @@ app.post('/api/posts/:id/comments', async (req, res) => {
   const pos = (await q('SELECT coalesce(max(position),-1)+1 AS n FROM comments WHERE post_id=$1', [req.params.id])).rows[0].n;
   const r = await q(`INSERT INTO comments(post_id,position,author,initials,color,nb,time_label,thanks,is_reply,body)
                      VALUES($1,$2,$3,$4,$5,$6,'just now',0,$7,$8) RETURNING *`,
-    [req.params.id, pos, me.name, me.initials, me.color, me.address || 'neighbor', !!req.body.reply, body]);
+    [req.params.id, pos, me.name, me.initials, me.color, me.address || 'neighbor', !!req.body.reply, esc(body)]);
   res.json({ ok: true, comment: shapeComment(r.rows[0]) });
 });
 
@@ -237,8 +242,8 @@ app.post('/api/opportunities/:id/respond', async (req, res) => {
   const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
   if (!me.verified) return need(res, 403, 'claim your address to respond');
   const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
-  await q('INSERT INTO opp_responses(opportunity_id,user_id,body) VALUES($1,$2,$3)', [req.params.id, me.id, body]);
-  await q('UPDATE opportunities SET responses=responses+1 WHERE id=$1', [req.params.id]);
+  const ins = await q('INSERT INTO opp_responses(opportunity_id,user_id,body) VALUES($1,$2,$3) ON CONFLICT (opportunity_id,user_id) DO NOTHING RETURNING id', [req.params.id, me.id, esc(body)]);
+  if (ins.rows.length) await q('UPDATE opportunities SET responses=responses+1 WHERE id=$1', [req.params.id]);
   const r = await q('SELECT responses FROM opportunities WHERE id=$1', [req.params.id]);
   res.json({ ok: true, responses: r.rows.length ? r.rows[0].responses : 0 });
 });
@@ -256,7 +261,7 @@ app.get('/api/search', async (req, res) => {
   const like = '%' + term + '%';
   const posts = (await q(`SELECT id,author,category,body FROM posts WHERE body::text ILIKE $1 OR author ILIKE $1 OR category ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
   const opps = (await q(`SELECT id,neighbor,category,body FROM opportunities WHERE body ILIKE $1 OR category ILIKE $1 OR neighbor ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
-  const neighbors = (await q(`SELECT id,name,initials,color,address FROM users WHERE name ILIKE $1 LIMIT 8`, [like])).rows;
+  const neighbors = (await q(`SELECT id,name,initials,color FROM users WHERE name ILIKE $1 LIMIT 8`, [like])).rows;
   res.json({ posts, opportunities: opps, neighbors });
 });
 
@@ -291,7 +296,9 @@ app.post('/api/inbox/:id/messages', async (req, res) => {
   if (!pool) return need(res, 503, 'db required');
   const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
   const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
-  await q('INSERT INTO messages(conv_id,from_me,body) VALUES($1,true,$2)', [req.params.id, body]);
+  const own = await q('SELECT 1 FROM conversations WHERE id=$1 AND user_id=$2', [req.params.id, me.id]);
+  if (!own.rows.length) return need(res, 403, 'not your conversation');
+  await q('INSERT INTO messages(conv_id,from_me,body) VALUES($1,true,$2)', [req.params.id, esc(body)]);
   res.json({ ok: true });
 });
 

← 7cbd55e Build out every button with real UX: sessions + claim gate,  ·  back to Bubbesblock  ·  (newest)