[object Object]

← back to Rentv 2026

rentv: P1+P2+P3 public-launch hardening (TK-10564/10575, Steve-approved 2026-08-14)

f2bf755126d59a4c805868c18fd20c7d9c456c95 · 2026-08-15 04:09:11 -0700 · Steve Abrams

P1 (critical): fix INTERNAL_STATIC %2f bypass — test decodeURIComponent(req.path) so
/admin%2f correctly 302→/login like /admin does. Closes live UI-shell info-disclosure.

P2: trust proxy (req.protocol=https, req.ip=real-client behind nginx TLS terminator);
brute-force throttle on POST /api/login (10 bad attempts/15 min → 429 Retry-After);
robots.txt Disallow: /api/ to block bulk API/CSV scraping.

P3: app.disable('x-powered-by'); baseline headers middleware (X-Content-Type-Options,
X-Frame-Options, Referrer-Policy, HSTS on secure connections).

Co-Authored-By: 4am-fix-loop <noreply@anthropic.com>

Files touched

Diff

commit f2bf755126d59a4c805868c18fd20c7d9c456c95
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 15 04:09:11 2026 -0700

    rentv: P1+P2+P3 public-launch hardening (TK-10564/10575, Steve-approved 2026-08-14)
    
    P1 (critical): fix INTERNAL_STATIC %2f bypass — test decodeURIComponent(req.path) so
    /admin%2f correctly 302→/login like /admin does. Closes live UI-shell info-disclosure.
    
    P2: trust proxy (req.protocol=https, req.ip=real-client behind nginx TLS terminator);
    brute-force throttle on POST /api/login (10 bad attempts/15 min → 429 Retry-After);
    robots.txt Disallow: /api/ to block bulk API/CSV scraping.
    
    P3: app.disable('x-powered-by'); baseline headers middleware (X-Content-Type-Options,
    X-Frame-Options, Referrer-Policy, HSTS on secure connections).
    
    Co-Authored-By: 4am-fix-loop <noreply@anthropic.com>
---
 server.js | 41 ++++++++++++++++++++++++++++++++++-------
 1 file changed, 34 insertions(+), 7 deletions(-)

diff --git a/server.js b/server.js
index d652f6dc..5359b305 100644
--- a/server.js
+++ b/server.js
@@ -18,6 +18,19 @@ const { localizeImage } = require('./scripts/lib/localize.cjs'); // never hotlin
 const { spawn } = require('child_process'); // article-to-video generator jobs (/social studio)
 const express = require('express');
 const app = express();
+// P3 — hide framework fingerprint (TK-10564/10575 hardening, approved 2026-08-14)
+app.disable('x-powered-by');
+// P2 — trust nginx TLS terminator so req.protocol=https, req.ip=real-client (fixes robots sitemap http:// + login throttle)
+app.set('trust proxy', 1);
+// P3 — baseline security headers (TK-10564/10575 hardening, approved 2026-08-14)
+app.use((_q, r, next) => {
+  r.setHeader('X-Content-Type-Options', 'nosniff');
+  r.setHeader('X-Frame-Options', 'SAMEORIGIN');
+  r.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+  // HSTS only over HTTPS — nginx terminates TLS, so in prod req.secure=true with trust proxy set
+  if (_q.secure) r.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
+  next();
+});
 app.use(express.json({ limit: '256kb' })); // covers 40kb blog bodies + folded /consulting admin-bucket saves
 const PORT = process.env.PORT || 9704;
 const DATA = path.join(__dirname, 'data');
@@ -80,17 +93,29 @@ app.get('/csrf.js', (_q, r) => {
   r.type('application/javascript').set('Cache-Control', 'no-store').send(
     "(function(){var m=document.cookie.match(/(?:^|;\\s*)rentv_csrf=([^;]+)/);var t=m?decodeURIComponent(m[1]):'';if(!t)return;var of=window.fetch;window.fetch=function(u,o){o=o||{};var meth=((o.method||'GET')+'').toUpperCase();if(['POST','PUT','DELETE','PATCH'].indexOf(meth)>=0){var h=new Headers(o.headers||{});h.set('X-CSRF-Token',t);o.headers=h;}return of(u,o);};})();");
 });
