[object Object]

← back to Wallco Ai

SEC-2: harden /_devlogin and prod secret fallbacks (fail-closed)

ad389f743128cc6a418c71d0d630b8ef7263a82e · 2026-05-23 11:45:50 -0700 · Steve Abrams

Three secret-handling sites were silently falling back to hardcoded
placeholder strings if their env var was unset, and one route was
relying on a runtime gate that could be bypassed:

1. /_devlogin (server.js:16935) — checked AUTH_MODE inside the handler
   then fell back to hardcoded `wallco-dev-secret-change-for-prod` if
   AUTH_DEV_SECRET was unset. Now the route is only REGISTERED when
   AUTH_MODE === 'dev' AND a non-placeholder AUTH_DEV_SECRET is present.
   In prod the route does not exist at all.

2. WALLCO_JWT_SECRET / JWT_SECRET (server.js:1554) — fell back to
   `wallco-trade-dev-secret-CHANGE-IN-PROD` with only a console.warn.
   Now routed through resolveSecret() which process.exit(1)s in prod if
   missing or placeholder; in dev uses a random ephemeral fallback.

3. MP_DESIGNER_SECRET (src/marketplace/index.js:49) — fell back to
   `wallco-marketplace-dev-secret-rotate-me`. Same fail-closed pattern.

resolveSecret() helper added at top of server.js. .env.example untracked
(was caught by .env* gitignore); added `!.env.example` exception so the
schema is committed. .env.example updated with proper rotation recipe
and three REQUIRED-in-prod secrets marked.

ACTION REQUIRED on Kamatera (/root/public-projects/wallco-ai/.env):
  AUTH_MODE=prod
  AUTH_DEV_SECRET=                 # empty for prod
  WALLCO_JWT_SECRET=<48-byte hex>
  MP_DESIGNER_SECRET=<48-byte hex>
Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
Restart pm2 after rotation. Without these the server will refuse to
boot — confirming the rotation actually happened.

Files touched

Diff

commit ad389f743128cc6a418c71d0d630b8ef7263a82e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 11:45:50 2026 -0700

    SEC-2: harden /_devlogin and prod secret fallbacks (fail-closed)
    
    Three secret-handling sites were silently falling back to hardcoded
    placeholder strings if their env var was unset, and one route was
    relying on a runtime gate that could be bypassed:
    
    1. /_devlogin (server.js:16935) — checked AUTH_MODE inside the handler
       then fell back to hardcoded `wallco-dev-secret-change-for-prod` if
       AUTH_DEV_SECRET was unset. Now the route is only REGISTERED when
       AUTH_MODE === 'dev' AND a non-placeholder AUTH_DEV_SECRET is present.
       In prod the route does not exist at all.
    
    2. WALLCO_JWT_SECRET / JWT_SECRET (server.js:1554) — fell back to
       `wallco-trade-dev-secret-CHANGE-IN-PROD` with only a console.warn.
       Now routed through resolveSecret() which process.exit(1)s in prod if
       missing or placeholder; in dev uses a random ephemeral fallback.
    
    3. MP_DESIGNER_SECRET (src/marketplace/index.js:49) — fell back to
       `wallco-marketplace-dev-secret-rotate-me`. Same fail-closed pattern.
    
    resolveSecret() helper added at top of server.js. .env.example untracked
    (was caught by .env* gitignore); added `!.env.example` exception so the
    schema is committed. .env.example updated with proper rotation recipe
    and three REQUIRED-in-prod secrets marked.
    
    ACTION REQUIRED on Kamatera (/root/public-projects/wallco-ai/.env):
      AUTH_MODE=prod
      AUTH_DEV_SECRET=                 # empty for prod
      WALLCO_JWT_SECRET=<48-byte hex>
      MP_DESIGNER_SECRET=<48-byte hex>
    Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
    Restart pm2 after rotation. Without these the server will refuse to
    boot — confirming the rotation actually happened.
