// ───────────────────────────────────────────────────────────────────────────── // A02:2025 SECURITY MISCONFIGURATION (2021: A05 same name, up from #5) // ───────────────────────────────────────────────────────────────────────────── // WHAT : The code is fine; the settings around it are not — insecure // defaults, debug features, missing hardening, exposed internals. // HAPPENS : verbose stack traces in prod; default credentials (admin/admin); // directory listing; DEBUG=true / NODE_ENV unset; CORS '*' with // credentials; missing security headers (CSP, HSTS, X-Content-Type- // Options, X-Frame-Options); unnecessary HTTP methods (TRACE, PUT); // public cloud bucket / open S3 ACL; unpatched platform; sample apps. // SPOT : .env committed; `app.use(express.static(root))` on repo root; // error handler sending err.stack; `cors()` with no options; // `X-Powered-By: Express`; Swagger/GraphQL playground reachable in // prod; recon: /.git/, /.env, /server-status, /actuator, /debug. // FIX : config loaded from env and VALIDATED at startup (fail fast); // fail-closed defaults; helmet-style headers; generic error pages; // least-privilege IAM; infrastructure-as-code reviewed; hardened // golden images; automated config scanning (CIS benchmarks). // SAY : CWE-16 Configuration, CWE-209 Information Exposure Through Error // Message, CWE-1104 Unmaintained Components, CWE-942 Permissive CORS. // Pitch: "Secure by default, verified at boot, same config path for // dev/stage/prod with only the values differing." // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── 1. Environment-driven config with validation at startup (fail fast) // VULNERABLE: // const port = process.env.PORT || 3000; // const debug = process.env.DEBUG !== 'false'; // undefined => true. fail-open. // const secret = process.env.SESSION_SECRET || 'changeme'; // FIXED: parse once, require what matters, throw before listening. function loadConfig(env) { const isProd = env.NODE_ENV === 'production'; const errors = []; if (!['production', 'development', 'test'].includes(env.NODE_ENV)) errors.push('NODE_ENV invalid'); if (!env.SESSION_SECRET || env.SESSION_SECRET.length < 32) errors.push('SESSION_SECRET too short'); if (env.SESSION_SECRET === 'changeme') errors.push('SESSION_SECRET is default'); if (isProd && env.DEBUG === 'true') errors.push('DEBUG on in production'); if (isProd && env.CORS_ORIGIN === '*') errors.push('CORS_ORIGIN wildcard in production'); if (errors.length) throw new Error('config: ' + errors.join('; ')); return Object.freeze({ isProd, debug: env.DEBUG === 'true', // fail-closed: must opt in sessionSecret: env.SESSION_SECRET, corsOrigins: (env.CORS_ORIGIN || '').split(',').filter(Boolean), exposeErrors: !isProd, }); } const goodEnv = { NODE_ENV: 'production', SESSION_SECRET: 'x'.repeat(48), CORS_ORIGIN: 'https://app.example.com' }; assert.equal(loadConfig(goodEnv).debug, false); assert.equal(loadConfig(goodEnv).exposeErrors, false); assert.throws(() => loadConfig({ ...goodEnv, DEBUG: 'true' }), /DEBUG on in production/); assert.throws(() => loadConfig({ ...goodEnv, SESSION_SECRET: 'changeme' }), /default|short/); assert.throws(() => loadConfig({ ...goodEnv, CORS_ORIGIN: '*' }), /wildcard/); assert.throws(() => loadConfig({ SESSION_SECRET: 'x'.repeat(48) }), /NODE_ENV/); // ── 2. Security headers (what helmet sets, as a plain object) // VULNERABLE: none of these set; `X-Powered-By: Express` leaks the stack. // FIXED: const securityHeaders = Object.freeze({ 'Content-Security-Policy': "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', 'X-Content-Type-Options': 'nosniff', // no MIME sniffing -> blocks some XSS vectors 'X-Frame-Options': 'DENY', // clickjacking (legacy; CSP frame-ancestors is modern) 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', 'Cache-Control': 'no-store', // for authenticated responses }); const removeHeaders = ['X-Powered-By', 'Server']; function applyHeaders(res) { for (const [k, v] of Object.entries(securityHeaders)) res.headers[k] = v; for (const k of removeHeaders) delete res.headers[k]; return res; } const res = applyHeaders({ headers: { 'X-Powered-By': 'Express' } }); assert.equal(res.headers['X-Powered-By'], undefined); assert.match(res.headers['Content-Security-Policy'], /frame-ancestors 'none'/); assert.equal(res.headers['X-Content-Type-Options'], 'nosniff'); assert.match(res.headers['Strict-Transport-Security'], /max-age=\d+/); // ── 3. Error handler: no stack traces in prod // VULNERABLE: // app.use((err, req, res, next) => res.status(500).send(err.stack)); // paths, versions, SQL // FIXED: generic message out, full detail to logs with a correlation id. function errorResponse(err, config, requestId) { const body = { error: 'Internal Server Error', requestId }; if (config.exposeErrors) body.detail = err.message; // dev only; never err.stack to client return { status: 500, body }; } const prodCfg = loadConfig(goodEnv); const devCfg = loadConfig({ ...goodEnv, NODE_ENV: 'development' }); const boom = new Error('ECONNREFUSED 10.0.0.7:5432 at /srv/app/db.js:42'); assert.deepEqual(errorResponse(boom, prodCfg, 'r1').body, { error: 'Internal Server Error', requestId: 'r1' }); assert.equal(errorResponse(boom, devCfg, 'r1').body.detail, boom.message); assert.equal(JSON.stringify(errorResponse(boom, prodCfg, 'r1')).includes('10.0.0.7'), false); // ── 4. HTTP method + default-credential checks // VULNERABLE: server answers TRACE/OPTIONS everywhere; admin user shipped as admin/admin. // FIXED: const allowedMethods = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']); function methodAllowed(m) { return allowedMethods.has(m); } assert.equal(methodAllowed('TRACE'), false); assert.equal(methodAllowed('GET'), true); const knownDefaults = new Set(['admin:admin', 'admin:password', 'root:root', 'guest:guest', 'test:test']); function rejectsDefaultCredential(user, pass) { return !knownDefaults.has(`${user}:${pass}`); } assert.equal(rejectsDefaultCredential('admin', 'admin'), false); assert.equal(rejectsDefaultCredential('admin', 'Zr9!kq2#vL'), true); // ── 5. Directory listing / static root / public bucket (checklist, not code) // express.static(path.join(__dirname, 'public'), { dotfiles: 'deny', index: false }); // S3: Block Public Access ON at account level; bucket policy explicit; no 'Principal': '*'. // Disable GraphQL introspection + playground in prod; remove sample apps/docs endpoints. console.log('ok: owasp/a02-security-misconfiguration.js');