# 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.

![](https://cdn.hashnode.com/uploads/covers/65102b1d5866800ea27ebefa/3085e635-2ec7-4e97-8068-56cb459fa90d.png align="center")

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](https://github.com/tinacms/tinacms/security/advisories/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:

```bash
npm view next-tinacms-s3 dist-tags
```

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

```plaintext
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.

```plaintext
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.

```plaintext
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:

```plaintext
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:

```plaintext
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

```plaintext
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:

```ts
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:

```json
"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:

```ts
// 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.
