← 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 →