Information disclosure is any response that tells a stranger something the application never meant to publish: a stack trace with file paths and SQL, a Server header with an exact version, a .git directory in the web root, a configuration backup, a private IP address in an error. OWASP Top 10:2025 maps CWE-200, Exposure of Sensitive Information to an Unauthorized Actor, to A01 Broken Access Control, and CWE-209, sensitive error messages, to A10 Mishandling of Exceptional Conditions.
Most leaks are reconnaissance: they turn guessing into a lookup and tell an attacker which bug to try first. Some skip that step, because leaked source code, credentials and signing keys are the breach.
The mental model: diagnostics meant for you, read by everyone
| Leak |
Typical source |
What it tells an attacker |
| Stack trace |
Unhandled exception rendered to the client |
Framework, file layout, query text, library versions |
| Version banner |
Server, X-Powered-By, generator meta tag |
Which published vulnerabilities to try |
/.git/ directory |
Repository checked out into the web root |
Source code and its full history |
.env, config.php.bak, dump.sql |
Deployment and editing leftovers |
Credentials, keys, database contents |
| Private IP or internal hostname |
Error text, comments, redirects |
Network layout and SSRF targets |
| Hard-coded key |
Inline configuration in a page |
Direct access to a third-party API |
The rule behind the table: anything an unauthenticated client can request is published. A response is not private because nothing links to it.
How it happens
Three habits cause almost every case.
Debug settings reach production. ASPNETCORE_ENVIRONMENT=Development, Django's DEBUG = True, PHP's display_errors = On and Express without NODE_ENV=production all send exception details to the browser. Hand-written handlers do it on purpose, because a trace is convenient during development:
import traceback
@app.errorhandler(Exception)
def handle_error(exc):
return {"error": str(exc), "trace": traceback.format_exc()}, 500
The deployment is a copy of the working directory. A git pull into the document root brings .git/ along. Editors leave config.php~ and .swp files, someone saves config.php.bak before an edit, a database dump lands next to index.php, and the web server serves it all.
Defaults announce themselves. Servers and runtimes put names and versions in Server and X-Powered-By, CMS platforms add a generator meta tag, and session cookies such as JSESSIONID, PHPSESSID and laravel_session name the stack.
A concrete walk-through: one error, one file, one map
An authorized tester starts with a value the application does not expect, such as a non-numeric order ID:
GET /orders/abc HTTP/1.1
Host: shop.example
With the handler above, the reply reads like documentation:
HTTP/1.1 500 Internal Server Error
Server: nginx/1.24.0
Content-Type: application/json
{"error": "invalid input syntax for type integer: \"abc\"",
"trace": "Traceback (most recent call last):\n File \"/srv/shop/releases/2026-09-30/app/orders.py\", line 41, in show_order\n cur.execute(\"SELECT id, total FROM orders WHERE id = %s\", (order_id,))\n..."}
One response reveals the proxy and its version, Python with PostgreSQL, the deployment path, a table and its columns, and that this query is parameterized. The dated release directory hints at a checkout-based deployment, so the tester asks for one more file:
GET /.git/HEAD HTTP/1.1
Host: shop.example
HTTP/1.1 200 OK
Content-Type: application/octet-stream
ref: refs/heads/main
That line proves the repository is served. An authorized assessment stops and reports it: the objects behind it rebuild the source, including deleted files and every committed secret. The same one-request proof works for /.env, where a variable name is evidence enough, and for a .bak copy of a script, served as text instead of executed.
What an attacker gains
- A banner or trace becomes a CVE lookup. An exact version maps straight to published advisories; see vulnerable components.
- An absolute path aims file attacks. Knowing the code lives in
/srv/shop/releases/ turns a blind path traversal guess into a precise read.
- Query text shapes injection. Errors that echo SQL feed error-based SQL injection.
- Private addresses become targets. A
10.0.4.17 in an error names a host for SSRF.
- Source and configuration hand over keys. A
.env file or repository history yields passwords, API keys and signing secrets, enough to forge tokens or sign in directly.
- Email addresses seed phishing and supply usernames for password spraying.
A realistic chain needs no exploit: the banner names the framework, .git yields the source, history yields a signing key, and the key mints an administrator session.
Reading a SelfSec finding
The checks are passive and run on every crawled URL, so one leak in a shared template becomes many findings that one fix clears.
- Each check reports its first match per URL. A public support address in the footer is a low finding on every page; keeping it is a decision, not a bug.
- The stack-trace check matches the words stack trace and traceback, Java frames such as
at com.example.Shop.load(Shop.java:42) and Python File "…", line N frames. Test other runtimes' error paths by hand.
- The secret check looks for assignments such as
apiKey: "…" or secret_key = '…' with a quoted value of 16 or more letters and digits. A quoted JSON key such as "apiKey": "…" does not match. Keys in bundles and source maps belong to the Client-Side Secret Scanner; see exposed secrets in JavaScript.
- The mixed-content check flags
src, href and action attributes pointing at http://, plain links included; see TLS misconfiguration.
- Technology Fingerprint findings are informational; the evidence is the technology name or generator string. A
Server header with a version, such as nginx/1.24.0, and any X-Powered-By header are also low findings from the security headers check.
- The crawl requests leftovers such as
/.git/config, /.env and .bak copies of discovered files, and those that answer join the scanned surface. Review them even when no pattern fires.
- Development servers and framework debug pages have their own module; see debug mode exposure.
The fix: keep diagnostics on the server
Fix the error path first: log everything, return a status, a generic message and an incident ID. In Flask:
import logging
import uuid
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
log = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_error(exc):
if isinstance(exc, HTTPException):
return exc
incident = uuid.uuid4().hex
log.error("Unhandled error, incident %s", incident, exc_info=exc)
return jsonify(error="Internal server error", incident=incident), 500
HTTPException passes through so 404 and 405 behave normally; everything else is logged with its traceback and answered with a fixed body. Leave FLASK_DEBUG unset in production.
In ASP.NET Core:
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
The handler logs the exception and returns a problem details body with the status, a generic title and a traceId; AddServerHeader = false drops Server: Kestrel. Never run a reachable host as Development, which turns on the developer exception page. In PHP, php.ini logs instead of displaying, and expose_php = Off removes X-Powered-By:
display_errors = Off
log_errors = On
expose_php = Off
Next, publish only what you meant to. Deploy a build artifact, such as CI output, a container image or git archive, not a checkout; keep secrets in environment variables or a secret store; serve the public directory alone. Then add an nginx backstop ahead of other regex locations:
server_tokens off;
location ~ /\.(?!well-known/) {
return 404;
}
location ~* (?:\.(?:bak|old|orig|save|swp|sql|dump)|~)$ {
return 404;
}
A 404, unlike a 403, does not confirm the file exists. If anything sensitive was ever reachable, rotate it: deletion does not recall downloaded copies.
Fixes that do not hold
- A friendly error page for HTML only: the JSON API or a proxy error page can still print the trace.
- Blocking
/.git/ on one host: another virtual host or the CDN origin may serve it. Remove it from disk.
- Deleting a committed secret: it stays in history and every clone; rotate it.
- Listing private paths in
robots.txt: Disallow: /backup/ advertises the directory.
- Hiding the banner instead of patching: behavior still fingerprints the stack.
- Moving the trace into an HTML comment: it is still in the response.
A developer review checklist
- Trigger errors with malformed IDs, wrong types and unsupported methods, and read every response.
- Confirm production flags:
ASPNETCORE_ENVIRONMENT, DEBUG, FLASK_DEBUG, NODE_ENV=production and display_errors.
- Deploy build output only; the document root holds no
.git, .env, *.bak, editor files or dumps.
- Keep secrets out of the web root and client code; rotate anything that leaked.
- Remove version banners:
server_tokens off, expose_php = Off, disabled X-Powered-By, no Server: Kestrel.
- Search responses for private IPs, internal hostnames, absolute paths and HTML comments.
- Return an incident ID to the client and keep the details in access-controlled logs.
References