Skip to main content
Glama

MCP Object-Authz Lab

lab-integrity CodeQL License: MIT

A vulnerable-by-design MCP server for practising object-level authorization bugs (BOLA / IDOR, CWE-639, CWE-862) in MCP tools. Thirteen hands-on scenarios, each one a different variant of the bug, each with a vulnerable build and a fixed build.

git clone https://github.com/WRG-11/mcp-objauthz-lab && cd mcp-objauthz-lab
npm install && npm run poc    # Node.js 20 or newer

The PoC is an MCP client. It starts the server on your machine and sends the same cross-tenant request to both builds. Two rows of its 38-row output:

  SC   BUILD  ACTION                                         OUTCOME   EXPECT    OK
  S2   vuln   note_search  scope-as-param (Alice→org_globex) LEAKED    LEAKED    ✓
  S2   fixed  note_search  scope-as-param (Alice→org_globex) SCOPED    SCOPED    ✓

  Two-way gate: PASS (38/38 rows OK)

Same caller, same request: the vulnerable build returns another tenant's notes, the fixed build scopes the result to the caller's own tenant.

Try the scenarios without the answers · Walkthroughs · Hunt checklist

Why this lab exists

Most MCP security attention goes to prompt injection and tool poisoning. Object-level authorization is a different class, and the usual prompt-injection test suites and scanners do not find it. When an MCP server is multi-tenant, every tool that resolves an object from a client-supplied id must verify the caller is allowed to touch that object. Miss the check on a single tool and a caller in one tenant can read or change another tenant's objects.

This is not theoretical. Asana's MCP connector (Jun 2025) leaked data across tenant boundaries for roughly 1,000 customer organizations (Pomerium's writeup). In n8n-mcp, CVE-2026-54052 let any caller read or delete another tenant's stored API keys: sequential integer ids on a table with no tenant-id column. Both are S1/S3-shaped bugs, in production instead of a lab.

Related MCP server: vuln-mcp-server

Try the challenges

The scenarios are in challenges/, with no hints until you open solutions/. Each runs locally in under 5 minutes.

Scenario

Pattern

S1

Inconsistent authorization: find the single outlier

S2

Client-supplied scope trusted as authorization

S3

List→get asymmetry: batch skips per-object check

S4

Wildcard/sentinel value bypasses scope filter

S5

Role/token-type bypass: admin-named tool, no role check

S6

Foreign-parent injection: create trusts a caller-supplied org

S7

Unscoped query: tenant key omitted from the filter (the real-world shape)

S8

Resource-URI-as-scope: the resources/read surface, not tools/call

S9

Authz-from-client-round-tripped-value: an editable share grant

S10

Forwarded-header-as-scope: a trusted request header (HTTP transport)

S11

X-Forwarded-For quota bypass: a trusted request header for rate limiting (HTTP transport)

S12

Batch/bulk endpoint BOLA: multi-object endpoint without per-item tenant filter

S13

JWT/token scope confusion: token's scope/aud claim vs actual permissions mismatch

Walkthroughs with hints and answers are in docs/scenarios.md.

Quickstart (< 5 minutes)

Requirements: Node.js 20 or later.

npm install
npm test    # 92 tests: unit, docs-consistency, CI-hardening, PoC-output and fixture-annotation checks
npm run poc # 38-row two-way gate: the tools/resources wired end-to-end over MCP
npm run poc -- --json-output poc-chain-evidence.json --sarif-output poc-chain-evidence.sarif

Expected npm run poc output (38/38 rows, all scenarios + the hardened build):

