Résumé
Tina: Broken Access Control: arbitrary bucket-key write/delete in `next-tinacms-s3` (and sibling production media adapters)
Détails de l’avis
Summary
The production media handler shipped by next-tinacms-s3 (createMediaHandler in packages/next-tinacms-s3/src/handlers.ts) accepts an attacker-chosen ?key= query parameter and returns an AWS-signed PutObject URL whose Key is that value, with no check that the key falls under the operator's configured mediaRoot. The same handler's DELETE branch reads objectKey = (req.query.media as string[])[1] and dispatches a DeleteObjectCommand for that exact key, again unbounded by mediaRoot. Any caller that passes the operator-supplied authorized() predicate — i.e. any logged-in CMS editor in a typical TinaCloud / self-hosted deployment — therefore has write and delete authority over the entire S3 bucket the IAM key can reach, even though the package documents mediaRoot as the place where editors are scoped. The same shape is present in next-tinacms-dos, next-tinacms-azure, and next-tinacms-cloudinary, so a single design mistake spans every first-party production media backend.
- Project: TinaCMS — first-party production media adapters (consumed by self-hosted Next.js sites and TinaCloud-backed deployments).
- Source reviewed:
tinacms/tinacms@main(b56dad4). - Deployed artefact validated:
next-tinacms-s3@21.0.3handler logic, exercised against@aws-sdk/client-s3@3.665.xviaaws-sdk-client-mock@4.1.0(the AWS SDK signs the URL identically whether the bucket is real or mocked). - Affected file(s):
packages/next-tinacms-s3/src/handlers.ts:67-90—GET ?key=returns presignedPutObjectCommandURL with attacker-chosenKey.packages/next-tinacms-s3/src/handlers.ts:199-223—DELETEreads[, objectKey] = mediaand issuesDeleteObjectCommandagainst attacker-chosenKey.packages/next-tinacms-dos/src/handlers.ts:79-152and:249-278— same write/delete pattern, plus a server-side upload that builds the key withpath.join(mediaRoot, prefix + filename)over attacker-controlleddirectoryandfilename.packages/next-tinacms-azure/src/handlers.ts:44-95—uploadMediawritespath.join(directory, filename)with both fields attacker-controlled (nomediaRootconfigured at all);deleteAssetdeletes any blob in the container.packages/next-tinacms-cloudinary/src/handlers.ts:193-204—cloudinary.uploader.destroy(public_id)over attacker-chosenpublic_id.
- CWE: CWE-639 — Authorization Bypass Through User-Controlled Key. Adjacent: CWE-284 (Improper Access Control), CWE-862 (Missing Authorization on the per-key authority check).
- OWASP 2021: A01:2021 — Broken Access Control (the operator's intended
mediaRootboundary is enforced only on listing, not on writes or deletes). Secondary: A04:2021 — Insecure Design (every adapter independently re-implements the same broken pattern).
Vulnerable code
packages/next-tinacms-s3/src/handlers.ts:39-98:
export const createMediaHandler = (config: S3Config, options?: S3Options) => {
const client = new S3Client(config.config);
const bucket = config.bucket;
let mediaRoot = config.mediaRoot || ''; // (1)
if (mediaRoot) { /* normalise to "media/" form */ }
return async (req: NextApiRequest, res: NextApiResponse) => {
const isAuthorized = await config.authorized(req, res);
if (!isAuthorized) {
res.status(401).json({ message: 'sorry this user is unauthorized' });
return;
}
switch (req.method) {
case 'GET':
if (req.query.key) {
const expiresIn: number =
(req.query.expiresIn && Number(req.query.expiresIn)) || 3600; // (2)
const s3_key = req.query.key
? Array.isArray(req.query.key) ? req.query.key[0] : req.query.key
: null;
if (!s3_key) return res.status(400).json({ message: 'key is required' });
if (await keyExists(client, bucket, s3_key)) {
return res.status(400).json({ message: 'key already exists' }); // (3)
}
const signedUrl = await getUploadUrl(bucket, s3_key, expiresIn, client); // (4)
return res.json({ signedUrl, src: cdnUrl + s3_key });
}
return listMedia(req, res, client, bucket, mediaRoot, cdnUrl); // (5)
case 'DELETE':
return deleteAsset(req, res, client, bucket); // (6)
packages/next-tinacms-s3/src/handlers.ts:199-223:
async function deleteAsset(req, res, client, bucket) {
const { media } = req.query;
const [, objectKey] = media as string[]; // (7)
const params: DeleteObjectCommandInput = { Bucket: bucket, Key: objectKey };
const command = new DeleteObjectCommand(params);
...
}
At (1) the operator configures mediaRoot (e.g. "media/"), and the package's README documents this as the directory the IAM key is scoped to. At (4) the handler signs a PutObjectCommand with Key: s3_key taken verbatim from req.query.key. Nothing between (1) and (4) verifies that s3_key starts with mediaRoot, so any path the IAM key can reach is fair game. The only filter is the keyExists check at (3), which prevents overwrite of an existing object but not creation of arbitrary new ones (and not overwrite of objects the IAM key cannot HeadObject). At (2) the validity window of the produced URL is also attacker-controlled, capped only by AWS SigV4's 7-day hard limit. At (5) the list path does prefix-join mediaRoot (Prefix: mediaRoot ? path.join(mediaRoot, prefix) : prefix), demonstrating that the boundary was understood to exist — it just isn't enforced on writes. At (6) + (7) delete extracts the second URL segment into objectKey with no prefix check, so DELETE /api/s3/media/x/anything-in-the-bucket is a valid arbitrary-key delete primitive.
For contrast, the same package's listMedia (line 139) does write Prefix: mediaRoot ? path.join(mediaRoot, prefix) : prefix, and stripMediaRoot (line 100) exists explicitly to peel mediaRoot off keys returned to the client — so the codebase models mediaRoot as a security boundary on the read side. The write and delete sides simply forgot to apply it.
The same misalignment is duplicated in three sibling packages:
packages/next-tinacms-dos/src/handlers.ts:115-125— upload buildsKey: mediaRoot ? path.join(mediaRoot, prefix + filename) : prefix + filenameover attacker-controlledprefix(fromreq.body.directory) andfilename(frommulter'sfile.originalname);path.joincollapses..segments, so adirectoryof../..plus a chosenfilenamelands at any key in the bucket. Lines 249-278 reproduce the S3 delete-by-second-segment pattern.packages/next-tinacms-azure/src/handlers.ts:44-95—uploadMediawrites a blob atpath.join(directory, filename)with both values straight out offormData.next-tinacms-azurehas nomediaRootconfig at all (AzureBlobStorageConfiginsrc/types.ts), so the entire container is writable / deletable by any authorized user.packages/next-tinacms-cloudinary/src/handlers.ts:193-204—deleteAssetcallscloudinary.uploader.destroy(public_id)over the attacker-chosen second segment, deleting any asset in the cloud. The same handler'slistMedia(line 110) interpolatesmediaListOptions.directorydirectly into a Cloudinary search-expression DSL string (folder="${directory}"), giving an authorized user full search-expression injection — out of scope for this finding but worth a separate report.
Reproduction (validated locally)
Environment: Node 22, @aws-sdk/client-s3@3.665.0, @aws-sdk/s3-request-presigner@3.665.0, aws-sdk-client-mock@4.1.0. The harness vendors createMediaHandler byte-for-byte from packages/next-tinacms-s3/src/handlers.ts and runs it against a mocked S3Client. The AWS SDK signs the URL identically whether the bucket exists — the resulting URL is the same one a real S3 deployment would hand back to the editor's browser, so it wou
Références
- https://github.com/advisories/GHSA-8mq9-5fw2-5rm4
- https://github.com/tinacms/tinacms/security/advisories/GHSA-8mq9-5fw2-5rm4
- https://github.com/tinacms/tinacms/pull/7088
- https://github.com/tinacms/tinacms/commit/d44558e9b4502d4f4fc2c970d22985339fe2b6ce
- https://github.com/tinacms/tinacms/releases/tag/next-tinacms-s3@23.0.4
- https://nvd.nist.gov/vuln/detail/CVE-2026-59992
Vulnérabilités liées
Tout Supply chain →- HIGHCVE-2026-63735
SurrealDB: Custom API route lets authenticated callers override namespace/database scope via URL path
- MEDIUMCVE-2026-63669
ApostropheCMS: Missing destination-parent authorization in page `move()` allows a low-privileged editor to move and re-rank pages inside a restricted subtree
- HIGHCVE-2026-81892
EasyAdmin custom-action dispatcher bypasses access_control on other routes
- MEDIUMCVE-2026-54746
Hatchet allows cross-tenant write/DoS to other tenants' workers via Dispatcher gRPC UpsertWorkerLabels and Unsubscribe
- MEDIUMCVE-2026-61663
django CMS: Missing authorization in `render_object_structure` discloses non-PageContent placeholder structure to low-privileged staff
- MEDIUMCVE-2026-63003
django CMS: Broken access control in page *Duplicate* allows reading the content of any page (cross-site / restriction bypass)