Security Misconfiguration
OWASP API8 — the vulnerability class where nobody wrote any bad code.
Misconfiguration is the category with no bug in it. The code is fine; a setting is wrong, a default was never changed, or an environment drifted from the one that was reviewed.
The usual findings
- Debug mode in production — stack traces, a debug toolbar, verbose errors.
- Default credentials on an admin panel, a database, a message queue, a monitoring tool.
- Permissive CORS introduced to make an error go away.
- Missing security headers.
- Directory listing enabled, or
.gitand.envserved as static files. - Cloud storage buckets readable by the public.
- Verbose banners disclosing exact versions.
- Unnecessary HTTP methods —
TRACE,PUTon a static host. - Environments that drifted — staging hardened, production not, or the reverse.
Why it persists
Configuration lives outside the code review. A developer changes a value to unblock themselves, it works, and it ships. Nobody reviews an environment variable the way they review a function.
The fixes that work
- Configuration as code. Infrastructure in a repository, reviewed like everything else.
- Refuse to start when a dangerous setting is present in production — a crash is better than a warning nobody reads.
- Scan from outside, on a schedule, against production.
- Identical environments, differing only in scale and data. Divergence is where misconfiguration hides.
- Secure defaults in your own framework, so the safe path is the default path.
Example
// Refuse to start rather than warn
if (process.env.NODE_ENV === 'production') {
const forbidden = {
DEBUG: process.env.DEBUG,
SHOW_STACK_TRACES: process.env.SHOW_STACK_TRACES === 'true',
GRAPHQL_INTROSPECTION: process.env.GRAPHQL_INTROSPECTION === 'true',
CORS_ALLOW_ALL: process.env.CORS_ALLOW_ALL === 'true',
DISABLE_AUTH: process.env.DISABLE_AUTH === 'true',
NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0',
};
const enabled = Object.entries(forbidden).filter(([, v]) => v);
if (enabled.length) {
throw new Error(`Unsafe in production: ${enabled.map(([k]) => k).join(', ')}`);
}
}When to use it
- A debug toolbar left enabled in production exposes environment variables including database credentials.
- A publicly readable storage bucket is found by an external scan before it is found by anyone else.
- An application refuses to boot after a deploy sets DISABLE_AUTH, which had been used locally and committed by accident.
More examples
A startup guard with real coverage
Exiting rather than warning is the whole design: a warning in a startup log is indistinguishable from every other startup log line.
// config/assert-production-safe.js — imported first in the entry point.
const problems = [];
const isProd = process.env.NODE_ENV === 'production';
function forbid(condition, message) { if (condition) problems.push(message); }
function require_(condition, message) { if (!condition) problems.push(message); }
if (isProd) {
// ── Debug and verbosity ────────────────────────────────────────────
forbid(process.env.DEBUG, 'DEBUG must not be set');
forbid(process.env.SHOW_STACK_TRACES === 'true', 'stack traces must be off');
forbid(process.env.LOG_LEVEL === 'debug', 'LOG_LEVEL must not be debug');
forbid(process.env.GRAPHQL_INTROSPECTION === 'true', 'introspection must be off');
forbid(process.env.SWAGGER_UI === 'true', 'swagger ui must be off');
// ── Anything that disables a control ───────────────────────────────
forbid(process.env.DISABLE_AUTH === 'true', 'DISABLE_AUTH is set');
forbid(process.env.DISABLE_RATE_LIMIT === 'true', 'DISABLE_RATE_LIMIT is set');
forbid(process.env.SKIP_CSRF === 'true', 'SKIP_CSRF is set');
forbid(process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0',
'TLS verification is disabled');
// ── Placeholder and weak secrets ───────────────────────────────────
const WEAK = new Set(['secret', 'changeme', 'password', 'test', 'dev', 'admin']);
for (const name of ['SESSION_SECRET', 'JWT_SECRET', 'ACCESS_SECRET']) {
const v = process.env[name];
require_(v, `${name} must be set`);
forbid(v && v.length < 32, `${name} must be at least 32 characters`);
forbid(v && WEAK.has(v.toLowerCase()), `${name} is a placeholder value`);
}
// ── URLs and origins ───────────────────────────────────────────────
require_(process.env.APP_URL?.startsWith('https://'), 'APP_URL must be https');
forbid(process.env.CORS_ORIGINS === '*', 'CORS_ORIGINS must not be *');
forbid(process.env.CORS_ORIGINS?.includes('localhost'),
'CORS_ORIGINS includes localhost');
// ── Database ───────────────────────────────────────────────────────
forbid(process.env.DATABASE_URL?.includes('localhost'),
'DATABASE_URL points at localhost');
forbid(process.env.DATABASE_URL?.includes('sslmode=disable'),
'database connection is not encrypted');
forbid(/:(postgres|root|admin|password)@/.test(process.env.DATABASE_URL ?? ''),
'DATABASE_URL uses a default credential');
// ── Cookies ────────────────────────────────────────────────────────
require_(process.env.COOKIE_SECURE !== 'false', 'cookies must be Secure');
}
if (problems.length) {
console.error('\n❌ Unsafe production configuration:\n' +
problems.map((p) => ` - ${p}`).join('\n') + '\n');
process.exit(1); // crash. Do not warn.
}
// index.js
// import './config/assert-production-safe.js'; ← the FIRST import
// A misconfigured deploy now fails in the health check within seconds, which
// is exactly when you want to find out.An external scan you run on a schedule
Running from outside is what makes this meaningful — the same script executed inside the VPC passes against hosts that are wide open to the internet.
#!/usr/bin/env bash
# scripts/misconfig-scan.sh — from OUTSIDE, against PRODUCTION, weekly.
set -uo pipefail
HOST="${1:-https://dfg.com}"
FAIL=0
note() { echo "$1"; FAIL=1; }
# ── 1. Files that must never be served ───────────────────────────────
for path in /.env /.git/config /.git/HEAD /package.json /composer.json \
/config.json /docker-compose.yml /.aws/credentials /backup.sql; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HOST$path")
[ "$code" = "200" ] && note "EXPOSED FILE: $path"
done
# ── 2. Directory listing ─────────────────────────────────────────────
curl -s --max-time 5 "$HOST/uploads/" | grep -qi 'index of' \
&& note "DIRECTORY LISTING at /uploads/"
# ── 3. Debug and management surfaces ─────────────────────────────────
for path in /debug/pprof/ /actuator/env /metrics /graphiql /swagger-ui \
/_profiler /telescope /horizon /phpinfo.php; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HOST$path")
[ "$code" = "200" ] && note "EXPOSED SURFACE: $path"
done
# ── 4. Stack traces in errors ────────────────────────────────────────
body=$(curl -s --max-time 5 "$HOST/api/does-not-exist-$RANDOM")
echo "$body" | grep -qiE 'stack|at /|\.js:[0-9]+|node_modules|Traceback|Exception' \
&& note "STACK TRACE in an error response"
# ── 5. Version banners ───────────────────────────────────────────────
headers=$(curl -sI --max-time 5 "$HOST/")
echo "$headers" | grep -iE '^(server|x-powered-by|x-aspnet-version):.*[0-9]+\.[0-9]' \
&& note "VERSION DISCLOSURE in headers"
# ── 6. Dangerous HTTP methods ────────────────────────────────────────
for m in TRACE TRACK PUT DELETE CONNECT; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 -X "$m" "$HOST/")
[ "$code" = "200" ] && note "METHOD ALLOWED: $m /"
done
# ── 7. Permissive CORS ───────────────────────────────────────────────
acao=$(curl -sI --max-time 5 "$HOST/api/health" -H 'Origin: https://evil.com' \
| grep -i 'access-control-allow-origin' | tr -d '\r')
[ -n "$acao" ] && note "CORS REFLECTS an untrusted origin: $acao"
# ── 8. Missing headers ───────────────────────────────────────────────
echo "$headers" | grep -qi 'strict-transport-security' || note "MISSING HSTS"
echo "$headers" | grep -qi 'x-content-type-options' || note "MISSING nosniff"
# ── 9. Cloud storage ─────────────────────────────────────────────────
for bucket in dfg-uploads dfg-backups dfg-assets dfg-static; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
"https://$bucket.s3.amazonaws.com/")
[ "$code" = "200" ] && note "PUBLIC BUCKET: $bucket"
done
exit $FAILPreventing drift between environments
Comparing the running configuration rather than the declared configuration catches the case that terraform plan cannot: a value overridden at runtime.
# Misconfiguration lives in the difference between what was reviewed and what
# is running. Remove the difference.
# ── 1. One template, environment-specific values only ────────────────
# terraform/modules/api/main.tf — used by staging AND production
variable "environment" {}
variable "instance_count" { default = 2 }
resource "aws_ecs_service" "api" {
name = "api-${var.environment}"
desired_count = var.instance_count # ← differs
# Everything security-relevant is IDENTICAL:
# security groups, IAM roles, network policy, TLS policy,
# WAF rules, logging configuration, secret sources
}
# ── 2. Detect drift on a schedule ────────────────────────────────────
terraform plan -detailed-exitcode
# exit 0 = no drift; 2 = drift. Alert on 2, every night.
# Manual console changes are the usual source of production-only misconfiguration.
# ── 3. Policy as code, enforced in CI ────────────────────────────────
# policy/s3.rego
package terraform.s3
deny[msg] {
bucket := input.resource.aws_s3_bucket[name]
bucket.acl == "public-read"
msg := sprintf("S3 bucket %v must not be public-read", [name])
}
deny[msg] {
bucket := input.resource.aws_s3_bucket[name]
not bucket.server_side_encryption_configuration
msg := sprintf("S3 bucket %v must be encrypted", [name])
}
# conftest test terraform-plan.json
# ── 4. Compare environments directly ─────────────────────────────────
diff <(aws ecs describe-services --cluster staging --services api \
| jq -S '.services[0] | {networkConfiguration, taskDefinition}') \
<(aws ecs describe-services --cluster prod --services api \
| jq -S '.services[0] | {networkConfiguration, taskDefinition}')
# ── 5. And compare the RUNNING configuration, not just the declared one ─
# An authenticated endpoint that reports the security-relevant settings —
# no values, only whether each control is on.
curl -s https://dfg.com/internal/config-check -H "$ADMIN" | jq
# {
# "environment": "production",
# "debug": false, "introspection": false, "stackTraces": false,
# "corsOrigins": 3, "corsWildcard": false,
# "cookieSecure": true, "cookieSameSite": "lax",
# "rateLimitEnabled": true, "tlsMinVersion": "1.2"
# }
# Diff staging against production. Any difference is either intentional and
# documented, or a finding.
Discussion