Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix missing object-level authorization (IDOR) in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Load the signed-in user from Flask's signed session in a before_request hook, never from request data, and add the same scope to every invoice query: tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin). Detail, list and export then return only rows the caller may read. Return 404 otherwise; refuse an export when any requested ID is out of scope.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Flask 3.1.3
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. List every view that reads an invoice: detail, list, search, export, download and any background task. Note where each one gets the invoice IDs and the user.
  2. Load the user once per request in a before_request hook, from the session (or your token validation) and the users table, and protect the views with a login_required decorator. Delete code that reads a user ID, tenant ID or role from request.args, request.form, request.json or headers.
  3. Pass g.user into the data layer and add tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin) to every invoice query, with the values bound from g.user.
  4. Return 404 when the scoped query finds nothing, so a missing invoice and someone else's look the same.
  5. For bulk endpoints, compare the rows the scoped query returned with the IDs that were asked for, and refuse the whole request if any are missing.
  6. Add regression tests that sign in as a colleague, as a member of another tenant and as a billing admin of another tenant (client.session_transaction() sets the session in tests) and replay invoice IDs against the detail, list and export routes.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
def get_invoice(db: sqlite3.Connection, user: User, invoice_id: int) -> dict | None:
    row = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE id = ?""",
        (invoice_id,)).fetchone()
    return None if row is None else dict(row)


def list_invoices(db: sqlite3.Connection, user: User) -> list[dict]:
    rows = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE tenant_id = ?
        ORDER BY id""",
        (user.tenant_id,))
    return [dict(row) for row in rows]


def export_invoices(db: sqlite3.Connection, user: User, ids: list[int]) -> list[dict]:
    rows = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE id IN (SELECT value FROM json_each(?))
        ORDER BY id""",
        (json.dumps(ids),))
    return [dict(row) for row in rows]

With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).

The fix

Fixedimpl/fixed.pyPython
# Members read their own invoices; billing admins read every invoice in their own
# tenant. The tenant test sits outside the parentheses, so it binds admins too:
#   tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin)
def _scope(user: User) -> dict:
    return {"tenant": user.tenant_id, "user": user.id, "is_billing_admin": user.role == "billing_admin"}


def get_invoice(db: sqlite3.Connection, user: User, invoice_id: int) -> dict | None:
    row = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE id = :id AND tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin)""",
        {"id": invoice_id, **_scope(user)}).fetchone()
    return None if row is None else dict(row)


def list_invoices(db: sqlite3.Connection, user: User) -> list[dict]:
    rows = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin)
        ORDER BY id""",
        _scope(user))
    return [dict(row) for row in rows]


def export_invoices(db: sqlite3.Connection, user: User, ids: list[int]) -> list[dict]:
    rows = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE id IN (SELECT value FROM json_each(:ids))
          AND tenant_id = :tenant AND (owner_id = :user OR :is_billing_admin)
        ORDER BY id""",
        {"ids": json.dumps(ids), **_scope(user)})
    return [dict(row) for row in rows]

With IMPL=fixed, 15 of 15 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def _can_read(user: User, invoice: sqlite3.Row) -> bool:
    return invoice["owner_id"] == user.id or user.role == "billing_admin"


def get_invoice(db: sqlite3.Connection, user: User, invoice_id: int) -> dict | None:
    row = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE id = ?""",
        (invoice_id,)).fetchone()
    return dict(row) if row is not None and _can_read(user, row) else None


def list_invoices(db: sqlite3.Connection, user: User) -> list[dict]:
    rows = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        ORDER BY id""")
    return [dict(row) for row in rows if _can_read(user, row)]


def export_invoices(db: sqlite3.Connection, user: User, ids: list[int]) -> list[dict]:
    rows = db.execute("""
        SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
        WHERE id IN (SELECT value FROM json_each(?))
        ORDER BY id""",
        (json.dumps(ids),))
    return [dict(row) for row in rows if _can_read(user, row)]

With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).

Routes: the user comes from the signed session, not the request

app.pyPython
@app.before_request
def load_signed_in_user() -> None:
    # Flask signs the session cookie with SECRET_KEY, so a client cannot change
    # user_id. Tenant and role come from the users table, never from the request.
    user_id = session.get("user_id")
    row = None if user_id is None else get_db().execute(
        "SELECT id, tenant_id, role FROM users WHERE id = ?", (user_id,)).fetchone()
    g.user = None if row is None else User(**row)

def login_required(view):
    @wraps(view)
    def wrapped(**kwargs):
        if g.user is None:
            return {"error": "sign in first"}, 401
        return view(**kwargs)
    return wrapped

@app.get("/invoices")
@login_required
def list_invoices():
    return impl.list_invoices(get_db(), g.user)

@app.get("/invoices/<int(max=999999999):invoice_id>")
@login_required
def get_invoice(invoice_id: int):
    invoice = impl.get_invoice(get_db(), g.user, invoice_id)
    # Missing and not yours get the same answer, so IDs cannot be probed.
    return invoice if invoice is not None else ({"error": "not found"}, 404)

@app.get("/invoices/export")
@login_required
def export_invoices():
    ids = parse_ids(request.args.get("ids"))
    if ids is None:
        return {"error": f"ids must be 1 to {MAX_EXPORT_IDS} invoice IDs, comma-separated"}, 400
    invoices = impl.export_invoices(get_db(), g.user, ids)
    # All or nothing: one missing or unreadable ID refuses the whole export, so a
    # partial file is never mistaken for the full one.
    if len(invoices) != len(ids):
        return {"error": "not found"}, 404
    return to_csv(invoices), {"Content-Type": "text/csv; charset=utf-8"}

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_invoices.pyPython
# Exploit: other users, other tenants and other tenants' admins must get nothing.