+// P2 — brute-force throttle on now-public login (TK-10564/10575, approved 2026-08-14)
+// 10 bad attempts / 15-min window → 429, no external dep
+const _loginFails = new Map(); // ip → { count, first }
+const LOGIN_WIN_MS = 15 * 60 * 1000, LOGIN_MAX = 10;
 app.post('/api/login', (req, res) => {
   const b = req.body || {};
+  const ip = req.ip || '?';
+  const now = Date.now();
+  const f = _loginFails.get(ip) || { count: 0, first: now };
+  if (now - f.first > LOGIN_WIN_MS) { f.count = 0; f.first = now; }
+  if (f.count >= LOGIN_MAX) {
+    const retryAfter = Math.ceil((f.first + LOGIN_WIN_MS - now) / 1000);
+    return res.status(429).set('Retry-After', retryAfter).json({ ok: false, error: 'Too many attempts — try again later.' });
+  }
   const cred = 'Basic ' + Buffer.from(String(b.username || '') + ':' + String(b.password || '')).toString('base64');
   const role = CRED_ROLE.get(cred);
-  if (!role) return res.status(401).json({ ok: false, error: 'Invalid username or password' });
-  const id = newId(), csrf = newId(), now = Date.now();          // fresh id on every login = no session fixation
+  if (!role) { f.count++; _loginFails.set(ip, f); return res.status(401).json({ ok: false, error: 'Invalid username or password' }); }
+  const id = newId(), csrf = newId();          // fresh id on every login = no session fixation
   SESS.set(id, { role, user: String(b.username), created: now, seen: now, csrf });
-  const maxAge = Math.floor(SESS_ABS_MS / 1000), f = cookieFlags(req);
+  const maxAge = Math.floor(SESS_ABS_MS / 1000), flags = cookieFlags(req);
   res.setHeader('Set-Cookie', [
-    `rentv_sess=${id}; Max-Age=${maxAge}${f}`,
-    `rentv_csrf=${csrf}; Max-Age=${maxAge}${f.replace('; HttpOnly', '')}`, // readable by csrf.js (double-submit)
+    `rentv_sess=${id}; Max-Age=${maxAge}${flags}`,
+    `rentv_csrf=${csrf}; Max-Age=${maxAge}${flags.replace('; HttpOnly', '')}`, // readable by csrf.js (double-submit)
   ]);
   res.json({ ok: true, role });
 });
@@ -1440,8 +1465,9 @@ app.get('/topic/:slug', (_q, r) => sendPage(r, path.join(PUB, 'topic.html')));
 
 // robots + sitemap (both were missing — new SEO infra). Only indexable hubs listed.
 app.get('/robots.txt', (req, r) => {
+  // P2 — disallow API/CSV scraping; trust proxy ensures req.protocol=https here (TK-10564/10575)
   const base = (req.protocol + '://' + req.get('host')).replace(/\/$/, '');
-  r.type('text/plain').send(`User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`);
+  r.type('text/plain').send(`User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: ${base}/sitemap.xml\n`);
 });
 app.get('/sitemap.xml', (req, r) => {
   const base = (req.protocol + '://' + req.get('host')).replace(/\/$/, '');
@@ -2462,7 +2488,8 @@ require('./src/pr')(app, { adminOnly, sendPage, PUB });
 const INTERNAL_STATIC = /^\/(desk(\.html)?$|desk-assets\/|desk-admin|audience\.html|admin(\/|$|\.html)|versions(\/|$)|consulting(\/|$)|press(\/|$)|social(\.html)?$|summit-leads(\.html)?$|la-commercial(\.html)?$|pulse(\.html)?$|sector(\.html)?$|hub(\.html)?$|brief(\.html)?$|allstar(\.html)?$)/i;
 app.use((req, res, next) => {
   if (req.role === 'admin') return next();
-  if (INTERNAL_STATIC.test(req.path)) {
+  // P1 — %2f bypass: test DECODED path so /admin%2f is caught same as /admin/ (TK-10564/10575, approved 2026-08-14)
+  if (INTERNAL_STATIC.test(decodeURIComponent(req.path))) {
     // Mirror adminOnly: an anonymous browser deep-link to an internal shell (e.g.
     // /consulting/slideshow, which never reaches the adminOnly route gate) is bounced to
     // /login?next=… so admins can actually sign in and land on the page. APIs, logged-in

← d5a4141e pr-intelligence: add CAN-SPAM List-Unsubscribe header + addr  ·  back to Rentv 2026  ·  rentv: per-IP write-form rate limits on /api/subscribe + /ap 5a59077e →