← 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
M db/schema.sqlM public/app.jsM public/notifications.htmlM public/search.htmlM server.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)