Insecure Direct Object Reference in Flask with Api Keys
Insecure Direct Object Reference in Flask with Api Keys — how this specific combination creates or exposes the vulnerability
Insecure Direct Object Reference (BOLA/IDOR) in Flask occurs when an API endpoint uses user-supplied identifiers to access resources without verifying that the requesting identity is authorized for that specific object. Combining this with API keys as the sole authorization mechanism can expose sensitive records because the key identifies the client application or user, but the endpoint may still allow an attacker to iterate or manipulate object IDs (e.g., /users/123, /invoices/456) without enforcing ownership or scope checks.
Consider a Flask endpoint that retrieves a user profile by ID and expects an API key in the Authorization header:
from flask import Flask, request, jsonify
app = Flask(__name__)
# Example datastore
users = {
1: {"id": 1, "name": "alice", "email": "[email protected]", "api_key": "alice_key"},
2: {"id": 2, "name": "bob", "email": "[email protected]", "api_key": "bob_key"},
}
@app.route("/users/", methods=["GET"])
def get_user(user_id):
api_key = request.headers.get("X-API-Key")
if not api_key:
return jsonify({"error": "API key missing"}), 401
# Vulnerable: key only checked for existence, not bound to user_id
user = users.get(user_id)
if not user:
return jsonify({"error": "not found"}), 404
if user["api_key"] != api_key:
return jsonify({"error": "forbidden"}), 403
return jsonify({"id": user["id"], "name": user["name"], "email": user["email"]})
In this example, the API key is compared to a per-user key, but note that the check occurs after the object is retrieved by ID. An authenticated client with a valid key for user 1 could attempt to access /users/2 by changing the URL parameter. If the application does not validate that the key matches the requested user_id, the response may reveal existence or details of user 2, resulting in an IDOR. Even when the key matches, missing ownership validation at the route level means horizontal privilege escalation is possible if the key is leaked or reused across accounts.
Another common pattern is using API keys to identify an integration or service account that should only access a subset of resources. If the key is mapped to a broad role or the endpoint does not scope queries by the key’s associated tenant or group, an attacker who knows or guesses another valid ID may read or alter data belonging to others. For example:
@app.route("/invoices/", methods=["GET"])
def get_invoice(invoice_id):
api_key = request.headers.get("X-API-Key")
if not api_key:
return jsonify({"error": "API key missing"}), 401
# Vulnerable: no check that invoice belongs to the key’s tenant
invoice = get_invoice_from_db(invoice_id) # hypothetical DB call
if not invoice:
return jsonify({"error": "not found"}), 404
return jsonify(invoice)
Here, the API key might identify a merchant or customer tenant, but if the SQL query or ORM call does not filter by tenant_id, an attacker can enumerate invoice IDs and access data outside their tenant. This becomes a vertical IDOR if the key maps to a lower-privileged role that should not access certain invoice types. The risk is compounded when error messages differ between not found and unauthorized, allowing attackers to infer existence of resources.
middleBrick detects such patterns during scans by correlating authentication (API key usage) with authorization checks across endpoints and, where applicable, cross-referencing OpenAPI/Swagger definitions with runtime behavior to highlight missing ownership or scope constraints.
Api Keys-Specific Remediation in Flask — concrete code fixes
To remediate IDOR when using API keys in Flask, enforce strict ownership and scope checks before returning any object. Instead of treating the API key as a simple boolean credential, bind it to the requested resource and ensure the data access layer applies filters consistently.
1) Key-to-owner binding with parameterized queries:
from flask import Flask, request, jsonify
app = Flask(__name__)
# Example datastore
users = {
1: {"id": 1, "name": "alice", "email": "[email protected]", "api_key": "alice_key"},
2: {"id": 2, "name": "bob", "email": "[email protected]", "api_key": "bob_key"},
}
@app.route("/users/", methods=["GET"])
def get_user(user_id):
api_key = request.headers.get("X-API-Key")
if not api_key:
return jsonify({"error": "API key missing"}), 401
# Correct: find by key first, then verify requested ID matches
owner = None
for uid, u in users.items():
if u["api_key"] == api_key:
owner = u
break
if owner is None:
return jsonify({"error": "forbidden"}), 403
if owner["id"] != user_id:
return jsonify({"error": "forbidden"}), 403
return jsonify({"id": owner["id"], "name": owner["name"], "email": owner["email"]})
This ensures the key maps to a specific user and that the requested user_id equals the key’s owner, preventing horizontal IDOR.
2) Scoped data access for tenant-aware keys:
@app.route("/invoices/", methods=["GET"])
def get_invoice(invoice_id):
api_key = request.headers.get("X-API-Key")
if not api_key:
return jsonify({"error": "API key missing"}), 401
# Assume get_tenant_by_api_key returns tenant metadata or None
tenant = get_tenant_by_api_key(api_key)
if not tenant:
return jsonify({"error": "forbidden"}), 403
# Enforce tenant scope in the data access layer
invoice = get_invoice_from_db(invoice_id, tenant_id=tenant["id"])
if not invoice:
return jsonify({"error": "not found"}), 404
return jsonify(invoice)
def get_invoice_from_db(invoice_id, tenant_id):
# Example SQL-like filter; implement with parameterized queries
# SELECT * FROM invoices WHERE id = ? AND tenant_id = ?
pass
By filtering at the query level using the tenant derived from the API key, you ensure that even if an attacker guesses another invoice ID, the database returns no rows because the tenant_id does not match. This approach aligns with the principle of least privilege and prevents both horizontal and vertical IDOR.
3) Use a mapping table or claims in the key itself to avoid iterating over users:
import hashlib
import hmac
# Example: derive a key binding to user_id without storing plaintext mapping
SECRET = b"rotate_me_securely"
def derive_key(user_id: int) -> str:
return hmac.new(SECRET, str(user_id).encode(), hashlib.sha256).hexdigest()
def verify_key(user_id: int, provided: str) -> bool:
return hmac.compare_digest(derive_key(user_id), provided)
@app.route("/users/", methods=["GET"])
def get_user(user_id):
api_key = request.headers.get("X-API-Key")
if not api_key or not verify_key(user_id, api_key):
return jsonify({"error": "forbidden"}), 403
return jsonify({"id": user_id, "name": "alice", "email": "[email protected]"})
This method binds the key to the ID cryptographically, so the server does not need to look up a per-user key, and tampering with the ID results in verification failure. Ensure keys are transmitted over TLS and rotated periodically.
middleBrick’s scans can validate these fixes by checking that endpoints which use API keys also enforce object-level authorization and that error handling does not leak information that could aid enumeration.
Related CWEs: bolaAuthorization
| CWE ID | Name | Severity |
|---|---|---|
| CWE-250 | Execution with Unnecessary Privileges | HIGH |
| CWE-639 | Insecure Direct Object Reference | CRITICAL |
| CWE-732 | Incorrect Permission Assignment | HIGH |