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.

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.
Applies to
  • Django 6.1 (6.1.2)
  • Flask and Werkzeug Flask 3.1.3, Werkzeug 3.1.9
  • Spring Boot 4.1 (4.1.1)
  • ASP.NET Core 10.0
  • Express 5.x (5.3.0)
  • Next.js 16.4
  • Laravel 13.x
  • Ruby on Rails 8.1
  • nginx 1.30 stable

Web applications and APIs built on common frameworks and served through a web server, reverse proxy or CDN. Cloud account, Kubernetes and container misconfiguration have their own guides.

Part of
Learning path: AppSec in practice (step 18 of 18). Workflows: Fix a finding, Test an application
Learning goals and prerequisitesAbout 60 minutes: 25 to read, 10 to run the probe and 25 for the deployment exercise

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.

Before you start

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

  • 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.

Refresh first: Handle failures without leaking internals or weakening controls

Go to the guided practice

Where a deployment leaks

Your deploymentProbe or attackerWeb server or CDNApplicationGET /.git/HEAD, /.env,/app.js.map1200 from the build directory2GET /orders?id='3forwarded request3500 with traceback, or generic + ref ID4Trust boundaryUntrusted input1Step, described below
  1. A probe asks for files nobody linkedRequests for /.git/HEAD, /.env and app.js.map reach the web server or CDN, which serves anything in the deployed directory.
  2. The edge answers from the build directoryIf the build copied the repository or emitted source maps, the edge returns them, and the application never sees the request.
  3. A malformed request reaches the applicationAn unexpected parameter makes a handler throw.
  4. Debug mode answers with internalsWith debug on, the response carries the stack trace, settings, SQL or an interactive console; in production mode it is a generic page with a reference ID.
File leaks happen at the edge and debug leaks in the application, so check both: the build and server configuration, and each framework's production switch.

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/.

Misconfiguration is the cause, disclosure is the effect
LeakTypical causeWhat it gives an attackerCWE
Debug error page or consoleDebug mode or development environment in productionSource paths, settings, SQL, and with an interactive console, code executionCWE-489, CWE-215, CWE-209
Monitoring and admin endpointsActuator include=*, pprof imported, Swagger UI or status pages publicEnvironment variables, configuration, heap and thread dumpsCWE-200
Repository and build filesThe deploy copies the working tree; .git, .env, backups or *.map in the web rootSource code, history, secrets and internal hostnamesCWE-527, CWE-538
Directory listingAuto-indexing on, or a static file middleware that lists foldersEvery file name, including ones never linkedCWE-548
Version banners and TRACEDefault Server, X-Powered-By and TRACE handlingExact versions to match against advisories, echoed headersCWE-200
Default accounts and sample appsInstaller defaults, examples left deployedA login or a known-vulnerable pageCWE-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.

Turn off each framework's debug output
FrameworkSwitchWhen on, a failing request showsSafe if unset?
Django 6.1DEBUGA 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.1flask 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.1Actuator 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 10Environment name DevelopmentThe developer exception page: stack trace, query, cookies and headers (tested)Yes: no environment means Production
Express 5NODE_ENVThe built-in handler writes err.stack (tested)No: anything but production sends the stack
Next.js 16.4next dev versus next startThe development overlay and full error messagesYes in next start: Server Component errors become a generic message with a digest
Laravel 13APP_DEBUGThe detailed error page, which can expose configuration valuesYes: config/app.php defaults to false, but .env files often set it
Rails 8.1config.consider_all_requests_localDetailed exception pages and /rails/info/propertiesYes: the generated production.rb sets false
PHP 8.5display_errorsWarnings and errors, with paths, in the responseNo: 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.

Spring Boot: expose health, protect the rest
RequestDefaultsAll endpoints exposedAll exposed, env values shown
/actuator/health200200200
/actuator/env404200, values ******200, password in clear
/actuator/configprops404200, values ******200
/actuator/threaddump404200200
/actuator/heapdump404404 (access restricted)404; 200 only with heapdump.access=unrestricted
/boom (exception)500 JSON without trace500 JSON without trace500 JSON without trace
Configurationapplication.propertiesINI
# 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

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.

Fixedapp.jsJavaScript
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.

ConfigurationProduction settingsPlain 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.

Configurationsite.confnginx
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/).

Testprobe.pyPython
"""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.

ASVS 5.0 V13.4 requirement by requirement
RequirementLevelControlCheck
V13.4.1 No source control metadata1Deploy a clean build; deny dotfiles at the server/.git/HEAD and /.svn/entries return 404
V13.4.2 Debug modes disabled2The production switch per frameworkA forced error returns a generic page; CI asserts the setting
V13.4.3 No directory listings2autoindex off, no listing middlewareFolder URLs return 403 or 404, never an index
V13.4.4 HTTP TRACE not supported2Server and framework defaultscurl -X TRACE returns 405 or 501
V13.4.5 Docs and monitoring endpoints not exposed unless intended2Actuator health only; pprof, Swagger and status pages internalThe probe's endpoint list returns 401, 403 or 404
V13.4.6 No detailed backend versions3server_tokens off, no X-Powered-By, no Server: KestrelResponse headers carry no version numbers
V13.4.7 Serve only allowlisted file extensions3Extension allowlist at the web server.map, .bak, .env and .sql return 404
V16.5.1 Generic message for unexpected errors2Final error handler with a reference IDError 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 scenario material
Plain 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. 1. Rank the findings

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

    Expected result and reasoning

    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. 2. Fix the deployment method

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

    Expected result and reasoning

    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. 3. Close Actuator

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

    Expected result and reasoning

    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. 4. Find the traceback's source

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

    Expected result and reasoning

    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. 5. Harden Apache

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

    Expected result and reasoning

    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. 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/.

    Expected result and reasoning

    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.

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

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

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Top 10:2025 A02 Security Misconfiguration. OWASP Top 10:2025.
  2. OWASP ASVS 5.0.0: V13 Configuration (V13.4 Unintended Information Leakage). Version 5.0.0.
  3. OWASP WSTG: Configuration and Deployment Management Testing. Version 4.2.
  4. Django settings reference: DEBUG and ALLOWED_HOSTS. Django 6.1.
  5. Werkzeug documentation: Debugging applications. Werkzeug 3.1.x.
  6. Spring Boot reference: Actuator endpoints. Spring Boot 4.1.1.
  7. Microsoft Learn: Handle errors in ASP.NET Core. ASP.NET Core 10.0.
  8. Express guide: Error handling. Express 5.x.
  9. Next.js documentation: error.js (error.message and error.digest). Next.js 16.4.0.

Reference information

Run on Python 3.14.8 with Django 6.1.2, Flask 3.1.3 and Werkzeug 3.1.9; Node.js 26.11.1 with Express 5.3.0; .NET SDK 10.0.112; OpenJDK 21.0.12 with Spring Boot 4.1.1; nginx 1.30.5 in Docker. Next.js 16.4, Laravel 13, Rails 8.1 and PHP 8.5 settings are from their documentation and were not run.

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-16.5.1
OWASP WSTG v4.2 tests
WSTG-CONF-02 · WSTG-CONF-04 · WSTG-CONF-05 · WSTG-ERRH-01

Download this guide as Markdown