Skip to main content

Command Palette

Search for a command to run...

mediaRoot Was Not A Wall

Updated
19 min readView as Markdown
mediaRoot Was Not A Wall

How we use Codex to find vulnerabilities worked end to end on CVE-2026-59992

Broken access control in next-tinacms-s3 and its three sibling media adapters GHSA-8mq9-5fw2-5rm4

I. Introduction

TinaCMS is an open-source, Git-backed headless CMS the "Contentful replacement, but your content lives in your repo" pitch. Editors get a visual editing layer bolted onto a Next.js/Astro site; every save becomes a commit. It's ~13.7k stars, TypeScript, and a large pnpm monorepo where the CMS core, the CLI, the GraphQL layer, and the storage adapters all ship as separate npm packages from one tree.

The part that matters here is the media adapters. Content lives in Git, but images and files don't those go to object storage, and TinaCMS ships first-party adapters for four backends: next-tinacms-s3, next-tinacms-dos (DigitalOcean Spaces), next-tinacms-azure, and next-tinacms-cloudinary. You mount one as a Next.js API route, hand it a cloud credential, and give it an authorized() callback that decides who counts as an editor.

That's the trust boundary. The adapter runs server-side with a long-lived storage credential, and it accepts object keys from the browser. The blast radius of a bug there is every object that credential can touch which, per TinaCMS's own documented IAM policy, is the entire bucket.

