← 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
A .env.exampleM .gitignoreM server.jsM src/marketplace/index.js
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 →