precogly / precogly/precogly

OWASP MCP Top 10: findings from the 2026-09-05 review

Open
#487 0 comments 0 reactions 1 assignee Claimed by @AlvinKuruvilla View on GitHub
AI from-sync Multi-Tenant-Issue Security
Dominant language
TypeScript
Stars
155
Forks
37
Avg merge
1d 17h
Merged PRs (30d)
82

Description

Read the MCP surface against the [OWASP MCP Top 10](https://github.com/OWASP/www-project-mcp-top-10) on 2026-09-05. Nine categories have a finding; MCP05 is clean and recorded below so nobody re-checks it.

MCP10 first — it is the only one that leaks data today.

This pass read code and configuration and ran no requests against a live `/mcp`. The MCP09 findings and the middleware half of MCP07 are read off `frontend/nginx.conf` and `docker-compose.prod.yml` rather than from a failed request. Library line numbers are django-oauth-toolkit 3.4.1 and `mcp` 2.1.1; re-check them against the installed version before quoting.

## MCP01 — Plaintext tokens, ten-hour lifetime

`oauth2_provider.W006` is silenced (`backend/config/settings/base.py:399`) with a verified reason: hashed storage breaks concurrent refresh, measured 2026-08-28. The reason is sound; the consequence stands — a database read yields live `/mcp` bearer tokens.

`ACCESS_TOKEN_EXPIRE_SECONDS` is never set, so it takes django-oauth-toolkit's default of 36000. `mcp/README.md:29` states "10 hours" as though it were chosen. Set it explicitly at whatever number is wanted, or the next upstream default change moves it silently.

Refresh reuse protection, rotation and the 7-day inactivity window are all on and argued.

- [ ] Set `ACCESS_TOKEN_EXPIRE_SECONDS` explicitly
- [ ] Correct the README's account of the lifetime (#482)

## MCP02 — Scope is checked once, at the server

`asgi_app` sets `required_scopes=["read"]` server-wide (`mcp/src/precogly_mcp/server.py:420`), and `BearerAuthMiddleware` checks that list and nothing else. Every tool is read-only today, so it holds. The consent screen already offers `write` — "Create and change threat models on your behalf" (`base.py:286`). The first write tool added inherits `required_scopes=["read"]` and is callable with a read-only token.

Not a bug now; a trap with no test on it.

- [ ] Add a per-tool scope check, or a test that fails when a tool declares a write

## MCP04 — Locked and pinned, never scanned

`uv.lock` carries a sha256 for every artifact and all four Dockerfile stages use `uv sync --frozen`. `pip-audit>=2.7` sits in `mcp/pyproject.toml` dev dependencies, but no CI job runs it — `ci.yml` has lint, backend, mcp, frontend and deploy-check, with no dependency scan.

`httpx2` was checked because it looks like a typosquat of the package that handles the bearer token. It is not: PyPI shows author Tom Christie, maintainer Pydantic Services Inc., source `github.com/pydantic/httpx2`, declared as the stewardship continuation of httpx. Recorded so nobody re-checks it.

- [ ] Run `pip-audit` in CI

## MCP05 — Nothing found

No shell, no `eval`, no SQL string construction. The only query path is `_matches`, a `casefold` substring test, and ORM filters take parameters. This one does not want re-checking.

## MCP06 — Free text reaches the agent unmarked

`list_threat_models` returns user-written `name` and `description` verbatim; the catalog searches return pack-written prose. Any org member can put instructions in a threat model description.

Packs are operator-installed from `LIBRARIES_PATH/packs` by a `security_team` user rather than uploaded, so the pack channel is the narrower one. Threat model descriptions are the lower bar and have no control at all. Nothing here is exploitable by this server — every tool is read-only and says so via `read_only_hint`. The risk is an agent that also holds write tools from somewhere else.

- [ ] Decide whether tool output marks tenant-authored text as data

## MCP07 — Transport and registration hardening

**`/mcp` bypasses every Django middleware.** `backend/config/wsgi.py:31` wraps the WSGI callable and `mcp_mount.dispatch` (`mcp_mount.py:114`) intercepts before Django's handler. Keeping CSRF and session middleware off a bearer-authenticated JSON path is deliberate and documented. It also takes `SecurityMiddleware` off: in production an `http://` request to `/mcp` gets no `SECURE_SSL_REDIRECT` and no HSTS header. `ALLOWED_HOSTS` never runs either. Nothing reflects the Host — `resource_url` and `issuer_url` are pinned settings, not derived per request — so no cache-poisoning or discovery-redirect vector was found. The exposure is cleartext acceptance at the app layer.

**DNS-rebinding protection is off.** `server.py:424` calls `streamable_http_app()` without `transport_security`, and the SDK defaults to `TransportSecuritySettings(enable_dns_rebinding_protection=False)` "for backwards compatibility" (`mcp/server/transport_security.py:47`), so `_validate_host` and `_validate_origin` never run. Bearer auth still refuses an unauthenticated call, so this is defence in depth. It matters most at the default `http://localhost:8000/mcp`, which is the config a developer runs. Content-Type is validated for POST regardless, and the 4 MB body cap applies.

**DCR is open and unthrottled.** `DCR_REGISTRATION_PERMISSION_CLASSES = AllowAllDCRPermission` (`base.py:355`). The comment there already names the surface and says it is "worth rate-limiting before a deployment is reachable from the internet." Nothing throttles it: no `DEFAULT_THROTTLE_CLASSES`, no django-ratelimit, no nginx `limit_req`. Unbounded `Application` row creation by anyone who can reach the port.

- [ ] Refuse a bearer token over cleartext at the app layer, or state that TLS termination is the deployment's job
- [ ] Pass `transport_security` to `streamable_http_app()`
- [ ] Rate-limit `/o/register/`

## MCP08 — No audit trail

Nothing in `backend/apps/core/mcp.py` or `mcp/src/precogly_mcp/` logs. The production root logger is WARNING (`production.py:52`), so the SDK's own loggers are silent. There is no audit model anywhere in `backend/apps/`. After an incident there is no record of which user, which `client_id`, which tool, which arguments, or how many rows left. An `AccessToken` row does not say the token was ever spent.

- [ ] Decide what a tool call records, and where

## MCP09 — `/mcp` and the whole OAuth surface are unroutable in the prod profile

`frontend/nginx.conf` proxies `/api/`, `/admin/`, `/media/` and `/assets/`, then `try_files ... /index.html`. So `/mcp`, `/.well-known/oauth-protected-resource/mcp`, `/.well-known/oauth-authorization-server` and `/o/` (authorize, token, register) all return the SPA. `docker-compose.prod.yml` publishes no backend port.

That is a functional break. The security half is that whoever adds those `location` blocks decides TLS, `X-Forwarded-Proto` and rate limiting with nothing in the repo telling them what `/mcp` needs.

- [ ] Add the `location` blocks
- [ ] Write down what a reverse proxy in front of `/mcp` is required to do (#482)

## MCP10 — Catalog tools read tables that hold tenant-authored rows

`ORMReader._threat_library`, `_countermeasure_library` and `_component_library` (`backend/apps/core/mcp.py:188-230`) apply no filter, while the docstring at `:189` asserts "the rows belong to no organization, and every organization reads the same ones."

`backend/apps/core/tenancy.py:93-101` says otherwise — all three tables are `Tenancy.MIXED`, holding pack-sourced global rows and tenant-authored custom rows told apart only by `source_pack IS NULL`. From `tenancy.py:70`: "Where the tenant-authored rows carry no owner, every organization reads them; #405 is that bug on `StandardFramework`."

MCP did not introduce it — `ThreatLibraryViewSet.get_queryset` is `objects.all()` too, and `IsSecurityTeam` waves `SAFE_METHODS` through, so any authenticated user already reads all of it over REST. What MCP changes is volume and destination: a whole catalog into an agent's context in one call, where the UI pages it.

Filtering `source_pack__isnull=False` in the three ORM reads makes the code match what the tool descriptions already promise ("shared across every organization"), and does not wait on #405. It also means rewriting `backend/apps/core/tests/test_mcp_reader.py:211`, `test_a_catalog_reads_the_same_for_anyone`, which currently pins the present behaviour as a guarantee.

- [ ] Filter `source_pack__isnull=False` in the three catalog reads
- [ ] Rewrite the test that pins the current behaviour

## Related

- #405 — the same tenancy bug on `StandardFramework`
- #306 — OAuth tokens keep working after an account is disabled
- #482 — MCP documentation, which carries the README and deployment items above

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.