---
 .env.example             | 35 +++++++++++++++++++
 .gitignore               |  2 ++
 server.js                | 91 ++++++++++++++++++++++++++++++++++--------------
 src/marketplace/index.js | 21 ++++++++++-
 4 files changed, 121 insertions(+), 28 deletions(-)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e1aecbb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,35 @@
+PORT=9905
+NODE_ENV=production
+GA4_MEASUREMENT_ID=G-XXXXXXXXXX
+
+# Auth mode. In dev, /_devlogin sets an admin cookie. In prod, /_devlogin
+# is NOT registered at all (route bypass is impossible). Setting AUTH_MODE=dev
+# in prod has no effect on /_devlogin registration — but other dev-only
+# behaviors may key off it, so leave this as `prod` on Kamatera.
+AUTH_MODE=prod
+# Required only when AUTH_MODE=dev. Generate with:
+#   node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
+# The string `wallco-dev-secret-change-for-prod` is treated as a placeholder
+# and will refuse to register /_devlogin even in dev mode.
+AUTH_DEV_SECRET=
+
+# Trade-tier JWT signing secret. REQUIRED in prod — the server refuses to
+# start if unset or set to the dev placeholder. Generate with the same
+# `crypto.randomBytes(48).toString('hex')` recipe above.
+WALLCO_JWT_SECRET=
+
+# Marketplace designer-cookie HMAC. REQUIRED in prod (same fail-closed rules).
+MP_DESIGNER_SECRET=
+
+DATABASE_URL=dw_unified
+
+# Shopify Admin API (used by the marketplace sample-order flow)
+SHOPIFY_STORE=designer-laboratory-sandbox.myshopify.com
+SHOPIFY_ADMIN_TOKEN=shpat_xxx
+SHOPIFY_API_VERSION=2024-10
+# Secret from the Shopify webhook subscription. Required in production;
+# if unset, the webhook handler logs a warning and accepts unsigned bodies (dev only).
+SHOPIFY_WEBHOOK_SECRET=
+
+# Default sample price in USD when a pattern has no sample_price set.
+MP_SAMPLE_PRICE=5.00
diff --git a/.gitignore b/.gitignore
index 43477b5..69e6334 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
 node_modules/
 .env*
+# but .env.example is a template — track it so the schema is committed
+!.env.example
 tmp/
 *.log
 .DS_Store
diff --git a/server.js b/server.js
index 28ca665..76bf56e 100644
--- a/server.js
+++ b/server.js
@@ -28,6 +28,35 @@ const { spawn, spawnSync, execSync, execFileSync } = require('child_process');
 const app  = express();
 const PORT = parseInt(process.env.PORT || '9905', 10);
 const SITE = 'wallco.ai';
+
+// Production secrets guard. Any of these strings appearing in env at boot in
+// prod means the operator never rotated the dev placeholder — refuse to start.
+// In dev/test the route handlers themselves are still allowed to use them,
+// but with a loud warning.
+const __IS_PROD = process.env.NODE_ENV === 'production';
+const __DEV_PLACEHOLDER_SECRETS = new Set([
+  'wallco-dev-secret-change-for-prod',
+  'wallco-trade-dev-secret-CHANGE-IN-PROD',
+  'wallco-marketplace-dev-secret-rotate-me',
+]);
+function resolveSecret(name, value, { allowDev = true } = {}) {
+  if (__IS_PROD) {
+    if (!value || __DEV_PLACEHOLDER_SECRETS.has(value)) {
+      console.error(`[secrets] FATAL: ${name} is unset or set to a known dev placeholder. Refusing to start in production. Rotate this in Kamatera /root/public-projects/wallco-ai/.env and restart pm2.`);
+      process.exit(1);
+    }
+    return value;
+  }
+  if (!value && allowDev) {
+    const ephemeral = crypto.randomBytes(32).toString('hex');
+    console.warn(`[secrets] ${name} unset in dev — using ephemeral random fallback (rotates per restart).`);
+    return ephemeral;
+  }
+  if (value && __DEV_PLACEHOLDER_SECRETS.has(value)) {
+    console.warn(`[secrets] ${name} is the dev placeholder. OK for local dev only — DO NOT deploy this value.`);
+  }
+  return value;
+}
 const DATA_FILE = path.join(__dirname, 'data', 'designs.json');
 const IMG_DIR   = path.join(__dirname, 'data', 'generated');
 const ROOMS_DIR = path.join(__dirname, 'public', 'uploads', 'rooms');
