[object Object]

← back to Commercialrealestate

CRCP: harden auth surface (yolo iter 1) — login rate-limit + form semantics + ticker a11y

6aeef2885b5648e0e14833fdc56021d61071aff1 · 2026-07-31 11:18:09 -0700 · Steve

- /auth/login: in-memory per-(ip|username) brute-force throttle, lock after 8 fails for 5min, reset on success (verified: 9th attempt -> 429, other users unaffected)
- deals-flow.html: login modal wrapped in a real <form> (password-manager + Enter + role=dialog/aria), Escape-to-close, magic-link fallback keyboard-accessible
- ticker: duplicated headline half marked aria-hidden (SR no longer reads every item twice), ticker region aria-labelled — deals-flow + just-listed
- re-verified headless: 0 console errors, login/persona/ticker/just-listed all functional

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6aeef2885b5648e0e14833fdc56021d61071aff1
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 31 11:18:09 2026 -0700

    CRCP: harden auth surface (yolo iter 1) — login rate-limit + form semantics + ticker a11y
    
    - /auth/login: in-memory per-(ip|username) brute-force throttle, lock after 8 fails for 5min, reset on success (verified: 9th attempt -> 429, other users unaffected)
    - deals-flow.html: login modal wrapped in a real <form> (password-manager + Enter + role=dialog/aria), Escape-to-close, magic-link fallback keyboard-accessible
    - ticker: duplicated headline half marked aria-hidden (SR no longer reads every item twice), ticker region aria-labelled — deals-flow + just-listed
    - re-verified headless: 0 console errors, login/persona/ticker/just-listed all functional
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/deals-flow.html   | 24 +++++++++++++-----------
 public/just-listed.html  |  4 ++--
 scripts/crcp-accounts.js | 18 ++++++++++++++++++
 3 files changed, 33 insertions(+), 13 deletions(-)

diff --git a/public/deals-flow.html b/public/deals-flow.html
index 33a3c56..e2d928d 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -88,7 +88,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
   <div class="nav"><a href="/just-listed.html">Just Listed</a><a href="/fha-leads.html">Assumable Leads</a><a href="/mls.html">Listings</a><a href="/">Explorer</a></div>
   <span id="auth" style="margin-left:auto;font-size:12px;color:var(--blue);cursor:pointer">Sign in</span>
 </header>
-<div class="ticker" id="ticker" style="display:none"><div class="lbl">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
+<div class="ticker" id="ticker" style="display:none" aria-label="Breaking real estate headlines"><div class="lbl" aria-hidden="true">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
 <div class="shell">
 <aside class="rail">
   <div class="rsec" id="savedSec" style="display:none"><h4>My saved searches</h4><div class="chips" id="fSaved"></div></div>
@@ -115,15 +115,17 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
 </div></div>
 
 <!-- sign-in modal (username + password) -->
-<div class="modal" id="loginModal">
+<div class="modal" id="loginModal" role="dialog" aria-modal="true" aria-label="Sign in to CRCP">
   <div class="box">
     <h3>Sign in to CRCP</h3>
     <p>Your personal deal-flow — saved searches, watchlist, and a home tuned to your business.</p>
-    <label>Username</label><input id="liUser" autocomplete="username" placeholder="frank">
-    <label>Password</label><input id="liPass" type="password" autocomplete="current-password" placeholder="••••••••">
-    <div class="err" id="liErr"></div>
-    <button class="btn" id="liBtn">Sign in</button>
-    <span class="alt" id="liMagic">Prefer an email magic-link instead?</span>
+    <form id="loginForm">
+      <label for="liUser">Username</label><input id="liUser" name="username" autocomplete="username" placeholder="frank" aria-label="Username">
+      <label for="liPass">Password</label><input id="liPass" name="password" type="password" autocomplete="current-password" placeholder="••••••••" aria-label="Password">
+      <div class="err" id="liErr" role="alert" aria-live="polite"></div>
+      <button class="btn" id="liBtn" type="submit">Sign in</button>
+    </form>
+    <span class="alt" id="liMagic" role="button" tabindex="0">Prefer an email magic-link instead?</span>
   </div>
 </div>
 
@@ -244,11 +246,11 @@ function openLogin(){$('#liErr').textContent='';$('#liUser').value='';$('#liPass
 const signIn=openLogin;   // back-compat: save/watch/star gates call signIn() to prompt sign-in
 function closeLogin(){$('#loginModal').classList.remove('on');}
 async function doLogin(){const username=$('#liUser').value.trim(),password=$('#liPass').value;if(!username||!password){$('#liErr').textContent='Enter username and password.';return;}$('#liErr').textContent='…';const r=await api('/auth/login',{method:'POST',body:JSON.stringify({username,password})}).catch(()=>null);if(r&&r.ok){closeLogin();location.reload();}else{$('#liErr').textContent=(r&&r.error)||'Sign-in failed.';}}
-$('#liBtn').onclick=doLogin;
-$('#liUser').addEventListener('keydown',e=>{if(e.key==='Enter')$('#liPass').focus();});
-$('#liPass').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();});
+$('#loginForm').addEventListener('submit',e=>{e.preventDefault();doLogin();});
 $('#liMagic').onclick=magicSignIn;
+$('#liMagic').addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();magicSignIn();}});
 $('#loginModal').addEventListener('click',e=>{if(e.target.id==='loginModal')closeLogin();});
+document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLogin();});
 // magic-link fallback (kept for email accounts)
 async function magicSignIn(){const email=prompt('Email for a magic-link sign-in:');if(!email)return;const r=await api('/auth/request',{method:'POST',body:JSON.stringify({email})});if(r.magic_link){if(confirm('Dev mode — open your magic-link to sign in now?'))location.href=r.magic_link;}else alert('Check your email for a sign-in link.');}
 // ── first-run industry / role picker ──
