Web Security (OWASP Top 10, fix-side) · lesson 4 of 6
Beginner
3Intermediate
3Also part of Web Security (OWASP Top 10, fix-side)
12- + Python Security from Python
- + Database Security from SQL
- + JavaScript Security Best Practices from JavaScript
- + Java Security from Java
- + Bash Security Best Practices from Bash
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + FastAPI Roles, CORS and Settings from Environment Variables from FastAPI
- + Spring Security — Authentication and Authorization from Java
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + C Secure Coding: Buffer Overflows, Sanitizers, and Safe Functions from C
- + LLM Security Beyond Guardrails from LLM Engineering
- + Dependency Scanning (SCA) and SBOMs with pip-audit, Syft and Grype from DevSecOps (AppSec in the pipeline)
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
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
- With the fixed app, what does
POST /orders/1return? Write a test that checks its status and that it still carriesX-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.
- Add
Referrer-Policy: no-referrerto 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.
- Delete the two
isinstancelines fromhandle_errorand runtest_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.
- Return HTTP errors as JSON too, so
GET /nopegives{"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"}.