← back to Bounce Studio
chore: session-close hardening — 413 on oversize body, path.sep traversal guard, in-flight submit guard
36dd476d0175c8838a24fca364d8381056089c2c · 2026-08-12 06:57:39 -0700 · steve@designerwallcoverings.com
Files touched
M public/index.htmlM server.js
Diff
commit 36dd476d0175c8838a24fca364d8381056089c2c
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Wed Aug 12 06:57:39 2026 -0700
chore: session-close hardening — 413 on oversize body, path.sep traversal guard, in-flight submit guard
---
public/index.html | 3 +++
server.js | 16 +++++++++++++---
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/public/index.html b/public/index.html
index 0215107..97688fb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -339,12 +339,15 @@
if(!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ msg.className='msg err'; msg.textContent='that email looks off — check it?'; return; }
if(ref && !/^(https?:\/\/)?[\w-]+(\.[\w-]{2,})+/.test(ref)){ msg.className='msg err'; msg.textContent='that link looks off — paste a full url?'; return; }
msg.textContent='sending…';
+ const submitBtn = form.querySelector('button[type=submit]');
+ submitBtn.disabled = true; // in-flight guard: no double-POST on a slow connection
try{
const r = await fetch('/api/waitlist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,budget,ref,note})});
const j = await r.json();
if(j.ok){ msg.className='msg ok'; msg.textContent="you're in the queue. we'll be in touch with a scoped brief."; form.reset(); }
else { msg.className='msg err'; msg.textContent = j.error || 'something went wrong — try again.'; }
}catch(_){ msg.className='msg err'; msg.textContent='network hiccup — try again.'; }
+ finally{ submitBtn.disabled = false; }
});
</script>
</body>
diff --git a/server.js b/server.js
index 8e6ba39..e0083e9 100644
--- a/server.js
+++ b/server.js
@@ -28,7 +28,7 @@ function serveStatic(req, res) {
let rel = clean === '/' ? 'index.html' : clean.replace(/^\/+/, '');
// prevent path traversal, keep nested public/ assets working
const fp = path.normalize(path.join(PUB, rel));
- if (!fp.startsWith(PUB)) { res.writeHead(403); return res.end('forbidden'); }
+ if (fp !== PUB && !fp.startsWith(PUB + path.sep)) { res.writeHead(403); return res.end('forbidden'); } // sep-guard: don't let a sibling like public-evil/ pass startsWith
if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
res.writeHead(200, { 'Content-Type': TYPES[path.extname(fp)] || 'application/octet-stream' });
return res.end(fs.readFileSync(fp));
@@ -56,9 +56,19 @@ const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/waitlist') {
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
if (!wlAllowed(ip)) { res.writeHead(429, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'too many requests' })); }
- let body = '';
- req.on('data', (c) => { body += c; if (body.length > 1e4) req.destroy(); });
+ let body = '', tooBig = false;
+ req.on('data', (c) => {
+ body += c;
+ if (body.length > 1e4 && !tooBig) { // cap + tell the client, don't just drop the socket
+ tooBig = true;
+ res.writeHead(413, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: 'payload too large' }));
+ req.destroy();
+ }
+ });
+ req.on('error', () => {}); // swallow the ECONNRESET that follows req.destroy()
req.on('end', () => {
+ if (tooBig) return;
let email = '', note = '', budget = '', ref = '';
try { const j = JSON.parse(body || '{}'); email = (j.email || '').trim(); note = (j.note || '').toString().slice(0, 500); budget = (j.budget || '').toString().slice(0, 40); ref = (j.ref || '').toString().trim().slice(0, 300); } catch (_) {}
const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
← 0689fe1 funnel: first-class reference-link field on #start (wired en
·
back to Bounce Studio
·
TK-10471: add social-share + JSON-LD SEO surface to landing 8e7eb09 →