Web Security (OWASP Top 10, fix-side) · lesson 1 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: Security Fundamentals Ethical Hacking (methodology, lab-only)
Broken Access Control and IDOR: Fix It in Code, Pin It with a Test
A Flask API leaks another user's invoice. Add the ownership check that fixes it, and a pytest sweep that catches the next route to forget the check.
By SkillByExample · Published
Broken access control is number one in the OWASP Top 10. Its most common form is an IDOR: the API checks who you are and then forgets to check whether this record is yours. This lesson builds a tiny invoice API in Flask with that bug, proves it with one request and a failing test, fixes it, and adds a test that sweeps every route so the next endpoint can’t forget. To learn the attack side in depth, use PortSwigger’s access control and IDOR labs.
The lab
Everything runs on your machine, on 127.0.0.1. Make a folder, create a virtual environment and
install two pinned packages:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install flask==3.1.3 pytest==9.1.1
$ flask --version
Python 3.12.10
Flask 3.1.3
Werkzeug 3.1.9
$ pytest --version
pytest 9.1.1
Every lesson in this topic uses this same environment.
An API that only checks who you are
Two users, two invoices, and a bearer token that maps to a user name. The route checks the token, then returns whichever invoice the URL names.
# app.py
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
INVOICES = {
1: {"id": 1, "owner": "alice", "amount": 120},
2: {"id": 2, "owner": "bob", "amount": 4500},
}
TOKENS = {"tok-alice": "alice", "tok-bob": "bob"}
def current_user():
user = TOKENS.get(request.headers.get("Authorization", ""))
if user is None:
abort(401)
return user
@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
current_user() # authenticated, but never asks "is this yours?"
invoice = INVOICES.get(invoice_id) or abort(404)
return jsonify(invoice)
Start it with flask --app app run --port 5001, then, in a second terminal, send one request as
Alice for invoice 2:
$ curl -s -H "Authorization: tok-alice" http://127.0.0.1:5001/invoices/2
{"amount":4500,"id":2,"owner":"bob"}
Alice gets Bob’s invoice. Nothing was bypassed: her token is valid, so current_user() passed, and
the route never compared invoice["owner"] with her name. That’s the whole bug. The ID in the URL
is input, and the server trusted it to mean “a record you’re allowed to see”.
A test that proves it
Pin the expected behaviour before fixing anything. Alice can read her own invoice, gets a 404 for Bob’s, and an anonymous request gets a 401.
# test_app.py
from app import app
client = app.test_client()
ALICE = {"Authorization": "tok-alice"}
def test_owner_reads_own_invoice():
r = client.get("/invoices/1", headers=ALICE)
assert r.status_code == 200
assert r.json["owner"] == "alice"
def test_other_users_invoice_is_not_found():
r = client.get("/invoices/2", headers=ALICE)
assert r.status_code == 404
def test_anonymous_is_rejected():
assert client.get("/invoices/1").status_code == 401
$ pytest -q --tb=short
.F. [100%]
================================== FAILURES ===================================
____________________ test_other_users_invoice_is_not_found ____________________
test_app.py:16: in test_other_users_invoice_is_not_found
assert r.status_code == 404
E assert 200 == 404
E + where 200 = <WrapperTestResponse streamed [200 OK]>.status_code
=========================== short test summary info ===========================
FAILED test_app.py::test_other_users_invoice_is_not_found - assert 200 == 404
1 failed, 2 passed in 0.17s
Two tests pass, and that’s the point: a suite that only checks the happy path and the “not logged
in” path stays green with this bug. Access control needs a test written from the other user’s
side. app.test_client() calls the app in-process, so the tests need no running server.
The fix: check ownership, answer 404
Replace get_invoice in app.py:
@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
user = current_user()
invoice = INVOICES.get(invoice_id)
if invoice is None or invoice["owner"] != user:
abort(404) # same answer as "doesn't exist": no hint that id 2 is real
return jsonify(invoice)
$ pytest -q --tb=short
... [100%]
3 passed in 0.13s
Restart the server and ask for an invoice Alice owns, one she doesn’t, and one that doesn’t exist:
$ for id in 1 2 99; do curl -s -o /dev/null -w "invoice $id -> %{http_code}\n" -H "Authorization: tok-alice" http://127.0.0.1:5001/invoices/$id; done
invoice 1 -> 200
invoice 2 -> 404
invoice 99 -> 404
Invoice 2 and invoice 99 now look identical from outside. A 403 for invoice 2 would still tell
Alice that it exists, which is enough to enumerate IDs. In a real app with a database, put the
owner in the query itself (WHERE id = ? AND owner_id = ?) so a record you don’t own is never
loaded at all.
The next route forgets
A month later someone adds a delete endpoint, copying the old pattern. Append it to app.py:
@app.delete("/invoices/<int:invoice_id>")
def delete_invoice(invoice_id):
current_user()
INVOICES.pop(invoice_id, None) or abort(404)
return "", 204
test_app.py still passes, because it only tests GET. Instead of hoping someone writes a DELETE
test, generate one test per route from Flask’s URL map:
# test_every_route.py
import pytest
from app import app
ALICE = {"Authorization": "tok-alice"}
BOBS_INVOICE_ROUTES = [
(method, rule.rule.replace("<int:invoice_id>", "2"))
for rule in app.url_map.iter_rules()
if "invoice_id" in rule.arguments
for method in sorted(rule.methods - {"HEAD", "OPTIONS"})
]
@pytest.mark.parametrize("method,url", BOBS_INVOICE_ROUTES)
def test_alice_cannot_touch_bobs_invoice(method, url):
r = app.test_client().open(url, method=method, headers=ALICE)
assert r.status_code == 404
$ pytest -q --tb=short test_every_route.py
.F [100%]
================================== FAILURES ===================================
__________ test_alice_cannot_touch_bobs_invoice[DELETE-/invoices/2] ___________
test_every_route.py:17: in test_alice_cannot_touch_bobs_invoice
assert r.status_code == 404
E assert 204 == 404
E + where 204 = <WrapperTestResponse streamed [204 NO CONTENT]>.status_code
=========================== short test summary info ===========================
FAILED test_every_route.py::test_alice_cannot_touch_bobs_invoice[DELETE-/invoices/2]
1 failed, 1 passed in 0.16s
The test found two routes that take an invoice_id, GET and DELETE, without anyone listing them.
GET passes and DELETE fails: Alice just deleted Bob’s invoice and got a cheerful 204. Any route
added later with <int:invoice_id> in its URL joins the sweep automatically.
One helper, every route
Move the check into one function and make every route go through it. Replace both routes:
def owned_invoice_or_404(invoice_id):
user = current_user()
invoice = INVOICES.get(invoice_id)
if invoice is None or invoice["owner"] != user:
abort(404)
return invoice
@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
return jsonify(owned_invoice_or_404(invoice_id))
@app.delete("/invoices/<int:invoice_id>")
def delete_invoice(invoice_id):
INVOICES.pop(owned_invoice_or_404(invoice_id)["id"])
return "", 204
$ pytest -q --tb=short
..... [100%]
5 passed in 0.11s
All five tests pass: three from test_app.py and two generated routes. The routes can no longer
load an invoice without the ownership check, because the only way to get one is through
owned_invoice_or_404. The sweep is the safety net for the day someone bypasses the helper anyway.
Practice
- Add
GET /invoicesthat lists the caller’s invoices. Write a test that Alice’s list contains only invoice 1.
Solution
Filter by owner in the route itself, so nothing else can leak:
@app.get("/invoices")
def list_invoices():
user = current_user()
return jsonify([i for i in INVOICES.values() if i["owner"] == user])
def test_list_only_mine():
r = app.test_client().get("/invoices", headers={"Authorization": "tok-alice"})
assert [i["id"] for i in r.json] == [1]
The sweep doesn’t cover this route, since it has no invoice_id, which is why list endpoints need
their own test.
- Add
PATCH /invoices/<int:invoice_id>that updatesamount, using the helper. How many tests doestest_every_route.pyrun now?
Solution
@app.patch("/invoices/<int:invoice_id>")
def update_invoice(invoice_id):
invoice = owned_invoice_or_404(invoice_id)
invoice["amount"] = request.json["amount"]
return jsonify(invoice)
Three: GET, DELETE and PATCH, all passing. The helper aborts with 404 before the route reads the
request body, so Alice’s PATCH on Bob’s invoice never reaches request.json.
- Change the helper’s
abort(404)toabort(403). Which tests fail, and what does the API now reveal?
Solution
Every “other user’s invoice” test fails: test_other_users_invoice_is_not_found and each route in
the sweep, with assert 403 == 404. The tests are doing their job, because the rule was “answer as
if it doesn’t exist”. With this exact helper, a missing invoice also returns 403, so nothing
leaks yet. But the moment someone splits the branches into “missing → 404, not yours → 403”, a 403
confirms the ID is real.
- Add a test that Bob can’t read invoice 1.
Solution
def test_bob_cannot_read_alices():
r = app.test_client().get("/invoices/1", headers={"Authorization": "tok-bob"})
assert r.status_code == 404
It passes with the helper in place. Testing from both users’ sides catches a check that only works
one way, for example if invoice["owner"] == "bob" hard-coded during debugging.