@@ -1552,12 +1581,10 @@ app.post('/api/designs/bulk-action', express.json({ limit: '64kb' }), (req, res)
 // Per Steve's standing rule: NO autonomous email sending. The magic link is
 // logged to console + echoed in the API response. Wire SES/sendgrid later.
 const jwt = require('jsonwebtoken');
-const WALLCO_JWT_SECRET = process.env.WALLCO_JWT_SECRET
-  || process.env.JWT_SECRET
-  || (function(){
-       console.warn('[trade-login] WALLCO_JWT_SECRET / JWT_SECRET unset — using dev fallback. Set one of these in .env for prod.');
-       return 'wallco-trade-dev-secret-CHANGE-IN-PROD';
-     })();
+const WALLCO_JWT_SECRET = resolveSecret(
+  'WALLCO_JWT_SECRET / JWT_SECRET',
+  process.env.WALLCO_JWT_SECRET || process.env.JWT_SECRET
+);
 const TRADE_COOKIE = 'wallco_trade_session';
 
 // Minimal cookie reader. cookie-parser isn't in deps and `req.cookies` is
@@ -16923,39 +16950,49 @@ app.get('/admin/health', (req, res) => {
   });
 });
 
-// ── DEV LOGIN — sets dw_auth HS256 cookie for admin layer + inline editor (dev mode only)
-const __crypto = require('crypto');
+// ── DEV LOGIN — sets dw_auth HS256 cookie for admin layer + inline editor.
+// SEC-2 (2026-05-23): the route is now ONLY registered when AUTH_MODE === 'dev'
+// AND a non-placeholder AUTH_DEV_SECRET is present. The previous gate inside
+// the handler still allowed registration in prod and silently fell back to a
+// hardcoded secret if the env var was missing — pre-auth admin takeover.
 function __b64u(s) { return Buffer.from(s).toString('base64').replace(/=+$/,'').replace(/\+/g,'-').replace(/\//g,'_'); }
 function __sign(secret, payload) {
   const h = __b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
   const p = __b64u(JSON.stringify(payload));
-  const sig = __b64u(__crypto.createHmac('sha256', secret).update(h + '.' + p).digest());
+  const sig = __b64u(crypto.createHmac('sha256', secret).update(h + '.' + p).digest());
   return `${h}.${p}.${sig}`;
 }
-app.get('/_devlogin', (req, res) => {
-  if ((process.env.AUTH_MODE || 'dev') !== 'dev') return res.status(403).send('dev login disabled in prod');
-  const secret = process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod';
-  const exp = Math.floor(Date.now() / 1000) + 8 * 3600;
-  const token = __sign(secret, {
-    email: 'steve@designerwallcoverings.com',
-    role: 'admin',
-    iss: 'https://auth.agentabrams.com',
-    exp
-  });
-  res.cookie('dw_auth', token, { httpOnly: false, maxAge: 8 * 3600 * 1000, sameSite: 'lax' });
-  const next = req.query.next || '/admin';
-  res.type('text/html').send(`<!doctype html><meta charset="utf-8"><title>Admin login</title>
+const __DEV_LOGIN_ENABLED = (process.env.AUTH_MODE === 'dev')
+  && !!process.env.AUTH_DEV_SECRET
+  && !__DEV_PLACEHOLDER_SECRETS.has(process.env.AUTH_DEV_SECRET);
+if (__DEV_LOGIN_ENABLED) {
+  const DEV_SECRET = process.env.AUTH_DEV_SECRET;  // already validated above
+  app.get('/_devlogin', (req, res) => {
+    const exp = Math.floor(Date.now() / 1000) + 8 * 3600;
+    const token = __sign(DEV_SECRET, {
+      email: 'steve@designerwallcoverings.com',
+      role: 'admin',
+      iss: 'https://auth.agentabrams.com',
+      exp
+    });
+    res.cookie('dw_auth', token, { httpOnly: false, maxAge: 8 * 3600 * 1000, sameSite: 'lax' });
+    const next = req.query.next || '/admin';
+    res.type('text/html').send(`<!doctype html><meta charset="utf-8"><title>Admin login</title>
 <style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0f0e0c;color:#e8e2d6;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}.card{background:#1a1816;padding:36px 44px;border-radius:10px;border:1px solid #2a2825;max-width:480px}.card h1{font:300 22px/1.2 'Cormorant Garamond',serif;margin:0 0 6px;color:#d2b15c}.card p{color:#bba;margin:6px 0}a{color:#d2b15c;font-weight:500}</style>
 <div class="card">
   <h1>Admin login set ✓</h1>
   <p>Logged in as <code>steve@designerwallcoverings.com</code> · expires in 8 hours.</p>
   <p><a href="${next}">→ Continue to ${next}</a> · <a href="/_devlogout">Log out</a></p>
 </div>`);
-});
-app.get('/_devlogout', (_req, res) => {
-  res.clearCookie('dw_auth');
-  res.type('text/plain').send('Logged out.\n');
-});
+  });
+  app.get('/_devlogout', (_req, res) => {
+    res.clearCookie('dw_auth');
+    res.type('text/plain').send('Logged out.\n');
+  });
+  console.log('[devlogin] /_devlogin registered (AUTH_MODE=dev, valid secret)');
+} else {
+  console.log('[devlogin] /_devlogin DISABLED (AUTH_MODE!=dev or AUTH_DEV_SECRET unset/placeholder)');
+}
 
 // ── Start
 app.listen(PORT, '0.0.0.0', () => {
diff --git a/src/marketplace/index.js b/src/marketplace/index.js
index a2eb70b..3491e2a 100644
--- a/src/marketplace/index.js
+++ b/src/marketplace/index.js
@@ -46,7 +46,26 @@ const onboarding = require('./onboarding');
 const orders = require('./orders');
 
 // ── Designer cookie helpers (lightweight HMAC; trust-only, not crypto-grade)
-const MP_SECRET = process.env.MP_DESIGNER_SECRET || process.env.JWT_SECRET || 'wallco-marketplace-dev-secret-rotate-me';
+// SEC-2 (2026-05-23): refuse the dev placeholder in prod. If MP_DESIGNER_SECRET
+// (or JWT_SECRET) is missing or the dev placeholder, fail-closed in prod and
+// use an ephemeral random secret in dev (which means designer cookies don't
+// survive a restart locally — that's correct dev behavior).
+const __MP_DEV_FALLBACKS = new Set(['wallco-marketplace-dev-secret-rotate-me']);
+const MP_SECRET = (() => {
+  const v = process.env.MP_DESIGNER_SECRET || process.env.JWT_SECRET;
+  if (process.env.NODE_ENV === 'production') {
+    if (!v || __MP_DEV_FALLBACKS.has(v)) {
+      console.error('[secrets] FATAL: MP_DESIGNER_SECRET / JWT_SECRET unset or dev placeholder in prod. Refusing to start.');
+      process.exit(1);
+    }
+    return v;
+  }
+  if (!v) {
+    console.warn('[secrets] MP_DESIGNER_SECRET unset in dev — using ephemeral random fallback.');
+    return crypto.randomBytes(32).toString('hex');
+  }
+  return v;
+})();
 function signDesigner(designerId) {
   const payload = Buffer.from(JSON.stringify({ id: designerId, t: Date.now() })).toString('base64url');
   const sig = crypto.createHmac('sha256', MP_SECRET).update(payload).digest('base64url');

← b6ef2b2 SEC-1: fix shell injection in POST /api/leads  ·  back to Wallco Ai  ·  SEC-3: strip payout_details / commission_rate from public de 72ddc0e →