@@ -257,7 +259,7 @@ function openRole(){_roleSel=(ME&&ME.role)||null;$('#roleErr').textContent='';$(
 async function saveRole(){if(!_roleSel)return;const r=await api('/auth/profile',{method:'POST',body:JSON.stringify({role:_roleSel})}).catch(()=>null);if(r&&r.ok){ME=r.me;$('#roleModal').classList.remove('on');renderAuth();renderPersona();}else{$('#roleErr').textContent=(r&&r.error)||'Could not save.';}}
 $('#roleBtn').onclick=saveRole;
 // ── breaking-news ticker ──
-async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');$('#tickTrack').innerHTML=html+html;$('#ticker').style.display='';}catch(e){}}
+async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');const dup=html.replace(/<span class="it">/g,'<span class="it" aria-hidden="true">');$('#tickTrack').innerHTML=html+dup;$('#ticker').style.display='';}catch(e){}}
 loadTicker();
 async function loadSaved(){const r=await api('/api/saved-searches');const s=r.saved||[];window._saved=s;$('#fSaved').innerHTML=s.length?s.map(x=>`<span class="chip" data-sid="${x.id}" title="apply this search">${esc(x.name)}<span class="ct" data-del="${x.id}" title="delete">✕</span></span>`).join(''):'<span style="font-size:11px;color:var(--mut)">none yet — set filters, then ★ Save search</span>';}
 async function loadWatch(){const r=await api('/api/watchlist');WATCH=new Set(r.ids||[]);render();}
diff --git a/public/just-listed.html b/public/just-listed.html
index c4edd65..cd55d72 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -60,7 +60,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
   <div class="nav"><a href="/deals-flow.html">Deal Flow</a><a href="/mls.html">Ranked Deals</a><a href="/">Explorer</a></div>
   <span id="auth" style="margin-left:auto;font-size:12px;color:var(--blue);cursor:pointer">Sign in</span>
 </header>
-<div class="ticker" id="ticker" style="display:none"><div class="lbl">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
+<div class="ticker" id="ticker" style="display:none" aria-label="Breaking real estate headlines"><div class="lbl" aria-hidden="true">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
 <div class="shell">
 <aside class="rail">
   <div class="rsec"><h4>Status</h4><div class="chips" id="fStatus"></div></div>
@@ -169,7 +169,7 @@ const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/jso
 function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role?'':'';el.innerHTML=`<b style="color:#fff">${who}</b> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=()=>location.href='/deals-flow.html';}}
 api('/api/me').then(m=>{ME=m;renderAuth();}).catch(()=>{});
 // ── breaking-news ticker ──
-async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');$('#tickTrack').innerHTML=html+html;$('#ticker').style.display='';}catch(e){}}
+async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');const dup=html.replace(/<span class="it">/g,'<span class="it" aria-hidden="true">');$('#tickTrack').innerHTML=html+dup;$('#ticker').style.display='';}catch(e){}}
 loadTicker();
 // ── load active inventory ──
 fetch('/data/listings.json').then(r=>r.json()).then(d=>{
diff --git a/scripts/crcp-accounts.js b/scripts/crcp-accounts.js
index 994e82d..cdf788a 100644
--- a/scripts/crcp-accounts.js
+++ b/scripts/crcp-accounts.js
@@ -77,15 +77,33 @@ module.exports = function mountAccounts(app, ROOT) {
   // magic-link, so saved-searches + watchlist work identically. The user key for a password
   // account is its lowercased username, stored under db.users like any other account.
   const uname = s => String(s || '').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
+  // In-memory brute-force throttle (defense-in-depth — the whole site already sits behind basic
+  // auth, but this caps guesses against a known username too). Keyed by ip|username; after 8 fails
+  // it locks that key for 5 min. Resets on success. Process-local + reversible — no store, no dep.
+  const _fails = new Map();
+  const LOCK_N = 8, LOCK_MS = 5 * 60 * 1000;
+  const gc = () => { for (const [k, v] of _fails) if (v.until && v.until < now()) _fails.delete(k); };
   app.post('/auth/login', (req, res) => {
     const b = req.body || {};
     const username = uname(b.username);
     if (!username || !b.password) return res.status(400).json({ error: 'username and password required' });
+    gc();
+    const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
+    const fk = ip + '|' + username;
+    const f = _fails.get(fk);
+    if (f && f.until && f.until > now()) {
+      return res.status(429).json({ error: 'too many attempts — try again in a few minutes' });
+    }
     const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
     const u = db.users[username];
     if (!u || !u.passhash || !verifyPw(b.password, u.salt, u.passhash)) {
+      const rec = f && f.until <= now() ? { n: 0 } : (f || { n: 0 });   // reset a lapsed lock
+      rec.n = (rec.n || 0) + 1;
+      if (rec.n >= LOCK_N) rec.until = now() + LOCK_MS;
+      _fails.set(fk, rec);
       return res.status(401).json({ error: 'invalid username or password' });   // same message either way (no user-enumeration)
     }
+    _fails.delete(fk);   // clean slate on success
     const sid = rid(); db.sessions[sid] = { email: username, exp: now() + 30 * 86400000 };
     u.last_login = new Date().toISOString(); save(db);
     setSession(res, sid);

← ba57a25 crcp Panel 7: add 6/12/24-month window toggle (persisted, re  ·  back to Commercialrealestate  ·  condos detail modal: broker/firm fields drill to crcp (TK-10 653c5c9 →