def assert_hidden(signed_in, user: str, path: str, number: str) -> None:
    """Denied looks exactly like missing: 404 and none of the invoice's data."""
    res = signed_in(user).get(path)
    body = res.get_data(as_text=True)
    assert res.status_code == 404, f"expected 404, got {res.status_code}: {body}"
    assert number not in body, f"response leaked {number}"


def test_exploit_member_cannot_read_a_colleagues_invoice_in_the_same_tenant(signed_in):
    assert_hidden(signed_in, "alice", "/invoices/502", "INV-502")


def test_exploit_member_of_another_tenant_cannot_read_the_invoice(signed_in):
    assert_hidden(signed_in, "erin", "/invoices/501", "INV-501")


def test_exploit_billing_admin_cannot_read_an_invoice_in_another_tenant(signed_in):
    assert_hidden(signed_in, "cara", "/invoices/601", "INV-601")


def test_exploit_members_list_leaves_out_colleagues_invoices(signed_in):
    assert listed_ids(signed_in, "alice") == [501, 503]


def test_exploit_billing_admins_list_stays_inside_their_tenant(signed_in):
    assert listed_ids(signed_in, "cara") == [501, 502, 503]


def test_exploit_export_that_includes_a_colleagues_invoice_is_refused_whole(signed_in):
    assert_hidden(signed_in, "alice", "/invoices/export?ids=501,502", "INV-502")


def test_exploit_billing_admins_export_into_another_tenant_is_refused_whole(signed_in):
    assert_hidden(signed_in, "cara", "/invoices/export?ids=502,601", "INV-601")

Pitfalls

  • invoice['owner_id'] == user.id or user.role == 'billing_admin' lets a billing admin of any tenant read every tenant's invoices. That is this card's wrong implementation, and every test inside one tenant passes against it.
  • same_tenant and is_owner or is_admin parses as (same_tenant and is_owner) or is_admin. Parenthesize the role branch in Python and in SQL, where AND also binds tighter than OR.
  • Filtering rows in Python after an unscoped SELECT loads other tenants' data and breaks LIMIT, OFFSET and counts. Put the scope in the WHERE clause.
  • SQLAlchemy: session.get(Invoice, invoice_id) selects by primary key alone. Use select(Invoice).where(Invoice.id == invoice_id, ...) with the full scope in every query; with_loader_criteria() can add the tenant condition to every SELECT of the entity as a second layer.
  • Flask's default session is a signed cookie: the user can read it but cannot change it without SECRET_KEY. Keep the key secret, and load tenant and role from the database on each request, as load_signed_in_user does, so a changed role or a removed user takes effect at once.
  • A tenant ID from a header, a subdomain or the URL is only a selector. If you accept one, check that the signed-in user belongs to that tenant before using it.
  • Random IDs such as uuid4() make guessing harder, but leaked links, logs and browser history still expose them; the check is still required.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/python-flask does this for you).
  2. Against your own API, sign in as two members of one tenant and as a billing admin of another tenant, then replay each other's invoice IDs on the detail, list and export routes. Every request that crosses a boundary must get a 404 or only in-scope rows.
  3. Do not expect a scanner to find this: Bandit has no authorization check, no Semgrep registry rule or CodeQL query models object-level authorization for Flask, and Semgrep's OWASP Top 10 pack reported nothing on the vulnerable code. The regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix missing object-level authorization (IDOR) in Python and Flask
ToolRuleWhat it found here
Semgrepp/owasp-top-tenNo findings on app.py or impl/*.py: the vulnerable, fixed and wrong code look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE)
Banditall plugins (default profile)Bandit 1.9.4No findings on app.py or impl/*.py. Bandit has no authorization plugin, so its silence says nothing about object-level access.Checked: Bandit 1.9.4

No rule targets object-level authorization in Python. In semgrep-rules develop at 9bbf021 the only CWE-639 rule is Rails' check-unscoped-find, and the CWE-862 and CWE-863 rules cover C# MVC controllers, Apex, Dockerfiles, web.xml and AI-assistant settings. In github/codeql main at b5b8165, the only Python query tagged CWE-285, CWE-639, CWE-862 or CWE-863 is PamAuthorization.ql (PAM account checks).

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
werkzeug3.1.9
sqlite3 (standard library)Python 3.14.8; SQLite 3.53.4
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/python-flaskfolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authorization/python-flask runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • sqlite 3.53.4
Matrix
fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
Test results
IMPL=fixed 15/15 pass; IMPL=vulnerable 6/15 fail (6 exploit); IMPL=wrong 3/15 fail (3 exploit); run offline in a no-network sandbox, after installs with warm and with empty package caches

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
  2. OWASP Insecure Direct Object Reference Prevention Cheat Sheet
  3. OWASP Authorization Cheat Sheet
  4. OWASP Multi-Tenant Security Cheat Sheet
  5. Flask Quickstart: Sessions (signed cookies and SECRET_KEY)
  6. Flask tutorial: Blueprints and Views (load_logged_in_user, login_required)
  7. Flask: Testing Flask Applications (session_transaction)
  8. Python sqlite3: placeholders and named parameters
  9. SQLAlchemy 2.0: with_loader_criteria (adding criteria to every load of an entity)

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.