CVE-2026-59992 is a medium-severity (CVSS 5.4) CWE-639 vulnerability in next-tinacms-s3. O3 Security confirms whether CVE-2026-59992 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Tina: Broken Access Control: arbitrary bucket-key write/delete in `next-tinacms-s3` (and sibling production media adapters)
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
next-tinacms-s3npmnext-tinacms-dosnpmnext-tinacms-azurenpmDescription
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 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/[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 against a real bucket without further help.
PoC layout under /Users/admin/joplin_research/tinacms-s3-poc/:
package.json (deps only)
handler-vendored.mjs (verbatim copy of createMediaHandler, types stripped)
poc.mjs (four exhibits exercising the handler)
Handler is configured the way the package's README documents:
const handler = createMediaHandler({
config: { region: 'us-east-1', credentials: { accessKeyId: '…', secretAccessKey: '…' } },
bucket: 'editor-media-bucket',
mediaRoot: 'media', // editors are "scoped to media/"
authorized: async () => true, // typical wiring: TinaCloud verified user
});
Run:
$ cd /Users/admin/tinacms-s3-poc && node poc.mjs
--- Exhibit A: presigned PUT URL for arbitrary bucket key ---
HTTP status : 200
signedUrl : https://editor-media-bucket.s3.us-east-1.amazonaws.com/index.html?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAFA...
src : https://editor-media-bucket.s3.us-east-1.amazonaws.com/index.html
signed Key : /index.html
RESULT : BYPASS (signed URL writes outside media/)
--- Exhibit B: cross-tenant overwrite via attacker-chosen key ---
signed Key : /tenant-victim/posts/announcement.mdx
RESULT : BYPASS (cross-tenant write primitive granted)
--- Exhibit C: arbitrary-key DELETE via [...media] route ---
HTTP status : 200
S3 Bucket : editor-media-bucket
S3 Key : tenant-victim/posts/announcement.mdx
RESULT : BYPASS (arbitrary-key DELETE granted)
--- Exhibit D: attacker-controlled ?expiresIn ---
X-Amz-Expires: 604800 sec
RESULT : BYPASS (URL valid far longer than the 1h default)
Verification, in detail:
-
Exhibit A —
GET /api/s3/media?key=index.html.mediaRoot=media/is configured but never consulted on the write path. The handler hands back a SigV4 URL whose path is/index.html. PUT'ing any body to that URL would create the bucket-root objectindex.htmlagainst the operator's IAM credentials. For a deployment that fronts the bucket with CloudFront / S3-website hosting, this is a one-shot site-defacement primitive (and, for any CSP that trusts the bucket, a one-shot stored-XSS primitive against the published site). -
Exhibit B — same primitive, attacker-chosen key
tenant-victim/posts/announcement.mdx. In the multi-tenant TinaCloud / TinaCMS Cloud deployment shape (one bucket shared across tenants, each tenant assigned amediaRoot/<tenant>/prefix), a Tenant A editor receives a presigned PUT URL for any Tenant B key. Cross-tenant content tampering with no further bug needed. -
Exhibit C —
DELETE /api/s3/media/anything-here-is-ignored/tenant-victim%2Fposts%2Fannouncement.mdx. The handler reads the catch-all asreq.query.media = ['anything-here-is-ignored', 'tenant-victim/posts/announcement.mdx'], destructures toobjectKey = media[1], dispatchesDeleteObjectCommand({ Bucket: 'editor-media-bucket', Key: 'tenant-victim/posts/announcement.mdx' }). The mock confirms the captured command'sKeyis the attacker-supplied string. Real S3 will honour the same call. (The mock-captured call confirms the handler's behaviour; the real S3 call requires onlys3:DeleteObjecton the bucket, which the package'sREADMEinstructs operators to grant.) -
Exhibit D —
?expiresIn=604800(7 days, the SigV4 ceiling). The handler accepts it, the resultingX-Amz-Expires=604800. Combined with Exhibit A, an attacker who briefly compromises a CMS session (e.g. via a stolen idle browser session or the dev-server CSRF write described in finding 06) can mint a long-lived offline write primitive — usable for a week with no further interaction with the CMS.
The four exhibits together: cross-mediaRoot write, cross-tenant write, arbitrary-key delete, and long-lived signed-URL minting.
Impact
- Cross-tenant / cross-mediaRoot write. In any deployment where the bucket holds anything other than this one editor's
mediaRoot(multi-tenant TinaCloud, shared corporate bucket, bucket-backed static site), an authorized editor obtains write authority over every key the IAM credential can reach. Concretely: defacement of a static site fronted by the bucket; cross-tenant content tampering; replacement of*.html,*.js,*.jsonassets that downstream consumers trust. (Exhibits A, B;handlers.ts:67-90.) - Stored XSS via uploaded HTML. S3 returns objects with the
Content-Typethe upload supplies. The presignedPutObjectCommandURL carries noContent-Typeconstraint — the attacker'sPUTchooses it. An editor can therefore write an HTML / SVG / JS payload to the bucket-root, and any CDN or origin that serves bucket objects directly to an authenticated SaaS surface inherits stored XSS in that origin. - Arbitrary-key delete (data destruction). Through
[...media]the handler treats the second URL segment as a free-form object key. An editor can wipe any object the IAM key cans3:DeleteObject, including non-media artefacts like deployment manifests, backups, or other tenants' files. (Exhibit C;handlers.ts:199-223.) - Bucket-policy implications. The
README(lines 38-90) tells operators to grant the IAM keys3:PutObject,s3:PutObjectAcl,s3:DeleteObject, ands3:ListBucketon the whole bucket. The README implies the application code constrains the editor's actions tomediaRoot. Because the application code does not, every operator who follows the README has — without realising — granted every CMS editor full write/delete on the whole bucket. - Long-lived offline write primitive.
expiresInis attacker-controlled and not capped by the handler. A single GET request mints a URL valid for up to 7 days (AWS SigV4 ceiling). An attacker who briefly compromises a CMS session can stash one such URL and continue writing arbitrary objects long after the session is revoked. (Exhibit D.) - Same-shape exposure on every other first-party media adapter.
next-tinacms-dos(DigitalOcean Spaces, S3-compatible),next-tinacms-azure(Blob Storage), andnext-tinacms-cloudinary(Cloudinary) all duplicate the broken pattern. Azure has no mediaRoot config at all, so the boundary thatnext-tinacms-s3advertises but doesn't enforce isn't even nominally present innext-tinacms-azure.
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | next-tinacms-s3 | all versions | 23.0.4 |
| 📦npm | next-tinacms-dos | all versions | 23.0.4 |
| 📦npm | next-tinacms-azure | all versions | 14.0.4 |
| 📦npm | next-tinacms-cloudinary | all versions | 26.0.4 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for next-tinacms-s3. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update next-tinacms-s3 to 23.0.4 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-59992 is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether CVE-2026-59992 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to CVE-2026-59992. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is CVE-2026-59992 in your dependencies?
O3 detects CVE-2026-59992 across npm dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.