Vulnerable versions: next-tinacms-s3 ≤ 23.0.3, next-tinacms-dos ≤ 23.0.3, next-tinacms-azure ≤ 14.0.3, next-tinacms-cloudinary ≤ 26.0.3.

  • Advisory: GHSA-8mq9-5fw2-5rm4

  • CVE: CVE-2026-59992

  • CWE: CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-862 (Missing Authorization)

  • Merged fix: d44558e9b (PR #7088), opened and merged 2026-06-22; released to npm 2026-07-01.

One correction worth making up front: the advisory's version metadata reads "affected <= 21.0.3, patched 21.0.3," which is self-contradictory and doesn't match the release train. I checked the published tarballs: next-tinacms-s3@23.0.3 does not contain the fix, and 23.0.4 (2026-07-01) does. If you're pinning off the advisory, pin off the tarball instead.

This post is mostly about method. The bug is a good bug, but it isn't a clever one and that's the point. It survived in four packages for years because finding it required someone to be exhaustive rather than insightful. That is exactly the shape of work worth handing to an agent, and section III is the playbook we actually use.

II. Target selection

Target selection is the one part of this that an agent does not do for you, so it's worth being explicit about the signal.

TinaCMS publishes a preview build for every branch, tagged on npm. Which means:

npm view next-tinacms-s3 dist-tags

returns several hundred tags, each one a branch name. Scrolling that list:

security/graphql-path-traversal          0.0.0-b746611-20260209230655
ig/security-path-traversal-fixes         0.0.0-6f542e7-20260220043916
ig/security-patch-cors-vite-strict-mode  0.0.0-c63542a-20260224051215
security/publish-hardening               0.0.0-ffbb4fa-20260624122203

That is a public, timestamped index of the maintainers' private security work. It doesn't leak the bug but it tells you three things that are worth more than any single advisory:

  1. This project has an active path-traversal problem class, confirmed by the people who own the code.

  2. They were remediating it in the GraphQL and core layers those branch names say where they were looking.

  3. Nothing in that list mentions the media adapters.

So the hypothesis wrote itself: a team that is currently fixing path traversal in its core is a team that has the bug, has the awareness, and has a good chance of not having swept the peripheral packages yet. Adapters are exactly where invariants go to die they get written once per backend, then copy-pasted to the next backend, and nobody re-derives the security model on copy three.

Four adapters, one shape, written years apart. That's the target, and the question to put to the agent is:

Which of the four copies of this handler enforce the boundary the docs promise, and on which code paths?

Notice what that question is not. It is not "find vulnerabilities in this repo."

III. How we use Codex

The economics: exhaustiveness, not insight

An agent is not better than you at noticing that something smells wrong. It is better at not getting bored.

That distinction decides everything about how you prompt it. The bugs that survive in a mature, well-reviewed codebase are almost never subtle they are in the ninth of twelve places a pattern appears. Human review reliably covers places one through three, gets the gist, and moves on. Nobody reads the fourth copy of an adapter with the same attention they gave the first.

So we never ask the model to be clever. We ask it to be complete, and we structure every prompt so that incompleteness is visible.

Setup

Three things before any prompting, all of which pay for themselves:

Pin the tree. git clone, then git checkout <sha>. Every claim the agent makes is against a commit hash you can hand a maintainer. "It's on main" is not a report; 2e1be0b114 is.

Feed it the documentation, not just the code. This is the highest-value setup step and the most commonly skipped. The README, the config reference, and the setup guide define what an operator believes the security model is. The code defines what it is. Reportable bugs live in the gap between those two documents. In this hunt, the entire severity argument came from the README: TinaCMS's own recommended IAM policy grants write and delete on arn:aws:s3:::<bucket>/*, which is what turns "missing input validation" into "bucket-wide".

Keep it read-only during the audit. Ask for findings, not patches. An agent that has started writing a fix has committed to the bug being real, and it will defend that position for the rest of the session.

Pattern 1: Inventory: a table, with security talk banned

The first pass produces facts and nothing else.

For each of packages/next-tinacms-{s3,dos,azure,cloudinary}/src/handlers.ts,
list every call into the storage SDK (PutObjectCommand, DeleteObjectCommand,
getSignedUrl, getBlockBlobClient, cloudinary.uploader.*).

For each one output TSV: package, HTTP method, the exact expression that
produces the object key, and every transformation applied to it between the
request and the SDK call.

Cite file:line. Do not summarize. Do not comment on security.

That last line is load-bearing. The moment you say "find vulnerabilities," the model starts optimizing for producing text that looks like a vulnerability report, and you get four confident paragraphs about a keyExists() race that doesn't matter. Ask for a table with a fixed schema and it optimizes for filling the table.

Sixteen rows came back. A spreadsheet, not an opinion.

Pattern 2: Asymmetry: the query with a fixed row count

This is the core move, and it's the one worth taking away from this post.

mediaRoot is an operator-configured setting. Build a matrix:
rows = the four packages, columns = {list, upload, delete}.

In each cell put the exact expression that applies mediaRoot to the key on
that path, or the literal string NONE.

Cite file:line for every cell.

The property that makes this work is that the answer has a fixed shape. Twelve cells. The agent cannot get bored and stop at three examples, cannot summarize, cannot hedge every cell is either an expression or the word NONE, and a missing cell is instantly visible.

Compare that to "audit these four files for authorization bugs," which has no shape at all, and whose answer is complete exactly when the model decides it feels complete.

The matrix came back with NONE in eight of twelve cells. Every list cell had a real expression. Every upload and delete cell across all four adapters was NONE.

That is the finding. It took one prompt, and it was legible in about four seconds, because it's a table.

The same pattern generalizes as a set difference whenever the two sides are sets rather than a grid:

Set A = every route/handler that admits <dangerous capability>.
Set B = every one that calls <the guard>.
Give me A \ B, and for each row name the specific data it exposes.

Either shape works. What matters is that you have converted "is this safe?" an open question the model answers creatively into an enumeration whose row count you know in advance.

Pattern 3: Citation discipline

Every row, every cell, cites file:line. This does two things:

  • It collapses verification cost. Checking a cited claim is one sed -n '389,400p'. Checking an uncited claim means re-reading the file yourself, at which point the agent saved you nothing.

  • It suppresses invention. A fabricated cell needs a fabricated line number, and a fabricated line number does not survive the first spot check. Models hallucinate much less when the output format makes hallucination cheap to catch and in practice you only need to spot-check a sample before you trust the shape of the table.

Pattern 4: The rebuttal pass

Before touching Docker, before writing anything up, turn the agent against your own hypothesis:

Argue this is NOT exploitable. Is mediaRoot enforced by an outer middleware,
by the client before it calls the API, by the presigned-URL policy itself, or
by the IAM credential? Find the control I missed.

Phrase it as a command to find the control, not as "is this a bug?" asked neutrally, a model will mostly agree with whatever you seem to want. Given a specific adversarial job, it does the job.

This pass has exactly two outcomes and both are wins. Either it kills your finding for free, before you spent an afternoon on a PoC or it hands you, in advance, the precise sentence a maintainer would otherwise have written in the triage reply. Here it surfaced the authorized() callback and the keyExists() check. Neither is a boundary, but knowing that going in is what let the report pre-empt both.

The rules that keep it honest

Five, and we break none of them:

  1. The agent's conclusion is never the finding. Its output is a lead. The finding is what survived execution.

  2. Ban security adjectives in enumeration passes. No "unsafe", no "vulnerable", no "attacker-controlled" until the table is built.

  3. Every prompt has a knowable answer size. A matrix, a set difference, a row per file. If you can't predict the row count, you can't detect a short answer.

  4. Every phase ends in an artifact on disk a TSV, a matrix, a citation list not a narrative in a chat window. Narratives can't be diffed, and you cannot rerun them against next month's commit.

  5. Execute before reporting. Always.

What Codex will not do for you

An honest list, because the failure modes are all in the gaps:

  • Choose the target. Nothing in section II came from an agent. The npm dist-tags signal, the judgement that adapters are a weak spot, the decision to look at mediaRoot specifically that's the actual work.

  • Know what an operator believes. The model can tell you mediaRoot isn't applied on the write path. It cannot tell you that operators read it as containment, or that the vendor's README provisions a bucket-wide credential. That reasoning is what moved this from "input validation nit" to CVE.

  • Score severity. Models inflate. Left alone it would have argued C:L on "you can enumerate keys." That's a weak argument, and shipping one weak component discredits the strong ones.

  • Recognise a non-control. It listed keyExists() as a check, because it is one just not the kind that matters. Distinguishing a collision check from an authorization check is a judgement about intent.

  • Disbelieve itself. It will confirm a plausible-sounding hypothesis unless you explicitly pay for the rebuttal pass.

The split, stated plainly: the agent built the matrix and cited the lines; choosing which matrix to build was the job.

IV. What the matrix found

Data flow

GET /api/s3/media?key=<attacker>&expiresIn=<attacker>
  Cookie: <session of ANY logged-in editor>

  → createMediaHandler()                     packages/next-tinacms-s3/src/handlers.ts
      const isAuthorized = await config.authorized(req, res)   // "are you an editor?" yes
      if (!isAuthorized) return 401

      case 'GET':
        const expiresIn = Number(req.query.expiresIn) || 3600   // caller-controlled, uncapped
        const s3_key    = req.query.key                         // caller-controlled, RAW
        if (await keyExists(client, bucket, s3_key)) return 400  // collision check, not a boundary
        const signedUrl = await getUploadUrl(bucket, s3_key, expiresIn, client)
        return res.json({ signedUrl, src: cdnUrl + s3_key })
                            ↓
                   PutObjectCommand({ Bucket: bucket, Key: s3_key })

and the delete path, four lines long:

async function deleteAsset(req, res, client, bucket) {
  const { media } = req.query;
  const [, objectKey] = media as string[];      // second URL segment, raw

  const params: DeleteObjectCommandInput = { Bucket: bucket, Key: objectKey };
  const command = new DeleteObjectCommand(params);
  ...
}

mediaRoot is a parameter of createMediaHandler. It is normalised at the top of the factory, carefully trailing slash added, leading slash stripped. It is then passed to listMedia and to nothing else. deleteAsset didn't even take it as an argument.

Guard analysis

Three things in that handler look like controls. None of them is one:

  • config.authorized(req, res) answers "is this person an editor?" It's a boolean about the principal, evaluated once, before the request is parsed. It knows nothing about the key. In a multi-tenant or multi-author install the case TinaCMS explicitly supports every editor passes it.

  • keyExists() answers "would this upload overwrite something?" It's a collision check, and it fails open in the direction that matters: it returns false on a 403 from S3, and it returns false for every key that doesn't exist yet which is precisely the set of keys an attacker wants to create.

  • stripMediaRoot() is a display helper. It removes the prefix from keys on the way out to the UI so filenames render nicely. It is not, and was never, an input filter.

And mediaRoot itself is the one that matters:

mediaRoot was a display prefix that operators read as a containment boundary.

  • The read path honoured it: listMedia builds Prefix: path.join(mediaRoot, directory), so the media browser only ever shows you what's under it.

  • The write and delete paths never heard of it.

An operator configuring mediaRoot: 'media/' sees the CMS confined to media/ in every screen the CMS renders. The confinement is real on exactly the one code path that can't do damage.

Call it enforce-on-read, trust-on-write. It's the same shape as validate-before-canonicalize, one layer up: the guard is real, and it's installed on the door nobody attacks.

The corollary is the reason this scores as high as it does. The credential's reach is not incidental it's documented. TinaCMS's own next-tinacms-s3 README recommends an IAM policy granting s3:PutObject, s3:PutObjectAcl and s3:DeleteObject on:

"Resource": "arn:aws:s3:::<S3-Bucket-NAME>/*"

Bucket-wide. So the missing key check doesn't just fail to enforce mediaRoot it hands an editor exactly the reach the vendor's own setup guide provisions.


V. Proof, and impact

Four exhibits, each against a stock adapter with mediaRoot: 'media/' configured, using a session for a plain editor account with no special role:

# Request Result
1 GET /api/s3/media?key=index.html Presigned PUT URL for the bucket root outside mediaRoot entirely
2 GET /api/s3/media?key=tenant-victim/posts/announcement.mdx Presigned PUT into another tenant's content prefix
3 DELETE /api/s3/media/<anything>/<arbitrary-key> DeleteObjectCommand issued against a non-media object
4 GET /api/s3/media?key=x&expiresIn=604800 Signed URL valid for 7 days SigV4's ceiling, not the documented 1 hour

Exhibit 4 is the one I'd flag to anyone reproducing this. On its own, an uncapped expiresIn looks like a nitpick. Chained to exhibit 1 it stops being one: a momentary foothold in an editor session a borrowed laptop, a stolen cookie with minutes left on it, a compromised contractor account that gets revoked the next morning converts into a week-long offline write primitive against arbitrary bucket keys. The presigned URL doesn't care that the session is gone. Revoking the account doesn't revoke the URL.

The same PoC shape reproduces on next-tinacms-dos line for line. Azure and Cloudinary have the same missing check with different SDK verbs.

Impact

Any authenticated editor can write and delete arbitrary objects across the entire bucket:

  • Cross-tenant tampering. In a multi-tenant install, one tenant's editor writes into another tenant's content prefix. Since TinaCMS content is Markdown/MDX in object storage for some deployments, that's content injection, not just file litter.

  • Defacement. Exhibit 1 writes index.html at the bucket root. On any of the very common "bucket is also the static origin" setups, that's the site.

  • Stored XSS. The presigned PUT lets the client choose its own Content-Type. Upload HTML or JS to a bucket served from the site's own origin and it executes there.

  • Destruction. Arbitrary DeleteObject on non-media keys, with no undo.

  • Persistence. Exhibit 4, above.

AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L = 5.4 Moderate. Note C:N I scored no confidentiality impact, because the listing path was the one place mediaRoot was enforced, so this doesn't hand you a read primitive. It would have been easy to argue C:L on "you can enumerate by guessing keys." That argument is weak, and shipping a weak component in an otherwise solid report is how you get the whole thing discounted.

VI. The fix

The report bundled the systemic problem, not just the S3 sink: five numbered issues (SEC-1 … SEC-5), with the shared root cause called out as "four copies of one missing check" rather than four separate bugs.

The maintainers' fix, PR #7088, centralises it. A new resolveKey(mediaRoot, rawKey) helper plus resolveDirectory() for the listing path is wired into every write and delete entry point across all four adapters:

// packages/next-tinacms-s3/src/media-key.ts
if (decoded.includes('\0') || decoded.includes('\\'))
  throw new MediaKeyError('media key is not valid');
if (decoded.startsWith('/') || /^[a-zA-Z]:/.test(decoded))
  throw new MediaKeyError('absolute media keys are not allowed');

const normalized = path.posix.normalize(decoded).replace(/^\.\//, '');
if (normalized === '..' || normalized.startsWith('../'))
  throw new MediaKeyError('media key may not traverse directories');

const scoped = normalized === root || normalized.startsWith(root + '/')
  ? normalized
  : path.posix.join(root, normalized);

// Defence in depth: the resolved key must stay within mediaRoot.
if (scoped !== root && !scoped.startsWith(root + '/'))
  throw new MediaKeyError('media key escapes mediaRoot');

Three details in there are worth stealing for your own code:

  • Normalise, then check and then check the result again. The traversal rejection and the final startsWith(root + '/') assertion are redundant by design. The second one catches whatever the first one's normalisation didn't anticipate.

  • Decoding is a parameter, not a default. { decode: false } exists because Next already decodes req.query once, and decoding twice would rewrite report%41.png into reportA.png and reject the legitimate filename 100%off.png. Double-decode is a classic way to turn a path fix into a correctness bug; making it explicit per call site is the right call.

  • stripSlashes() is a hand-rolled loop, not a regex deliberately, to avoid polynomial backtracking on an attacker-supplied run of slashes. Fixing an access-control bug is not a good moment to introduce a ReDoS.

The helper is duplicated byte-for-byte across the four packages, with a shared test suite asserting the copies stay identical. That's an unusual choice and I think a correct one: a shared internal package would have created a cross-package publish dependency in a monorepo that releases adapters independently. They took the duplication and paid for it with an anti-drift test 110 cases in tests/media-key.test.ts.

What shipped, and what didn't

The PR description tracks the report's own numbering, which makes the remediation auditable from the outside:

Item Status
SEC-3 key scoping on upload/delete, all four adapters ✅ Shipped in #7088
expiresIn clamp default and max 3600s ✅ Shipped in #7088
SEC-5 Cloudinary listMedia interpolating directory into a Search-API expression ✅ Shipped later, PR #7394 (2026-08-06)
SEC-1 constrain upload Content-Type (stored XSS via uploaded HTML/SVG) ❌ Still open on S3; payload is now confined to mediaRoot
SEC-2 real mediaRoot boundary for Azure and Cloudinary (needs new config) ❌ Still open; both pass an empty root today
SEC-4 README should scope the IAM policy to mediaRoot/* ❌ Still open; the README still documents arn:aws:s3:::<bucket>/*

SEC-4 is the one I'd push hardest on if I were doing it again. The code fix removes the bug; scoping the documented IAM policy removes the blast radius, and it's a three-line docs change. Defence in depth is worth the most when it's cheapest, and a report that says "here is the patch, and here is the one-line docs change that would have made the patch unnecessary" is a report a maintainer remembers.

VII. Timeline

Date Event
2026-06-DD Target selected from npm dist-tags leaking active security/* branch names path traversal confirmed as a live class, adapters visibly untouched.
2026-06-DD Inventory pass, then the mediaRoot enforcement matrix: four adapters × three paths. Eight of twelve cells NONE. Rebuttal pass run. Four exhibits reproduced.
2026-06-DD Reported privately via GitHub Security Advisories as five numbered issues with a shared root cause.
2026-06-22 Advisory accepted; PR #7088 opened and merged the same day centralised resolveKey() helper across all four adapters plus 110 regression cases.
2026-06-22 GHSA-8mq9-5fw2-5rm4 published, Moderate, CVSS 5.4, credited to StarPlatinu.
2026-07-01 Released to npm: next-tinacms-s3@23.0.4, next-tinacms-dos@23.0.4, next-tinacms-azure@14.0.4, next-tinacms-cloudinary@26.0.4.
2026-08-06 Follow-up SEC-5 shipped in PR #7394 Cloudinary search-expression escaping.
CVE-2026-59992 assigned.

VIII. What generalizes

The method, in five lines you can apply to any target tomorrow:

  1. Read the package registry, not just the repo. A monorepo that publishes per-branch preview builds is publishing its branch names. npm view <pkg> dist-tags is a timestamped map of the maintainers' in-flight security/* work the bug classes they've confirmed, and by omission, the packages they haven't swept. Free, public, and nobody looks at it.

  2. Point the agent at repeated implementations. Any time a project ships four implementations of one interface, the security model was designed for implementation one and copy-pasted into the rest. Build the matrix implementations × code paths and look for the asymmetry, not the bug.

  3. Convert every question into one with a knowable answer size. Matrix or set difference. "Is this safe?" has no shape; "twelve cells, each an expression or NONE, each cited" has exactly one.

  4. Pay for the rebuttal pass. It either kills the finding for free or writes your rebuttal to the triage reply in advance.

  5. A config value is not a boundary until you've found the code that enforces it on every path. Enforced on read is not enforced.

And the one that isn't about tooling: score down, and say why. C:N on this report was a giveaway I made deliberately. A finding with one obviously-inflated component invites the maintainer to re-litigate all of them.