MCP object-level authorization lab — two-way gate (13 scenarios + hardened build)

  SC   BUILD  ACTION                                         OUTCOME   EXPECT    OK
  S1   vuln   note_get    cross-tenant (Bob→Acme)            DENIED    DENIED    ✓
  S1   vuln   note_delete cross-tenant (Bob→Acme)            DELETED   DELETED   ✓
  S1   fixed  note_delete cross-tenant (Bob→Acme)            DENIED    DENIED    ✓
  S1   fixed  note_delete same-tenant  (Bob→Globex)          DELETED   DELETED   ✓
  S2   vuln   note_search  scope-as-param (Alice→org_globex) LEAKED    LEAKED    ✓
  S2   fixed  note_search  scope-as-param (Alice→org_globex) SCOPED    SCOPED    ✓
  S3   vuln   note_batch_get list→get asymm (Alice+Globex)   LEAKED    LEAKED    ✓
  S3   fixed  note_batch_get list→get asymm (Alice+Globex)   SCOPED    SCOPED    ✓
  S4   vuln   note_export  wildcard org_id='*' (Alice)       LEAKED    LEAKED    ✓
  S4   fixed  note_export  wildcard org_id='*' (Alice)       SCOPED    SCOPED    ✓
  S5   vuln   note_admin_get cross-org as Bob (user)         LEAKED    LEAKED    ✓
  S5   fixed  note_admin_get cross-org as Bob (user)         DENIED    DENIED    ✓
  S5   fixed  note_admin_get cross-org as Dana (real admin)  ALLOWED   ALLOWED   ✓
  S6   vuln   note_create_in_org org_id=org_globex (Alice)   INJECTED  INJECTED  ✓
  S6   fixed  note_create_in_org org_id=org_globex (Alice)   SCOPED    SCOPED    ✓
  S7   vuln   note_get_by_query cross-tenant (Alice→Globex)  LEAKED    LEAKED    ✓
  S7   fixed  note_get_by_query cross-tenant (Alice→Globex)  DENIED    DENIED    ✓
  S8   vuln   resources/read cross-tenant (Alice→Globex)     LEAKED    LEAKED    ✓
  S8   fixed  resources/read cross-tenant (Alice→Globex)     DENIED    DENIED    ✓
  S9   vuln   note_share_redeem tampered grant (Alice→Globex) LEAKED    LEAKED    ✓
  S9   fixed  note_share_redeem tampered grant (Alice→Globex) DENIED    DENIED    ✓
  S9   fixed  note_share_redeem own grant     (Alice→Acme)   ALLOWED   ALLOWED   ✓
  S10  vuln   note_get_scoped X-Org-Id=org_globex (Alice over HTTP) LEAKED    LEAKED    ✓
  S10  fixed  note_get_scoped X-Org-Id=org_globex (Alice over HTTP) SCOPED    SCOPED    ✓
  S11  vuln   note_create_limited quota exhausted (same XFF) BLOCKED   BLOCKED   ✓
  S11  vuln   note_create_limited quota key is 1.2.3.4       MATCH     MATCH     ✓
  S11  vuln   note_create_limited fresh server XFF=5.6.7.8 (quota key logic) CREATED   CREATED   ✓
  S11  vuln   note_create_limited quota exhausted XFF=5.6.7.8 BLOCKED   BLOCKED   ✓
  S11  fixed  note_create_limited quota exhausted (same XFF) BLOCKED   BLOCKED   ✓
  S11  fixed  note_create_limited quota key is u_alice       MATCH     MATCH     ✓
  S11  fixed  note_create_limited fresh server XFF=5.6.7.8 (quota key logic) CREATED   CREATED   ✓
  S11  fixed  note_create_limited quota exhausted XFF=5.6.7.8 BLOCKED   BLOCKED   ✓
  S12  vuln   note_batch_resolve batch with Globex id (Alice) LEAKED    LEAKED    ✓
  S12  fixed  note_batch_resolve batch with Globex id (Alice) SCOPED    SCOPED    ✓
  S13  vuln   note_get_by_token_scope scope=org_globex (Alice) LEAKED    LEAKED    ✓
  S13  fixed  note_get_by_token_scope scope=org_globex (Alice) SCOPED    SCOPED    ✓
  ALL  fixed  13 cross-tenant routes (Bob→Acme)              BLOCKED   BLOCKED   ✓
  ALL  fixed  legitimate access (Dana admin + Bob own note)  ALLOWED   ALLOWED   ✓

  Two-way gate: PASS (38/38 rows OK)

The PoC is a real MCP client. It spawns the server over stdio, locally, with no network and no third party, and runs a two-way gate per scenario: in the vuln build the exploit succeeds; in the fixed build it is blocked and legitimate same-org access still works (no false positive).

--json-output writes the structured evidence contract used by the optional toolkit adapter. --sarif-output writes only observed vulnerability outcomes from the intentionally vulnerable build; fixed controls and expected denials are not emitted as findings.

The final ALL rows apply that same two-way discipline to the whole server at once. With every scenario fixed, each cross-tenant route is closed while legitimate access (an admin's cross-org read, a user's own note) still works. Each S1-S13 arm deliberately pins one toggle and leaves the rest at their vuln default, so without these rows the hardened build that Run modes tells you to run would have no coverage at all.

Detection rules

detection/ ships 50 Semgrep rules that flag these patterns in your own MCP server source, not just this lab's. There is one per code shape above, with Python siblings where the JavaScript spelling cannot parse as Python, plus Go, Rust, Kotlin, Java, Ruby, PHP, C#, and Swift language packs. Five additional rules cover file paths, dynamic code execution, and outbound requests. Eleven of the object-authz rules run against Python as well as JavaScript/TypeScript, which matters because the reference MCP SDKs ship in both.

