[Security bug] : GET /api/frameworks/ returns other tenants' internal standards
- Dominant language
- TypeScript
- Stars
- 155
- Forks
- 37
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 82
Description
# `GET /api/frameworks/` returns other tenants' internal standards
`StandardFramework` holds two kinds of row in one table, and the field that decides which
says so itself (`backend/apps/compliance/models.py:22`):
```python
threat_model = models.ForeignKey(
"threat_models.ThreatModel",
...
help_text="Populated for user-created internal standards. "
"NULL for global pack-sourced frameworks.",
)
```
Rows with `threat_model` null are shipped reference data — NIST, ISO, SOC 2 — and every
tenant is meant to read them. Rows with it set belong to one threat model, and so to one
organization.
`StandardFrameworkViewSet` (`backend/apps/compliance/views.py:20`) serves both from a
class-level `queryset = StandardFramework.objects.all()` with no `get_queryset`.
`IsSecurityTeam` returns `True` for safe methods, so nothing narrows the read.
## Reproduction
```python
home_org = Organization.objects.create(name="Kestrel", domain="kestrel.test")
other_org = Organization.objects.create(name="Contoso", domain="contoso.test")
outsider = User.objects.create_user(
username="outsider", email="outsider@kestrel.test", password="testpass123"
)
OrganizationMember.objects.create(organization=home_org, user=outsider, role="member")
# Shared reference data: `threat_model` null, every tenant reads it.
StandardFramework.objects.create(
slug="soc2", name="SOC 2", version="2017", issuer="AICPA"
)
# One tenant's own standard, attached to that tenant's threat model.
other_model = ThreatModel.objects.create(name="Contoso ledger", organization=other_org)
StandardFramework.objects.create(
slug="contoso-internal",
name="Contoso Internal Control Standard",
version="1.0",
issuer="Contoso",
threat_model=other_model,
)
client = APIClient()
client.credentials(
HTTP_AUTHORIZATION=f"Bearer {RefreshToken.for_user(outsider).access_token}"
)
```
As a member of Kestrel only:
```
GET /api/frameworks/ -> 200
["Contoso Internal Control Standard", "SOC 2"]
```
Expected: `SOC 2` present, `Contoso Internal Control Standard` absent. Written as two
assertions, the reference-data half passes and the tenant half fails.
## Fix
Narrowing the queryset closes the read, and has to keep serving the null-`threat_model`
rows to everyone.
The reason it was open is that the table cannot say which of its rows belong to a tenant.
Splitting it is step 2 of `docs/design/tenancy-boundary.md`; this issue is the read that is
open until then.
Contributor guide
Research direction
Read backend/apps/compliance/views.py:20 alongside the StandardFramework relationship in backend/apps/compliance/models.py:22, then run the reproduction in the issue. Verify the framework list still includes rows with a null threat_model while excluding rows attached to another organization, and add regression coverage for both assertions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- django, python
- Domain
- api, backend, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100