[object Object]

← 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

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 →