Matrix-driven authorization testing for HTTP APIs.
overstep takes a declarative authorization matrix — who is allowed to do what — and turns it into concrete HTTP tests. It generates positive tests (access that should succeed) and negative tests (access that should be denied), runs them against a live target, and reports every negative test that slipped through as an authorization vulnerability: BOLA, BFLA or privilege escalation. Snapshot the results and CI fails the moment your authorization surface drifts.
authorization matrix ──► positive + negative tests ──► run ──► findings
(subjects × resources) (self / other, per role) (BOLA/BFLA/privesc/drift)
Most authorization bugs aren't a missing if in one handler — they're a cell
in a table nobody wrote down. "Can a plain user delete another user's order?"
is a question about the intersection of a role, a resource and an
ownership scope. overstep makes that table explicit and tests every cell:
- BOLA (Broken Object Level Authorization) — a subject reaches another
subject's object (
GET /orders/{id}for an id they don't own). - BFLA (Broken Function Level Authorization) — a subject invokes a function
their role shouldn't have (
GET /admin/usersas a normal user). - Privilege escalation — a lower-privileged role reaches something reserved for a higher one.
- Authorization drift — a decision that changed since your last release, caught by comparing against a saved baseline.
python -m venv .venv && . .venv/bin/activate
pip install -e . # or: pip install -e ".[dev]" for tests + demo server# 1. Start the intentionally-vulnerable demo API
python -m uvicorn examples.mock_api.server:app --port 8000
# 2. In another shell, run the matrix against it
overstep run examples/mock_api/matrix.yaml --out outYou'll get a summary like:
overstep summary
Tests run 18
Positive / negative 7 / 11
Vulnerabilities 8
BOLA 2
privilege-escalation 6
and reports in out/:
| File | For |
|---|---|
report.html |
humans |
findings.json |
scripts / dashboards |
overstep.sarif |
GitHub code scanning |
junit.xml |
CI test reporters |
overstep run exits non-zero when it finds a vulnerability, so it fails a
pipeline out of the box.
A matrix has three parts — subjects (who), resources (what) and policy (the allow-list). Everything not explicitly allowed is denied.
base_url: http://127.0.0.1:8000
roles: [anonymous, user, admin] # least -> most privileged
subjects:
- { name: alice, role: user, token: alice-token, attributes: { user_id: u1 } }
- { name: bob, role: user, token: bob-token, attributes: { user_id: u2 } }
- { name: root, role: admin, token: admin-token, attributes: { user_id: u9 } }
- { name: anon, role: anonymous, token: null }
resources:
- name: get_user
request: { method: GET, path: "/users/{id}" }
type: object # object-level -> BOLA surface
owner_param: id # {id} must match the caller's user_id
owner_attr: user_id
- name: admin_list_users
request: { method: GET, path: "/admin/users" }
type: function # function-level -> BFLA surface
policy:
get_user:
allow:
- { role: user, scope: own } # a user may read only their own object
- { role: admin, scope: any } # admins may read anyone's
admin_list_users:
allow:
- { role: admin } # admin-onlyFrom this, overstep generates (overstep plan examples/mock_api/matrix.yaml):
| Expected | Request | Subject | Variant |
|---|---|---|---|
| allow | GET /users/u1 |
alice | self |
| deny | GET /users/u2 |
alice | other ← BOLA probe |
| allow | GET /users/u9 |
root | self |
| allow | GET /users/u1 |
root | other |
| deny | GET /admin/users |
alice | na ← BFLA / privesc probe |
| allow | GET /admin/users |
root | na |
For finer rules (tenant isolation, attribute matching) an allow rule can carry a
safe boolean condition evaluated over subject and target attributes:
policy:
get_order:
allow:
- role: user
condition: "subject.tenant == target.tenant"Conditions run through a restricted AST evaluator — comparisons, boolean logic and attribute/index access only. No function calls, no arbitrary names.
By default each subject authenticates with Authorization: Bearer <token>. When
an endpoint needs more — a non-bearer auth scheme, an API key, a tenant header —
set headers on the resource (sent for every subject) and/or on the
subject (per identity). Subject headers override resource headers, and an
explicit Authorization header is never overwritten by the token:
resources:
- name: get_order
request:
method: GET
path: "/orders/{id}"
headers: { Accept: application/json, X-Api-Version: "2" } # every request
type: object
owner_param: id
subjects:
- name: alice
role: user
token: alice-token # -> Authorization: Bearer alice-token
headers: { X-Tenant: t1 } # extra per-subject header
attributes: { user_id: u1 }
- name: svc
role: admin
headers: { X-API-Key: "abc123" } # custom auth, no bearer token
attributes: { user_id: u9 }By default a 2xx status means access was granted and anything else means it was
denied. That's wrong for APIs that redirect on success, return 200 with an
error body, or mask a 403 as a 404. A response matcher makes the real
signal explicit. Set it matrix-wide under access: and/or override it per
resource:
# matrix-wide default
access:
allow_status: ["2xx"] # exact codes, ranges ("200-299") or classes ("2xx")
deny_body_regex: "access denied|not authorized" # a 200 with this body -> deny
treat_redirect_as: deny # how to read a 3xx: deny | allow | status
resources:
- name: start_export
request: { method: POST, path: "/exports" }
type: function
access:
allow_status: [200, 202] # async accept counts as success
- name: legacy_login_redirect
request: { method: GET, path: "/account" }
type: function
access:
treat_redirect_as: allow # this endpoint 302s on successEvaluation order: deny_body_regex (wins, fails safe) → allow_body_regex →
redirect handling → allow_status. Body patterns are case-insensitive and
matched against the full response body.
Static tokens don't survive CI — they expire and shouldn't be committed. Two features handle this:
${ENV} interpolation. Any ${VAR} in the matrix is replaced from the
environment at load time (${VAR:-default} for a fallback); a missing variable
fails the run loudly instead of sending the literal string. Pass a dotenv file
with --env-file.
Auth providers. A subject can obtain its token by logging in before the run,
instead of carrying a static one. type: http posts an arbitrary login request
and reads the token out of the JSON response; oauth2_client_credentials and
oauth2_password build the standard token-endpoint form. Values may contain
{{var}} placeholders filled from each subject's auth.vars, so one provider
serves many identities:
auth:
providers:
- name: login
type: http # or oauth2_password / oauth2_client_credentials
request:
method: POST
path: /auth/login
body: { username: "{{U}}", password: "{{P}}" }
token_path: "$.access_token" # dotted path into the JSON response
# token_header: Authorization # defaults; override for X-API-Key etc.
# token_format: "Bearer {token}"
subjects:
- name: alice
role: user
auth: { provider: login, vars: { U: alice, P: "${ALICE_PASS}" } } # secret from env
attributes: { user_id: u1 }export ALICE_PASS=… # or: overstep run matrix.yaml --env-file .env
overstep run matrix.yaml # logs in as each subject, then tests${...} is resolved once from the environment; {{...}} is resolved per subject
at login time — so secrets come from the environment and never touch the file.
Meaningful BOLA testing needs a real owned object — the order that belongs to alice, not her user id. Two pieces make that work:
objects on a resource maps each subject to the id of the object it owns.
That id drives the SELF request (the subject's own object) and the OTHER request
(reaching for someone else's — the BOLA probe).
setup steps run once before the suite, as a chosen subject, and extract
values from their responses into a capture context. Captures fill {{name}}
placeholders — including in objects — so ids created at runtime flow straight
into the tests:
setup:
- name: alice creates an order
as: alice # runs with alice's (dynamic) token
request: { method: POST, path: /orders, body: { item: book } }
extract: { ALICE_ORDER: "$.id" } # capture the new id
- name: bob creates an order
as: bob
request: { method: POST, path: /orders, body: { item: pen } }
extract: { BOB_ORDER: "$.id" }
resources:
- name: get_order
request: { method: GET, path: "/orders/{id}" }
type: object
owner_param: id
objects: { alice: "{{ALICE_ORDER}}", bob: "{{BOB_ORDER}}" }Now get_order::bob::other fetches alice's real order id, so a 200 is a
genuine BOLA finding rather than a guess. Captures also fill {{...}} in request
bodies, queries and headers. If a subject has no objects entry, overstep falls
back to its owner_attr attribute as before.
| Command | What it does |
|---|---|
overstep run MATRIX |
generate, execute and report; non-zero exit on findings |
overstep snapshot MATRIX |
record current decisions as a drift baseline |
overstep plan MATRIX |
print the generated test cases (no network) |
overstep validate MATRIX |
lint a matrix for structural problems |
overstep scaffold SPEC --fmt openapi|har |
generate a starter resources: block |
run flags: --base (override URL), --out, --baseline, --concurrency,
--insecure, --env-file, and --fail-on {vuln,drift,any,never}.
Bake the known-good state into a baseline, then fail only when authorization changes:
# once, after triaging findings
overstep snapshot examples/mock_api/matrix.yaml --out baseline.json
# on every pull request
overstep run examples/mock_api/matrix.yaml --baseline baseline.json --fail-on driftA cell that flips from deny → allow is a newly opened hole (high severity);
allow → deny is a new restriction (medium). Keep matrix.yaml and
baseline.json in version control and authorization gets reviewed like any other
code. See .github/workflows/ci.yml for a full
example, including uploading SARIF to GitHub code scanning.
Don't write the resource list by hand:
overstep scaffold openapi.yaml --fmt openapi > resources.snippet.yaml
overstep scaffold traffic.har --fmt har > resources.snippet.yamloverstep guesses object-vs-function from id-like path parameters; you add the policy.
| Capability | overstep | Burp Autorize / AuthMatrix | Schemathesis |
|---|---|---|---|
| Authorization matrix as code | ✅ | ❌ | |
| Positive and negative tests | ✅ | ||
| BOLA / BFLA / privesc classification | ✅ | ❌ | |
| Drift baselines for CI | ✅ | ❌ | ❌ |
| SARIF + JUnit output | ✅ | ❌ |
⚠️ means possible only with significant manual effort.
See examples/crapi to run overstep against OWASP
crAPI for a realistic BOLA/BFLA showcase.
Apache-2.0.