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 .git and .env served as static files.
  • Cloud storage buckets readable by the public.
  • Verbose banners disclosing exact versions.
  • Unnecessary HTTP methodsTRACE, PUT on 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

  1. Configuration as code. Infrastructure in a repository, reviewed like everything else.
  2. Refuse to start when a dangerous setting is present in production — a crash is better than a warning nobody reads.
  3. Scan from outside, on a schedule, against production.
  4. Identical environments, differing only in scale and data. Divergence is where misconfiguration hides.
  5. Secure defaults in your own framework, so the safe path is the default path.

Example

Example · javascript
// 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.

Example · javascript
// 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.

Example · bash
#!/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 $FAIL

Preventing drift between environments

Comparing the running configuration rather than the declared configuration catches the case that terraform plan cannot: a value overridden at runtime.

Example · bash
# 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

  • Be the first to comment on this lesson.