Skip to main content
Web Security (OWASP Top 10, fix-side) · lesson 4 of 6

Also part of: Ethical Hacking (methodology, lab-only)

Security Misconfiguration: Error Pages, Debug Mode and Security Headers

A Flask error page leaks a stack trace and a database password. Replace it with a generic handler, add security headers, and pin both with pytest.

By SkillByExample · Published

web-security owasp misconfiguration error-handling security-headers flask pytest

Security misconfiguration is in the OWASP Top 10 because many leaks need no clever attack at all, just an error. This lesson takes a Flask route whose error page prints the full traceback, including a database password, and fixes it with a generic error handler and a startup guard against debug mode. It also adds security headers to every response. Every check runs through Flask’s test client in pytest, with no server and no network. To learn the attack side, see PortSwigger’s information disclosure labs.

The lab uses the same environment as the first lesson: Python 3.12, Flask 3.1.3 and pytest 9.1.1.

An error page that says too much

The route fails to reach its database. Someone added a “helpful” error handler during development that prints the traceback, and it shipped.

# app.py
import traceback
from flask import Flask

app = Flask(__name__)
DATABASE_URL = "postgresql://shop:not-a-real-secret@db.internal:5432/shop"


def load_order(order_id):
    raise ConnectionError(f"could not connect to {DATABASE_URL}")


@app.get("/orders/<int:order_id>")
def get_order(order_id):
    return load_order(order_id)


@app.errorhandler(Exception)
def show_error(exc):
    return f"<pre>{traceback.format_exc()}</pre>", 500

A short script asks for an order through the test client and prints the response, skipping the indented source lines of the traceback:

# show.py
from app import app

r = app.test_client().get("/orders/1")
print(r.status_code, r.content_type)
for line in r.text.splitlines():
    if not line.startswith(" "):  # skip the indented source lines
        print(line)
print(list(r.headers.keys()))
$ python show.py
500 text/html; charset=utf-8
<pre>Traceback (most recent call last):
ConnectionError: could not connect to postgresql://shop:not-a-real-secret@db.internal:5432/shop
</pre>
['Content-Type', 'Content-Length']

Anyone who can make the database call fail now has the database host, port, user name and password. The skipped lines are worse in their own way: they hold the server’s file paths, the libraries it runs on and the line of code that failed. The response also carries no security headers at all.

Tests that pin the rules

Write down what the error path must do: return 500, leak nothing, carry the headers, and leave a plain 404 alone.

# test_app.py
from app import app

client = app.test_client()


def test_error_page_hides_internals():
    r = client.get("/orders/1")
    assert r.status_code == 500
    leaks = [s for s in ("Traceback", "not-a-real-secret") if s in r.text]
    assert leaks == []


def test_security_headers_on_every_response():
    h = client.get("/orders/1").headers
    assert h.get("X-Content-Type-Options") == "nosniff"
    assert "frame-ancestors 'none'" in h.get("Content-Security-Policy", "")


def test_unknown_url_is_still_404():
    assert client.get("/nope").status_code == 404
$ pytest -q --tb=short
FFF                                                                      [100%]
================================== FAILURES ===================================
_______________________ test_error_page_hides_internals _______________________
test_app.py:11: in test_error_page_hides_internals
    assert leaks == []
