GitHub Advisoryadvisory · npm
GHSA-8mq9-5fw2-5rm4 Tina: Broken Access Control: arbitrary bucket-key write/delete in `next-tinacms-s3` (and sibling production media adapters)
MEDIUM
Official page ↗
- Aliases
- CVE-2026-59992
- Ecosystem
- npm
- Packages
- next-tinacms-s3, next-tinacms-dos, next-tinacms-azure, next-tinacms-cloudinary
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L
- Weaknesses
- CWE-639, CWE-862
- Reviewed
- 2026-08-19
Affected packages
• npm next-tinacms-s3: < 23.0.4 → fixed in 23.0.4
• npm next-tinacms-dos: < 23.0.4 → fixed in 23.0.4
• npm next-tinacms-azure: < 14.0.4 → fixed in 14.0.4
• npm next-tinacms-cloudinary: < 26.0.4 → fixed in 26.0.4
Description
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: `[email protected]` handler logic, exercised against `@aws-sdk/[email protected]` via `[email protected]` (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 presigned `PutObjectCommand` URL with attacker-chosen `Key`.
• `packages/next-tinacms-s3/src/handlers.ts:199-223` — `DELETE` reads `[, objectKey] = media` and issues `DeleteObjectCommand` against attacker-chosen `Key`.
• `packages/next-tinacms-dos/src/handlers.ts:79-152` and `:249-278` — same write/delete pattern, plus a server-side upload that builds the key with `path.join(mediaRoot, prefix + filename)` over attacker-controlled `directory` and `filename`.
• `packages/next-tinacms-azure/src/handlers.ts:44-95` — `uploadMedia` writes `path.join(directory, filename)` with both fields attacker-controlled (no `mediaRoot` configured at all); `deleteAsset` deletes any blob in the container.
• `packages/next-tinacms-cloudinary/src/handlers.ts:193-204` — `cloudinary.uploader.destroy(public_id)` over attacker-chosen `public_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 `mediaRoot` boundary 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`:
```ts
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`:
```ts
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 builds `Key: mediaRoot ? path.join(mediaRoot, prefix + filename) : prefix + filename` over attacker-controlled `prefix` (from `req.body.directory`) and `filename` (from `multer`'s `file.originalname`); `path.join` collapses `..` segments, so a `directory` of `../..` plus a chosen `filename` lands 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` — `uploadMedia` writes a blob at `path.join(directory, filename)` with both values straight out of `formData`. `next-tinacms-azure` has no `mediaRoot` config at all (`AzureBlobStorageConfig` in `src/types.ts`), so the entire container is writable / deletable by any authorized user.
• `packages/next-tinacms-cloudinary/src/handlers.ts:193-204` — `deleteAsset` calls `cloudinary.uploader.destroy(public_id)` over the attacker-chosen second segment, deleting any asset in the cloud. The same handler's `listMedia` (line 110) interpolates `mediaListOptions.directory` directly 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/[email protected]`, `@aws-sdk/[email protected]`, `[email protected]`. 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 would PUT ag…
Related
Alias · CVE (1)
- CVE-2026-59992 Tina is a headless content management system. vulnerability
References
- https://github.com/tinacms/tinacms/security/advisories/GHSA-8mq9-5fw2-5rm4 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-59992 advisory
- https://github.com/tinacms/tinacms/pull/7088 web
- https://github.com/tinacms/tinacms/commit/d44558e9b4502d4f4fc2c970d22985339fe2b6ce web
- https://github.com/tinacms/tinacms package
- https://github.com/tinacms/tinacms/releases/tag/[email protected] web