[object Object]

← back to Wallco Ai

SECURITY: fix reflected XSS in htmlHead (<head> now esc()'d at chokepoint — kills /search ?q=</title><script> vector) + gate /api/generator/batch with isAdmin (was the only ungated generator route; anon could spawn 50 SDXL jobs). claude-codex findings #2/#3

c2f49d0d31f1f1ccf557d7e9a6dfa71c04a7a310 · 2026-06-01 14:13:36 -0700 · Steve Abrams

Files touched

Diff

commit c2f49d0d31f1f1ccf557d7e9a6dfa71c04a7a310
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 14:13:36 2026 -0700

    SECURITY: fix reflected XSS in htmlHead (<head> now esc()'d at chokepoint — kills /search ?q=</title><script> vector) + gate /api/generator/batch with isAdmin (was the only ungated generator route; anon could spawn 50 SDXL jobs). claude-codex findings #2/#3
---
 server.js | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/server.js b/server.js
index 44b5dd8..60fc6d1 100644
--- a/server.js
+++ b/server.js
@@ -5960,6 +5960,17 @@ const GTAG = GA4_ID ? `
 </script>` : '';
 
 function htmlHead({ title, description, canonical, ogImage = '', ogType = 'website', ogImageAlt = '', productOG = null, preloadImages = [] }) {
+  // SECURITY (2026-06-01, claude-codex finding): HTML-escape every dynamic value
+  // interpolated into <head> below, at this single chokepoint. The <body> esc()'d
+  // but the <head> did not — and /search reflects the user query as the page title
+  // (`Search: ${qRaw}`), so `?q=</title><script>…` was live reflected XSS. Escaping
+  // &<>"' is safe for both element-text (<title>) and attribute (content="…") contexts.
+  const _escHead = (s) => String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c]));
+  title = _escHead(title);
+  description = _escHead(description);
+  canonical = _escHead(canonical);
+  ogImage = ogImage ? _escHead(ogImage) : ogImage;
+  ogImageAlt = _escHead(ogImageAlt);
   const siteUrl = `https://${SITE}`;
   const ogImg = ogImage || `${siteUrl}/og-default.png`;
   const imgAlt = ogImageAlt || title;
@@ -21552,6 +21563,11 @@ app.delete('/api/generator/recipes/:id', (req, res) => {
 // runs to completion regardless of the tab. Progress shows up in the Recent
 // Runs list, which the page already polls every 30s.
 app.post('/api/generator/batch', (req, res) => {
+  // SECURITY (2026-06-01, claude-codex finding): this spawns up to 50 background
+  // SDXL renders. It was the ONLY /api/generator/* route missing the admin gate
+  // its siblings (palette/cooldowns/digest/recipes) all carry — so an anonymous
+  // POST could burn the houses' GPU at will. Gate it consistently.
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const { colors = '', elements = [], styles = [], patterns = [], extra_text = '' } = req.body || {};
   // Clamp the requested count to a sane 1–50 so a fat-fingered number can't
   // spawn a runaway render job.

← c76ad29 deploy: orphaned-entrypoint guard — warn if a pm2 process po  ·  back to Wallco Ai  ·  SECURITY: SQL-escape interpolated paths/category in elements b3db6de →