E   AssertionError: assert ['Traceback',...-real-secret'] == []
E     
E     Left contains 2 more items, first extra item: 'Traceback'
E     Use -v to get more diff
___________________ test_security_headers_on_every_response ___________________
test_app.py:16: in test_security_headers_on_every_response
    assert h.get("X-Content-Type-Options") == "nosniff"
E   AssertionError: assert None == 'nosniff'
E    +  where None = get('X-Content-Type-Options')
E    +    where get = Headers([('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', '1155')]).get
________________________ test_unknown_url_is_still_404 ________________________
test_app.py:21: in test_unknown_url_is_still_404
    assert client.get("/nope").status_code == 404
E   AssertionError: assert 500 == 404
E    +  where 500 = <WrapperTestResponse streamed [500 INTERNAL SERVER ERROR]>.status_code
E    +    where <WrapperTestResponse streamed [500 INTERNAL SERVER ERROR]> = get('/nope')
E    +      where get = <FlaskClient <Flask 'app'>>.get
=========================== short test summary info ===========================
FAILED test_app.py::test_error_page_hides_internals - AssertionError: assert ...
FAILED test_app.py::test_security_headers_on_every_response - AssertionError:...
FAILED test_app.py::test_unknown_url_is_still_404 - AssertionError: assert 50...
3 failed in 0.15s

The first two failures were expected. The third is a second bug hiding in the same handler: a URL that doesn’t exist returns 500, not 404. Werkzeug signals a missing page by raising NotFound, which is a subclass of Exception, so the catch-all handler swallows it too. (Your Content-Length will differ: the traceback contains your own file paths.)

The fix: log the detail, return a generic error

Replace app.py. The handler passes HTTP errors through with their own status, logs everything else with its traceback, and returns a body that says nothing about the cause. An after_request function adds the headers.

# app.py
from flask import Flask
from werkzeug.exceptions import HTTPException

app = Flask(__name__)
DATABASE_URL = "postgresql://shop:not-a-real-secret@db.internal:5432/shop"


def load_order(order_id):
    raise ConnectionError(f"could not connect to {DATABASE_URL}")


@app.get("/orders/<int:order_id>")
def get_order(order_id):
    return load_order(order_id)


@app.errorhandler(Exception)
def handle_error(exc):
    if isinstance(exc, HTTPException):
        return exc  # 404, 405 ... keep their own status
    app.logger.exception("unhandled error")  # full detail goes to the log
    return {"error": "internal server error"}, 500


@app.after_request
def security_headers(resp):
    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'"
    return resp
$ pytest -q --tb=short
...                                                                      [100%]
3 passed in 0.11s

Run show.py again. The response goes to stdout and the log goes to stderr, so look at each separately:

$ python show.py 2>/dev/null
500 application/json
{"error":"internal server error"}
['Content-Type', 'Content-Length', 'X-Content-Type-Options', 'Content-Security-Policy']
$ python show.py 2>&1 >/dev/null | head -2
[2026-10-14 20:04:46,496] ERROR in app: unhandled error
Traceback (most recent call last):

The client gets a two-word error and the two headers. The traceback still exists, but only in the log, where the people fixing the bug can read it. after_request runs on responses built by error handlers too, which is why the 500 carries the headers. nosniff stops a browser treating a response as a different content type, and frame-ancestors 'none' stops other sites loading the page in a frame to trick clicks.

Debug mode is the other leak

Flask turns debug mode on when the FLASK_DEBUG environment variable is set to 1, and the app can’t tell whether that was deliberate:

# check_debug.py
from app import app

print("debug:", app.debug)
$ python check_debug.py
debug: False
$ FLASK_DEBUG=1 python check_debug.py
debug: True

Under flask run, debug mode also starts the Werkzeug interactive debugger, which lets anyone who sees an error page run Python on the server, behind only a PIN. A leftover FLASK_DEBUG=1 in a production environment file is enough. Make the app refuse to start that way. Add two lines under app = Flask(__name__), plus import os at the top:

if app.debug and os.environ.get("APP_ENV") == "production":
    raise RuntimeError("refusing to start: debug mode is on in production")
$ FLASK_DEBUG=1 APP_ENV=production python check_debug.py 2>&1 | tail -1
RuntimeError: refusing to start: debug mode is on in production
$ APP_ENV=production python check_debug.py
debug: False

A crash at startup is the right outcome. It fails the deploy loudly, instead of serving a debugger to the internet. APP_ENV is this lab’s own variable: use whatever your deployment already sets to mark production.

Practice

  1. With the fixed app, what does POST /orders/1 return? Write a test that checks its status and that it still carries X-Content-Type-Options.
Solution

The route only allows GET, so Werkzeug raises MethodNotAllowed. The handler passes it through as a 405, and after_request adds the headers:

def test_405_keeps_status_and_headers():
    r = client.post("/orders/1")
    assert r.status_code == 405
    assert r.headers["X-Content-Type-Options"] == "nosniff"

It passes. Before the isinstance check existed, this would have been a 500.

  1. Add Referrer-Policy: no-referrer to every response, with a test.
Solution

One more line in security_headers:

    resp.headers["Referrer-Policy"] = "no-referrer"
def test_referrer_policy():
    assert client.get("/orders/1").headers["Referrer-Policy"] == "no-referrer"

The test asks for the failing route on purpose: it proves the header is on error responses, not just on the happy path.

  1. Delete the two isinstance lines from handle_error and run test_app.py. Which test fails?
Solution
FAILED test_app.py::test_unknown_url_is_still_404 - AssertionError: assert 50...
1 failed, 2 passed in 0.11s

Only the 404 test fails. The leak and header tests still pass, which is why the 404 test belongs in the suite: without it, nothing would notice every missing page turning into a 500.

  1. Return HTTP errors as JSON too, so GET /nope gives {"error": "Not Found"} with status 404.
Solution

Every HTTPException has a name and a code, so change the pass-through line:

    if isinstance(exc, HTTPException):
        return {"error": exc.name}, exc.code
def test_404_is_json():
    r = client.get("/nope")
    assert r.status_code == 404
    assert r.json == {"error": "Not Found"}

It passes, and the 405 from task 1 now comes back as {"error": "Method Not Allowed"}.

Frequently Asked Questions

Why is showing a stack trace to users a security problem?
A stack trace reveals file paths, the libraries in use, internal function names and often the values in the failing line, such as a database connection string with its password. Each detail helps an attacker plan the next step. Users only need to know that something failed; the full trace belongs in the server log.
Is it safe to run Flask with debug mode on in production?
No. Flask's debug mode enables the Werkzeug interactive debugger, which lets anyone who can trigger an error run Python code in the browser on the server, guarded only by a PIN. Debug mode also shows full tracebacks. Keep FLASK_DEBUG unset in production and refuse to start if it is set.
Why does a catch-all Flask errorhandler turn 404 errors into 500 errors?
Registering an error handler for Exception also catches Werkzeug's HTTPException subclasses, such as NotFound and MethodNotAllowed, because they inherit from Exception. Unless the handler checks isinstance(exc, HTTPException) and returns those unchanged, every missing page becomes a 500 internal server error.
Which HTTP security headers should every response have?
A good baseline is X-Content-Type-Options: nosniff, which stops browsers guessing a content type, and a Content-Security-Policy that includes frame-ancestors 'none' (or 'self'), which stops other sites framing the page for clickjacking. Referrer-Policy and, on HTTPS sites, Strict-Transport-Security are common additions.
How can I test that security headers are present?
Request a page with the framework's test client and assert on the response headers, including on error responses, since those are easy to miss. In Flask an after_request function adds headers to every response, including ones produced by error handlers, so a single test of a 500 response and a normal response covers both paths.