---
key: "information-disclosure-misconfiguration"
title: "Ship without debug modes, verbose errors or exposed files"
url: "https://softwaresecurity.com/domains/appsec/secure-coding/information-disclosure-misconfiguration/"
api: "https://softwaresecurity.com/api/v1/articles/information-disclosure-misconfiguration.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-200","CWE-209","CWE-215","CWE-489","CWE-548","CWE-527","CWE-538","A02:2025","V13.4","V16.5.1","WSTG-CONF-02","WSTG-CONF-04","WSTG-CONF-05","WSTG-ERRH-01"]
tags: ["Error handling and information disclosure","Secure defaults and frameworks"]
stages: ["build"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Ship without debug modes, verbose errors or exposed files

Turn off debug output, publish an allowlisted build and probe each deployment for exposed files and endpoints.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-coding/information-disclosure-misconfiguration/

## In short

Configure production explicitly and check it from outside. Turn every framework's debug output off: Django `DEBUG = False`, never the Werkzeug debugger or `flask run`, `ASPNETCORE_ENVIRONMENT=Production`, `NODE_ENV=production`, `APP_DEBUG=false` and Rails `consider_all_requests_local = false`. Expose only Spring Boot Actuator's `health` endpoint over HTTP and put the rest on a management port behind authentication. Deploy a build directory that holds only allowlisted file types: no `.git`, `.env`, backups or public source maps, and no directory listings. Return generic errors with a reference ID, remove version banners, delete sample apps and default accounts, and run a probe against every deployment.

- OWASP moved Security Misconfiguration to A02 in the 2025 Top 10; its official CWE list includes CWE-489 Active Debug Code, while CWE-200 and CWE-548 sit under A01 and CWE-209 and CWE-215 under A10.
- A debugger in production is code execution, not only disclosure: the Werkzeug console runs Python, and Laravel's Ignition error page was exploitable while debug mode was on (CVE-2021-3129).
- Defaults differ: ASP.NET Core falls back to Production when no environment is set, but Express sends stack traces unless `NODE_ENV=production`, and PHP with no php.ini displays errors.
- Spring Boot exposes only `health` over HTTP; in our test `include=*` added `env`, `configprops` and `threaddump`, and `show-values=always` printed a datasource password in plain text.
- ASVS 5.0 section V13.4 turns this into seven checkable requirements, from `.git` folders to file-extension allowlists.
- Prove it from outside: a probe catches what a configuration review misses, such as a proxy that lists directories or a CDN that serves source maps.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Django | `DEBUG = False` with `ALLOWED_HOSTS` set | Fail the build on `manage.py check --deploy --fail-level WARNING`. | Django 6.1 settings reference |
| Flask | No `--debug`, no `debug=True`, no `flask run` in production | Serve the app with a production WSGI server; the debugger runs arbitrary Python. | Flask 3.1 and Werkzeug 3.1 documentation |
| Spring Boot Actuator | `management.endpoints.web.exposure.include=health` | The default. Keep `show-values` at its sanitizing default and put other endpoints on `management.server.port` behind authentication. | Spring Boot 4.1 reference: Endpoints |
| ASP.NET Core | `ASPNETCORE_ENVIRONMENT=Production`, `UseExceptionHandler()` with `AddProblemDetails()` | Production is also the fallback when nothing is set. `AddServerHeader = false` drops `Server: Kestrel`. | Microsoft Learn: Handle errors in ASP.NET Core |
| Express | `NODE_ENV=production`, a final error handler, `app.disable('x-powered-by')` | Without `NODE_ENV`, the built-in handler sends `err.stack`. | Express 5 guide: Error handling |
| Next.js | `next build` and `next start`; `productionBrowserSourceMaps` left `false`; `poweredByHeader: false` | Production replaces Server Component error messages with a generic message and a `digest`. | Next.js 16.4 documentation |
| Laravel | `APP_DEBUG=false` | The documentation says it should always be false in production. | Laravel 13 configuration |
| Rails | `config.consider_all_requests_local = false` | The generated production.rb default; true only in development and test. | Rails 8.1 configuring guide |
| PHP | `display_errors = Off`, `log_errors = On`, `expose_php = Off` | With no php.ini, `display_errors` defaults to On; even php.ini-production leaves `expose_php = On`. | php-src php.ini-production (PHP 8.5) |
| Web server | `autoindex off`, dotfiles denied except `/.well-known/`, allowlisted extensions, `server_tokens off` | Enforce it at the server even when the build is clean. | nginx documentation; ASVS 5.0 V13.4 |

## Verify it

1. Request a URL that throws, through the production path (CDN, proxy, application), and confirm the body is generic with a reference ID: no stack trace, SQL, file path, setting or environment value.
2. Request `/.git/HEAD`, `/.env`, a `.map` for your main bundle and a known backup name, and confirm each returns 404.
3. Request each folder that holds static files and confirm none returns a directory listing.
4. Confirm `/actuator/health` is the only Actuator path reachable from outside, and that `/actuator/env`, `/debug/pprof/` and similar paths return 401, 403 or 404.
5. Inspect response headers for version numbers in `Server`, `X-Powered-By` or `X-AspNet-Version`, and send `TRACE /` expecting 405 or 501.
6. Run `probe.py` (or your DAST tool's equivalent checks) after every deployment, and fail the release on any finding.
7. In CI, run `manage.py check --deploy --fail-level WARNING` or your framework's equivalent, and assert the environment variables in the deployment manifest.

## What you will learn

- Name the production switch for debug output in nine frameworks, and expose Spring Boot Actuator safely.
- Publish only an allowlisted build, and block `.git`, `.env`, source maps and listings at the web server.
- Probe a deployment for these leaks and map each finding to ASVS 5.0 V13.4.

Audience: Developers and platform engineers who configure and deploy web applications, and reviewers who sign off a release.

Before you start:

- Know how your framework reads its environment and configuration.
- For the probe: Python 3.13 or later; for the other examples, your framework's runtime.

Time: About 60 minutes: 25 to read, 10 to run the probe and 25 for the deployment exercise

## Misconfiguration is the cause, disclosure is the effect

The OWASP Top 10:2025 places Security Misconfiguration second, up from fifth in 2021, and describes it as insecure settings anywhere in the stack: unhardened components, unnecessary features, default accounts, overly detailed errors. At the application layer most of these failures disclose something: a stack trace with a database address, an environment dump with credentials, the source of the front end, the whole Git history. Some disclose a way in: a debug console runs code, and an exposed heap dump contains session tokens and keys.

MITRE's CWE-200 covers exposure of sensitive information in general and discourages mapping findings to it; it prefers the specific cause, which is how this guide is organised. Note where OWASP's 2025 mapping puts each one, because scanners and reports will cite them: CWE-489 Active Debug Code is in A02, CWE-200, CWE-548 (directory listing) and CWE-538 (sensitive files) are in A01 Broken Access Control, CWE-209 (error messages) and CWE-215 (debugging code) are in A10 Mishandling of Exceptional Conditions, and CWE-1392 default credentials is in A07.

Error-handling logic, meaning what a handler returns and how it fails closed, is covered in /domains/appsec/secure-coding/secure-error-handling/. This guide covers the configuration around it: the switches that decide whether the framework's own error page, console and diagnostics are reachable at all, and the files and endpoints a deployment should never serve. Cloud accounts and clusters have their own baselines at /domains/runtime/cloud-posture/cloud-provider-baselines/ and /domains/runtime/containers/managed-kubernetes-hardening/.

| Leak | Typical cause | What it gives an attacker | CWE |
| --- | --- | --- | --- |
| Debug error page or console | Debug mode or development environment in production | Source paths, settings, SQL, and with an interactive console, code execution | CWE-489, CWE-215, CWE-209 |
| Monitoring and admin endpoints | Actuator `include=*`, pprof imported, Swagger UI or status pages public | Environment variables, configuration, heap and thread dumps | CWE-200 |
| Repository and build files | The deploy copies the working tree; `.git`, `.env`, backups or `*.map` in the web root | Source code, history, secrets and internal hostnames | CWE-527, CWE-538 |
| Directory listing | Auto-indexing on, or a static file middleware that lists folders | Every file name, including ones never linked | CWE-548 |
| Version banners and TRACE | Default `Server`, `X-Powered-By` and TRACE handling | Exact versions to match against advisories, echoed headers | CWE-200 |
| Default accounts and sample apps | Installer defaults, examples left deployed | A login or a known-vulnerable page | CWE-1392 |

## Turn off each framework's debug output

Every framework has one setting that decides whether an unhandled exception produces a developer page. Set it explicitly in the production configuration rather than relying on its default, and make the deploy fail if it is wrong. The table gives the switch, what it shows when on, and whether the default is safe. The rows marked as tested were run for this guide; the rest come from the vendors' current documentation.

| Framework | Switch | When on, a failing request shows | Safe if unset? |
| --- | --- | --- | --- |
| Django 6.1 | `DEBUG` | A traceback with local variables and the settings, filtered only by name (tested) | Yes: the default is `False`, but `startproject` writes `DEBUG = True` |
| Flask 3.1 / Werkzeug 3.1 | `flask run --debug`, `app.run(debug=True)` | The Werkzeug debugger: traceback plus an in-browser Python console behind a PIN (tested) | Yes, but `flask run` is the development server either way |
| Spring Boot 4.1 | Actuator exposure; `spring.web.error.include-*` | Environment, configuration and dumps through Actuator, and a `trace` field once `spring.web.error.include-stacktrace=always` is set (tested) | Yes: only `health` is exposed, and error bodies omit traces |
| ASP.NET Core 10 | Environment name `Development` | The developer exception page: stack trace, query, cookies and headers (tested) | Yes: no environment means Production |
| Express 5 | `NODE_ENV` | The built-in handler writes `err.stack` (tested) | No: anything but `production` sends the stack |
| Next.js 16.4 | `next dev` versus `next start` | The development overlay and full error messages | Yes in `next start`: Server Component errors become a generic message with a `digest` |
| Laravel 13 | `APP_DEBUG` | The detailed error page, which can expose configuration values | Yes: `config/app.php` defaults to `false`, but `.env` files often set it |
| Rails 8.1 | `config.consider_all_requests_local` | Detailed exception pages and `/rails/info/properties` | Yes: the generated production.rb sets `false` |
| PHP 8.5 | `display_errors` | Warnings and errors, with paths, in the response | No: the built-in default is On; php.ini-production sets Off |

## Django and Flask: what the debug pages really show

Django's settings reference says never to deploy with `DEBUG` on. Its debug page hides settings whose names contain `API`, `KEY`, `PASS`, `SECRET`, `SIGNATURE` or `TOKEN`, and our test confirmed a `DATABASE_PASSWORD` setting is masked, but everything else is shown: an internal payments URL and the database address in the exception message both appear. With `DEBUG = False` you must set `ALLOWED_HOSTS`, or every request gets a 400.

Flask's documentation is blunter: do not run the development server or enable the built-in debugger in production, because the debugger executes arbitrary Python from the browser and its PIN should not be relied on. Since Werkzeug 3.0.3, released after CVE-2024-34069, the debugger enables its console only for trusted hosts (`localhost` and `127.0.0.1` by default), but the tests show that the traceback, with the exception message, still renders for any host name. In production Flask returns a plain 500 page. We checked each of these with the frameworks' test clients, and all five checks passed with Django 6.1.2, Flask 3.1.3 and Werkzeug 3.1.9: a request that raises returns a 500 whose body contains the traceback and the exception message with debug on, and a generic page with it off.

## Spring Boot: expose health, protect the rest

Actuator is the most common source of serious leaks in Spring applications, because one property opens all of it. Spring Boot 4.1 exposes only `health` over HTTP by default, and its documentation asks you to check that endpoints contain no sensitive information, or are behind a firewall or Spring Security, before changing `management.endpoints.web.exposure.include`. If Spring Security is present and you define no `SecurityFilterChain`, every endpoint but `health` requires authentication; a custom chain switches that protection off, so add rules for `EndpointRequest.toAnyEndpoint()` yourself.

We ran a Spring Boot 4.1.1 application three ways; the table shows what each returned. `env` and `configprops` mask values with `******` by default, but `management.endpoint.env.show-values=always` printed `spring.datasource.password` in clear. `heapdump` stayed at 404 even with `include=*`, because its access is restricted by default; adding `management.endpoint.heapdump.access=unrestricted` returned a 33 MB heap dump, which contains whatever secrets and sessions were in memory. Keep exposure at `health`, move the rest to `management.server.port` on an internal interface, and require a role for it.

```ini
# application.properties (production)
# Only health over HTTP; everything else on an internal port behind a role.
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=when-authorized
management.server.port=9001
management.server.address=127.0.0.1

# Never in production:
# management.endpoints.web.exposure.include=*
# management.endpoint.env.show-values=always
# management.endpoint.heapdump.access=unrestricted
```

| Request | Defaults | All endpoints exposed | All exposed, env values shown |
| --- | --- | --- | --- |
| `/actuator/health` | 200 | 200 | 200 |
| `/actuator/env` | 404 | 200, values `******` | 200, password in clear |
| `/actuator/configprops` | 404 | 200, values `******` | 200 |
| `/actuator/threaddump` | 404 | 200 | 200 |
| `/actuator/heapdump` | 404 | 404 (access restricted) | 404; 200 only with `heapdump.access=unrestricted` |
| `/boom` (exception) | 500 JSON without trace | 500 JSON without trace | 500 JSON without trace |

## ASP.NET Core: the environment decides

Apps built with `WebApplication.CreateBuilder` turn the developer exception page on automatically in the Development environment; Microsoft's documentation lists what it can show (stack trace, query string, cookies, headers and endpoint metadata) and says never to enable it outside Development. The environment comes from `ASPNETCORE_ENVIRONMENT` or `DOTNET_ENVIRONMENT`, and falls back to Production when neither is set, so the risk is a container image or launch profile that sets `Development`. In production, `UseExceptionHandler()` with `AddProblemDetails()` returns an RFC 9457-style body with a trace ID. Kestrel also sends `Server: Kestrel` until you set `AddServerHeader = false`. We ran the same minimal API both ways on .NET 10: in Development the 500 response contained the exception message and stack trace; in Production with `UseExceptionHandler()` it was a ProblemDetails body with only a `traceId`, and `AddServerHeader = false` removed `Server: Kestrel`.

## Express: set NODE_ENV and own the error handler

Express's built-in error handler writes `err.stack` to the client unless `NODE_ENV` is `production`, and Express sends `X-Powered-By: Express` until you disable it. Do both, and add a final error middleware so the response no longer depends on an environment variable at all: log the error with a reference ID and return only the ID. The tests start each variant in a child process, because Express reads `NODE_ENV` when the application is created; `node --test` passes all three on Express 5.3.0.

```js
import express from 'express';

export function createApp() {
  const app = express();
  app.disable('x-powered-by');
  app.get('/boom', () => {
    throw new Error('database at 10.0.4.12 refused connection');
  });
  // Production error handler: log the detail, return a generic body with a reference.
  app.use((err, req, res, next) => {
    if (res.headersSent) return next(err);
    const ref = crypto.randomUUID();
    console.error(ref, err);
    res.status(500).json({error: 'Internal error', ref});
  });
  return app;
}
```

## Next.js, Laravel, Rails and PHP: the settings to pin

Next.js shows its development overlay only under `next dev`. In a production build, the `error` object passed to an error boundary carries, for errors thrown in Server Components, a generic message and a `digest` you can match in server logs, which the documentation says is to avoid leaking sensitive details. Browser source maps are off in production builds unless you set `productionBrowserSourceMaps: true`, and `poweredByHeader: false` removes `X-Powered-By: Next.js`.

Laravel's documentation says `APP_DEBUG` should always be `false` in production, because the detailed error page can expose configuration values. The cost of getting it wrong was shown by CVE-2021-3129: NVD describes Ignition before 2.5.2, the debug error page package, as allowing unauthenticated remote code execution on sites using debug mode with Laravel before 8.4.2. Rails sets `consider_all_requests_local` to `false` in the generated production.rb; it controls both detailed exception pages and `/rails/info/properties`. PHP itself displays errors when no php.ini is loaded, and the official Docker images ship php.ini-development and php.ini-production without enabling either, so copy php.ini-production into place and also turn off `expose_php`, which that file leaves on. These settings were taken from the vendors' documentation and were not run for this guide.

```text
// next.config.mjs
export default {
  poweredByHeader: false,
  productionBrowserSourceMaps: false, // the default; keep it explicit
};

# .env (Laravel, production)
APP_ENV=production
APP_DEBUG=false

# config/environments/production.rb (Rails)
config.consider_all_requests_local = false

; php.ini (start from php.ini-production)
display_errors = Off
display_startup_errors = Off
log_errors = On
expose_php = Off
```

## Deploy a build, not a working tree

ASVS 5.0 V13.4.1 asks that the application be deployed without source control metadata such as `.git` or `.svn` folders, or with them inaccessible, and V13.4.7 that the web tier serve only files with specific extensions. Both are easiest to meet by construction: build into a clean output directory, copy only that into the image or bucket, and never deploy with `rsync` or `git pull` into the web root. A readable `/.git/HEAD` usually means the whole repository, history included, can be downloaded object by object.

Then make the server enforce the same rule, so a mistake in the build is caught at the edge. The nginx configuration below denies dotfiles except `/.well-known/`, serves only allowlisted extensions, keeps `autoindex` off (the default) and sends `Server: nginx` without a version. We ran it in `nginx:1.30.5` over a web root that still contained `.git/HEAD`, `.env` and `app.js.map`: all three returned 404, `/static/` returned 404 instead of a listing, `/.well-known/security.txt` returned 200, and the probe in the pipeline section found nothing.

Source maps deserve a decision rather than a default. webpack's production mode emits none unless `devtool` is set, Vite's `build.sourcemap` defaults to `false` and Next.js disables browser source maps in production builds. If you need them for error reporting, generate them as hidden maps (Vite's `'hidden'` option suppresses the `sourceMappingURL` comment), upload them to the error tracker and do not deploy them; and never put secrets in front-end code, maps or not. Directory listings (CWE-548, ASVS V13.4.3) come from `autoindex`, Apache's `Options Indexes`, IIS directory browsing or static middleware such as `serve-index`; turn them off everywhere.

```nginx
server {
    listen 8080;
    root /srv/www;
    server_tokens off;            # "Server: nginx" without the version
    autoindex off;                # the default; stating it records the decision

    # Never serve dotfiles (.git, .env, .svn); /.well-known/ stays reachable for security.txt and ACME.
    location ~ /\.(?!well-known/) {
        return 404;
    }

    # Serve only the file types the site publishes; .map, .bak, .sql, .zip and the rest get 404.
    location / {
        location ~* \.(?:html|css|js|png|svg|woff2|txt)$ { try_files $uri =404; }
        location = / { try_files /index.html =404; }
        return 404;
    }

    error_page 404 /404.html;
    location = /404.html { internal; }
}
```

## Remove version banners, TRACE and diagnostic endpoints

ASVS 5.0 V13.4.6 (level 3) asks that detailed version information of backend components not be exposed. Version banners don't make software vulnerable, but they turn reconnaissance into a lookup, so remove the ones that cost nothing: `X-Powered-By` from Express, Next.js and PHP, `Server: Kestrel`, nginx's version with `server_tokens off`, and `X-AspNet-Version` on .NET Framework. V13.4.4 asks that the HTTP TRACE method not be supported in production; nginx answers it with 405 out of the box.

V13.4.5 asks that documentation and monitoring endpoints not be exposed unless intended. Inventory them per framework: Actuator; Go's `net/http/pprof`, which registers `/debug/pprof/` handlers on `http.DefaultServeMux` as a side effect of being imported, so serve your application on its own mux and pprof on a localhost-only listener; Swagger UI and OpenAPI documents for internal APIs; `phpinfo()` pages; Apache's `/server-status`; and Rails' `/rails/info`. Where an endpoint must stay, put it behind authentication and an internal network, and log its use.

## Delete sample apps and default accounts

Installers and base images ship things meant for evaluation. Tomcat's security considerations say its examples web application should always be removed from any security-sensitive installation, and that the Manager application, which allows remote deployment, is frequently targeted because of weak passwords and public instances; it is not accessible by default because no users are configured. The same applies to admin consoles with a default login, database tools such as phpMyAdmin left in a container, setup wizards that stay reachable after setup and test pages.

Default credentials are CWE-1392 (OWASP A07:2025). Remove or rename default accounts, force a credential change at first start, and make the deployment fail if a known default still works. Build images from minimal bases so that sample content never arrives, as /domains/runtime/containers/container-image-hardening/ describes, and keep secrets out of images and front-end bundles altogether (/domains/identity-crypto/secrets/secrets-lifecycle/).

## Check configuration in CI and probe every deployment

Make production configuration code and test it like code: one hardened configuration shared by staging and production, with only credentials and endpoints differing, and a CI step that fails when it drifts. Django's `manage.py check --deploy` reports `DEBUG`, cookie and HSTS settings; a unit test can assert `ASPNETCORE_ENVIRONMENT`, `NODE_ENV` or `APP_DEBUG` in the deployment manifest; and a policy check can reject an image whose web root contains `.git`.

Configuration review misses what the edge adds, so finish with an outside-in probe after each deployment. The script below uses only the Python standard library, sends about 20 requests and maps each finding to an ASVS 5.0 requirement. Run it against deployments you own: `python3 probe.py https://staging.example.com`. Its tests start a deliberately misconfigured local server, which yields eight findings, and a hardened one, which yields none; both pass on Python 3.14.8. It is a smoke test, not a scanner: add your framework's own endpoints, and use a DAST tool for depth (/domains/appsec/testing/sast-dast-workflow/).

```python
"""Probe a deployment you own for common information-disclosure misconfigurations.

Usage: python3 probe.py https://staging.example.com
Exits 1 if it finds anything. Standard library only; sends about 20 requests.
"""
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid

LEAK_MARKERS = [
    "Traceback (most recent call last)", "Werkzeug Debugger", "DisallowedHost", "settings.py",
    "Whoops", "Ignition", "Stack trace:", "   at ", "java.lang.", "Exception in thread",
    "node_modules/", "Microsoft.AspNetCore", "Action Controller: Exception caught",
]
LISTING_MARKERS = ["Index of /", "Directory listing for", "<title>Directory Listing"]


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None


OPENER = urllib.request.build_opener(NoRedirect)


def fetch(base, path, method="GET", limit=65536):
    req = urllib.request.Request(urllib.parse.urljoin(base, path), method=method,
                                 headers={"User-Agent": "deployment-probe/1"})
    try:
        with OPENER.open(req, timeout=10) as r:
            return r.status, dict(r.headers), r.read(limit).decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, dict(e.headers), e.read(limit).decode("utf-8", "replace")
    except (urllib.error.URLError, TimeoutError) as e:
        return 0, {}, str(e)


def probe(base):
    findings = []

    def add(asvs, what):
        findings.append(f"{asvs}: {what}")

    status, _, body = fetch(base, "/.git/HEAD")
    if status == 200 and (body.startswith("ref:") or re.fullmatch(r"[0-9a-f]{40}\s*", body)):
        add("V13.4.1", "/.git/HEAD is served; the repository can be downloaded")
    for path in ("/.env", "/.svn/entries", "/.DS_Store", "/config.php.bak", "/web.config.bak"):
        status, _, body = fetch(base, path)
        if status == 200 and body.strip():
            add("V13.4.7", f"{path} is served")

    status, headers, html = fetch(base, "/")
    for src in re.findall(r'<script[^>]+src="([^"]+\.js)"', html)[:5]:
        s, h, js = fetch(base, src)
        mapped = h.get("SourceMap") or h.get("X-SourceMap")
        comment = re.search(r"//# sourceMappingURL=(\S+)", js)
        map_url = mapped or (comment and comment.group(1)) or src + ".map"
        if not map_url.startswith("data:"):
            ms, _, mbody = fetch(base, urllib.parse.urljoin(src, map_url))
            if ms == 200 and '"mappings"' in mbody:
                add("V13.4.7", f"source map served for {src}")

    for path in ("/actuator", "/actuator/env", "/actuator/heapdump", "/debug/pprof/", "/swagger-ui/index.html",
                 "/phpinfo.php", "/server-status", "/rails/info/properties", "/elmah.axd"):
        status, _, body = fetch(base, path, limit=4096)
        if status == 200:
            add("V13.4.5", f"{path} answers 200")

    for path in ("/static/", "/assets/", "/uploads/", "/images/"):
        status, _, body = fetch(base, path)
        if status == 200 and any(m in body for m in LISTING_MARKERS):
            add("V13.4.3", f"directory listing at {path}")

    for path in (f"/{uuid.uuid4().hex}", f"/{uuid.uuid4().hex}?id=%27%22%3C%00"):
        status, _, body = fetch(base, path)
        hits = [m for m in LEAK_MARKERS if m in body]
        if hits:
            add("V16.5.1", f"error page for {path[:12]}... contains {hits[:3]}")

    status, _, body = fetch(base, "/", method="TRACE")
    if status == 200 and "TRACE / HTTP" in body:
        add("V13.4.4", "TRACE is enabled and echoes the request")

    for name in ("Server", "X-Powered-By", "X-AspNet-Version", "X-AspNetMvc-Version"):
        value = headers.get(name, "")
        if value and (name != "Server" or re.search(r"\d+\.\d+", value)):
            add("V13.4.6", f"{name}: {value}")

    return findings


if __name__ == "__main__":
    results = probe(sys.argv[1].rstrip("/") + "/")
    for line in results:
        print(line)
    print(f"{len(results)} finding(s)")
    sys.exit(1 if results else 0)
```

## ASVS 5.0 V13.4 requirement by requirement

ASVS 5.0 section V13.4, Unintended Information Leakage, has seven requirements, and V16.5.1 adds the error-message rule. The table maps each to the control in this guide and the check that proves it.

| Requirement | Level | Control | Check |
| --- | --- | --- | --- |
| V13.4.1 No source control metadata | 1 | Deploy a clean build; deny dotfiles at the server | `/.git/HEAD` and `/.svn/entries` return 404 |
| V13.4.2 Debug modes disabled | 2 | The production switch per framework | A forced error returns a generic page; CI asserts the setting |
| V13.4.3 No directory listings | 2 | `autoindex off`, no listing middleware | Folder URLs return 403 or 404, never an index |
| V13.4.4 HTTP TRACE not supported | 2 | Server and framework defaults | `curl -X TRACE` returns 405 or 501 |
| V13.4.5 Docs and monitoring endpoints not exposed unless intended | 2 | Actuator `health` only; pprof, Swagger and status pages internal | The probe's endpoint list returns 401, 403 or 404 |
| V13.4.6 No detailed backend versions | 3 | `server_tokens off`, no `X-Powered-By`, no `Server: Kestrel` | Response headers carry no version numbers |
| V13.4.7 Serve only allowlisted file extensions | 3 | Extension allowlist at the web server | `.map`, `.bak`, `.env` and `.sql` return 404 |
| V16.5.1 Generic message for unexpected errors | 2 | Final error handler with a reference ID | Error bodies contain no stack trace, query or secret |

## Pitfalls



- Setting debug off in the application but leaving the proxy's or CDN's own error pages, which show their version and sometimes the upstream response.
- An `.env` file copied into the image "for convenience", so that the web server, a backup or a later `docker save` exposes it.
- Turning off the debug page while the logs still record full request bodies and tokens (/domains/runtime/detection/security-event-logging/).
- A custom Spring Security chain that permits `/actuator/**` because a health check needed it; permit `/actuator/health` only.
- Removing version headers and calling the work done: patching is the control, banners only slow reconnaissance.
- Caching an error page that contains one user's data at a shared cache (/domains/runtime/edge-defense/cache-poisoning-deception/).
- Debug endpoints guarded only by an IP allowlist that a trusted proxy header can spoof.

## Guided practice

The release checklist for `shop.example.test` requires a clean probe of staging. The results below came back. Rank the findings, explain what each exposes, and give the fix and the ASVS 5.0 requirement for each.

### Supplied material

```text
$ python3 probe.py https://staging.shop.example.test/
V13.4.1: /.git/HEAD is served; the repository can be downloaded
V13.4.7: /.env is served
V13.4.7: source map served for /static/app.3f9c1.js
V13.4.5: /actuator answers 200
V13.4.5: /actuator/env answers 200
V13.4.3: directory listing at /uploads/
V16.5.1: error page for /9b2e41d0c7a... contains ['Traceback (most recent call last)']
V13.4.4: TRACE is enabled and echoes the request
V13.4.6: Server: Apache/2.4.58 (Ubuntu)
9 finding(s)
/.env contains DB_URL with a database password; /actuator/env masks values as ******.

Notes: the front end is served by Apache from /var/www/shop, deployed with "git pull";
the API is Spring Boot 4.1 behind the same Apache; a Python image service handles /images.
```

### 1. Rank the findings

Order the nine findings by what an attacker gains, and say which need action before anything else.

Worked answer: First `.env` (a live password: rotate it now) and `.git/HEAD` (source and history). Next `/actuator/env` (masked, one property from clear text) and the traceback. Then the source map and the `/uploads/` listing; TRACE and the version banner last.

### 2. Fix the deployment method

Explain why `git pull` into `/var/www/shop` causes two findings, and replace it.

Worked answer: The working tree, with `.git` and `.env`, is the web root. Deploy a clean build directory (V13.4.1, V13.4.7) and make the server refuse dotfiles, for example `RedirectMatch 404 /\.(?!well-known/)`.

### 3. Close Actuator

Write the Spring Boot properties that fix `/actuator` and `/actuator/env`.

Worked answer: `management.endpoints.web.exposure.include=health`, with any other endpoints on `management.server.port` bound to an internal address and protected by a role; leave `show-values` at its default (V13.4.5).

### 4. Find the traceback's source

The traceback is Python. Decide which component produced it and what to change.

Worked answer: The Python image service runs with debug on (for Flask, `--debug` or `debug=True`). Serve it with a production WSGI server and an error handler that returns a reference ID (V13.4.2, V16.5.1).

### 5. Harden Apache

Give the Apache directives for the listing, TRACE and version findings, and decide what to do with the source map.

Worked answer: `Options -Indexes` (V13.4.3), `TraceEnable Off` (V13.4.4), `ServerTokens Prod` and `ServerSignature Off` (V13.4.6). Upload hidden source maps to the error tracker instead of deploying them (V13.4.7).

### 6. Run the probe yourself

Create a folder with `.git/HEAD`, an `index.html` that loads `/static/app.js`, that file ending in a `sourceMappingURL` comment, and its `.map`. Serve the folder with `python3 -m http.server 8000 --bind 127.0.0.1` and run `python3 probe.py http://127.0.0.1:8000/`.

Worked answer: Four findings: the `.git` folder (V13.4.1), the source map (V13.4.7), the directory listing at `/static/` (V13.4.3) and `Server: SimpleHTTP/0.6 Python/3.14.8` (V13.4.6). `http.server` is a development server, and this is what serving a working tree with it looks like.

Deliverable: A ranked table of the nine findings with the exposure, the fix and the ASVS 5.0 requirement, plus the corrected deployment steps.

Scope: Run the probe only against systems you own or are authorized to test. The fixture is fictional; the final step runs locally.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP Top 10:2025 A02 Security Misconfiguration](https://top10.owasp.org/2025/A02_2025-Security_Misconfiguration/). OWASP Top 10:2025.
- [OWASP ASVS 5.0.0: V13 Configuration (V13.4 Unintended Information Leakage)](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x22-V13-Configuration.md). Version 5.0.0.
- [OWASP WSTG: Configuration and Deployment Management Testing](https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/). Version 4.2.
- [Django settings reference: DEBUG and ALLOWED_HOSTS](https://docs.djangoproject.com/en/6.1/ref/settings/#debug). Django 6.1.
- [Werkzeug documentation: Debugging applications](https://werkzeug.palletsprojects.com/en/stable/debug/). Werkzeug 3.1.x.
- [Spring Boot reference: Actuator endpoints](https://docs.spring.io/spring-boot/reference/actuator/endpoints.html). Spring Boot 4.1.1.
- [Microsoft Learn: Handle errors in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-10.0). ASP.NET Core 10.0.
- [Express guide: Error handling](https://expressjs.com/en/guide/error-handling.html). Express 5.x.
- [Next.js documentation: error.js (error.message and error.digest)](https://nextjs.org/docs/app/api-reference/file-conventions/error). Next.js 16.4.0.