# Tina: [Broken Access Control] letting any TinaCloud user authorize against any self-hosted site

TinaCMS's isAuthorized validated bearer tokens against a caller-supplied clientID, letting any TinaCloud user reach media and GraphQL APIs of unrelated self-hosted sites.

- Payout: Research
- Severity: high
- Program: TinaCMS
- Found by: riodrwn
- Disclosed: 2026-09-17
- CVE: CVE-2026-63506
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-g74q-6g2f-874x
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-g74q-6g2f-874x
- Tags: area:web domain:api domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:nextjs tech:nodejs technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** Next.js API routes (/api/cloudinary/media, /api/tina/gql) using @tinacms/auth
**Root cause:** isAuthorized reads clientID from req.query and validates the token against identity.tinajs.io/v2/apps/${clientID}/currentUser without comparing it to the site's own configured TinaCloud app id, so it answers 'is this token valid for the app the caller named' rather than 'for this site'.
**Steps:**
1. Create a free TinaCloud account and app; note its clientID (ATTACKER_APP) and bearer token (T_attacker).
2. Send `GET /api/cloudinary/media?clientID=ATTACKER_APP` with header `Authorization: T_attacker` to the victim self-hosted site.
3. The victim's authorized callback calls isAuthorized(req), which fetches `https://identity.tinajs.io/v2/apps/ATTACKER_APP/currentUser` with the attacker token, gets 200 + verified:true, and returns true.
4. For content backends using TinaCloudBackendAuthProvider, POST to `/api/tina/gql?clientID=ATTACKER_APP` with `{"query":"mutation($c:String!,$r:String!){deleteDocument(collection:$c,relativePath:$r){__typename}}","variables":{"c":"post","r":"hello.md"}}`.
5. Repeat POST upload / DELETE against the media handler for write and delete on the victim bucket.
**How found:** Source review of @tinacms/auth: noticed isAuthorized pulls clientID from req.query and forwards it verbatim to the identity server, and that the media-store README callbacks and TinaCloudBackendAuthProvider only check `user && user.verified` — never that clientID equals the site's configured app. Confirmed the same pattern duplicated in next-tinacms-azure and that the tinacms init wizard ships it by default.
**Exploitation:** Attacker needs only a free TinaCloud account and app; no victim account or interaction. They script requests with their own token and `clientID=<own app>` against known TinaCMS self-hosted endpoints, gaining media list/read/upload/delete (next-tinacms-dos writes ACL: public-read, enabling malware/phishing hosting under the victim CDN) and, with TinaCloudBackendAuthProvider, arbitrary GraphQL read/create/update/delete of victim content. Scales across deployments.
**Escalation:** Media read escalates to upload (public-read objects on victim CDN) and delete; the same gate on the GraphQL backend escalates to full content read/write/delete that deploys to production.
**Insight:** When an auth helper takes a tenant/app identifier from the request and forwards it to a central identity service, the service's correct scoping is irrelevant — grep for `req.query.*[Cc]lient|tenant|app` in auth helpers and check whether the caller-chosen value is ever compared to the deployment's own configured id before the boolean is trusted.

## Proof of concept (copied exactly from the original report)

**Media bucket read request with attacker clientID**

```
GET /api/cloudinary/media?clientID=ATTACKER_APP HTTP/1.1
Host: victim.example
Authorization: T_attacker
```

**GraphQL deleteDocument request with attacker clientID**

```
POST /api/tina/gql?clientID=ATTACKER_APP HTTP/1.1
Host: victim.example
Authorization: T_attacker
Content-Type: application/json
 
{"query":"mutation($c:String!,$r:String!){deleteDocument(collection:$c,relativePath:$r){__typename}}","variables":{"c":"post","r":"hello.md"}}
```