Measured against this lab's own source on 2026-10-28, the rules report a line in the handlers of 9 of the 13 scenarios (S2, S4, S6, S7, S8, S9, S10, S11, S13). The other four have their fix written in the same handler behind a runtime toggle, which the rules deliberately read as present. An earlier count listed S1 as flagged; that finding was on note_get, a correctly authorized handler, and is fixed. The 45-rule revision measured on 2026-10-28 produced zero findings against the official @modelcontextprotocol/sdk, with a planted canary proving the scan reached the tree. The five new rules require a new two-way scan before making the same claim. The per-scenario breakdown is in detection/README.md and the measurement notes are in docs/detection-notes.md.

Drop it into your own MCP server's CI as a GitHub Action. Findings upload to your repo's Security tab, so the calling workflow needs security-events: write:

permissions:
  security-events: write   # only needed for the SARIF upload

steps:
  - uses: WRG-11/mcp-objauthz-lab@main
    with:
      path: src/   # your MCP server source

Audit a whole MCP server

Object-level authorization remains the ruleset's main focus. skills/mcp-audit/ is a Claude Code skill that reviews the rest of a server's tool surface as well: how each tool describes itself, whether result shapes and errors agree across tools, which tools have tests, which look unused, and how state is written. On a multi-tenant server it runs these rules and walks the hunt checklist as one of its steps. It is read-only and writes a single report with a severity per finding.

Run modes

The lab is a multi-tenant note server exposing eighteen MCP tools and one MCP resource across thirteen independent BOLA scenarios. Each scenario has its own toggle, LAB_S1 to LAB_S13, and every toggle defaults to vuln. Run all scenarios in their fixed state:

# Linux / macOS
LAB_S1=fixed LAB_S2=fixed LAB_S3=fixed LAB_S4=fixed LAB_S5=fixed LAB_S6=fixed LAB_S7=fixed LAB_S8=fixed LAB_S9=fixed LAB_S10=fixed LAB_S11=fixed LAB_S12=fixed LAB_S13=fixed npm start

# Windows PowerShell
$env:LAB_S1='fixed'; $env:LAB_S2='fixed'; $env:LAB_S3='fixed'; $env:LAB_S4='fixed'; $env:LAB_S5='fixed'; $env:LAB_S6='fixed'; $env:LAB_S7='fixed'; $env:LAB_S8='fixed'; $env:LAB_S9='fixed'; $env:LAB_S10='fixed'; $env:LAB_S11='fixed'; $env:LAB_S12='fixed'; $env:LAB_S13='fixed'; npm start

What each toggle changes, and how to isolate one scenario, is in docs/configuration.md.

More documentation

Scenario reference

Rule messages link to the headings below. Each one points to its challenge and walkthrough.

Scenario S1 — Missing org-scope check (the original)

Inconsistent authorization: find the single outlier. note_delete, CWE-639 / CWE-862, toggle LAB_MODE / LAB_S1. Challenge, walkthrough.

Scenario S2 — Scope-as-param

Client-supplied scope trusted as authorization. note_search, CWE-639, toggle LAB_S2. Challenge, walkthrough.

Scenario S3 — List→get asymmetry

List→get asymmetry: batch skips per-object check. note_batch_get, CWE-862, toggle LAB_S3. Challenge, walkthrough.

Scenario S4 — Wildcard/sentinel bypass

Wildcard/sentinel value bypasses scope filter. note_export, CWE-639, toggle LAB_S4. Challenge, walkthrough.

Scenario S5 — Role/token-type bypass

Role/token-type bypass: admin-named tool, no role check. note_admin_get, CWE-863, toggle LAB_S5. Challenge, walkthrough.

Scenario S6 — Foreign-parent injection

Foreign-parent injection: create trusts a caller-supplied org. note_create_in_org, CWE-639, toggle LAB_S6. Challenge, walkthrough.

Scenario S7 — Unscoped query

Unscoped query: tenant key omitted from the filter (the real-world shape). note_get_by_query, CWE-639, toggle LAB_S7. Challenge, walkthrough.

Scenario S8 — Resource-URI-as-scope

Resource-URI-as-scope: the resources/read surface, not tools/call. note://{token}/{orgId}/{noteId}, CWE-639, toggle LAB_S8. Challenge, walkthrough.

Scenario S9 — Authz-from-client-round-tripped-value

Authz-from-client-round-tripped-value: an editable share grant. note_share_prepare, note_share_redeem, CWE-639, toggle LAB_S9. Challenge, walkthrough.

Scenario S10 — Forwarded-header-as-scope

Forwarded-header-as-scope: a trusted request header (HTTP transport). note_get_scoped, CWE-639 / CWE-290, toggle LAB_S10. Challenge, walkthrough.

Scenario S11 — Forwarded-header quota bypass

X-Forwarded-For quota bypass: a trusted request header for rate limiting (HTTP transport). note_create_limited, CWE-639 / CWE-290, toggle LAB_S11. Challenge, walkthrough.

Scenario S12 — Batch resolve without a per-item check

Batch/bulk endpoint BOLA: multi-object endpoint without per-item tenant filter. note_batch_resolve, CWE-639 / CWE-862, toggle LAB_S12. Challenge, walkthrough.

Scenario S13 — Token scope claim trusted as permission

JWT/token scope confusion: token's scope/aud claim vs actual permissions mismatch. note_get_by_token_scope, CWE-639 / CWE-290, toggle LAB_S13. Challenge, walkthrough.

Safety / scope

This server is vulnerable by design. Do not deploy it on a reachable network or use it as a starting point for real code. Run it locally for learning only.

Everything in it is synthetic. The orgs and users are made up, and so are their notes and tokens; there is no real target. The PoC never makes a network request. It only spawns the local server process over stdio.

Contributing

The most useful contribution to a detection ruleset is a false positive: a rule that fires on correctly authorized code. A gate wider than the defect it targets gets switched off, and a switched-off rule protects nothing, so those are treated as real defects here. Misses are just as welcome; the rules flag 9 of the 13 scenarios against this lab's own source and detection/README.md says why.

There is an issue template for each. CONTRIBUTING.md has the fixture convention and the exact-count CI gate. It also describes the multi-language rule trap that costs an afternoon if you meet it the hard way.

Participation is covered by the Code of Conduct. Security reports go through SECURITY.md. Please do not report the planted flaws; they are the point.

Citing this

CITATION.cff, or use GitHub's "Cite this repository" button.

License

MIT.

Available Tools

18 tools
note_admin_getA

Get any note by id, across organizations. Admin/support use only.

Inputs: id (required), token (required).

note_batch_getA

Fetch multiple notes by id in a single call (up to 20 ids).

Inputs: ids (required), token (required).

note_batch_resolveC

Fetch multiple notes by id in a single batch call (up to 50 ids). Returns all requested notes.

Inputs: ids (required), token (required).

note_createC

Create a note in your organization.

Inputs: title (required), token (required), body.

note_create_in_orgA

Create a note inside a specific organization (cross-team collaboration). The org_id parameter targets the destination org.

Inputs: title (required), token (required), body, org_id.

note_create_limitedA

Create a note with a per-client quota (max 3 notes). The quota is tracked by client identity.

Inputs: title (required), token (required), body.

note_deleteC

Delete a note by id.

Inputs: id (required), token (required).

note_exportB

Export all notes for an organization. Pass org_id='*' for a global export (admin only).

Inputs: token (required), org_id.

note_getC

Get one note by id.

Inputs: id (required), token (required).

note_get_by_queryC

Fetch a single note by id, resolved through a filtered store query.

Inputs: id (required), token (required).

note_get_by_token_scopeB

Get notes for the organization indicated by the token's scope claim. The token carries the intended scope.

Inputs: token (required), scope.

note_get_scopedB

List the caller's notes. Honors an X-Org-Id routing header set by the API gateway.

Inputs: token (required).

note_listC

List the notes in your organization.

Inputs: token (required).

note_share_prepareB

Prepare an opaque share grant for one of your own notes, redeemable via note_share_redeem.

Inputs: id (required), token (required).

note_share_redeemA

Redeem an opaque share grant produced by note_share_prepare.

Inputs: grant (required), token (required).

note_updateA

Update a note's body by id.

Inputs: id (required), body (required), token (required).

whoamiA

Return the session (user + org) your token authenticates as.

Inputs: token (required).

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    B3/5.0

    Scored across 18 tools

    Disambiguation2/5

    There are many overlapping read operations: note_get, note_get_by_query, note_get_scoped, note_get_by_token_scope, note_admin_get, and two batch getters. note_batch_resolve and note_batch_get especially are nearly indistinguishable, so an agent would struggle to pick the right tool for a simple fetch.

    Naming Consistency4/5

    Most tools follow a consistent note_<action> pattern, e.g. note_list, note_get, note_create, note_update, note_delete. Minor deviations like whoami and the inconsistent note_batch_resolve versus note_batch_get keep it from being a perfect 5.

    Tool Count3/5

    18 tools is above the comfortable 3-15 range and feels heavy for a notes/authz lab. Many read variants inflate the count when fewer, more distinct tools could express the same authorization scenarios.

    Completeness4/5

    The toolset covers the core note lifecycle: create, get, list, search, update, delete, export, plus authz-specific operations like admin access, org-scoped creation, token-scoped reads, and sharing. Minor gaps like share revocation and scoped update/delete variants exist, but core workflows are not dead-ended.

    Maintenance

    ActivityActive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers