All posts
Forward Development

Medusa 2.20.1 closes a field-filter data exposure — patch now

Medusa 2.20.1 makes field filters strip disallowed fields without the RBAC flag. What the gap exposed, how to upgrade from 2.19.x, and how to test it.

  • Medusa.js
  • Migrations
  • Operations

You added allowed to a custom route's query config, or listed a few sensitive columns under http.restrictedFields, and treated the problem as closed. The config is in the repo. It reads like a control. On Medusa before 2.20.1 it was, on most installs, dead code — the allowed query config and the http.restrictedFields config were only applied when the RBAC feature flag was enabled. Flag off, no stripping. The API returned whatever the query and the underlying module could produce.

Medusa 2.20.1 removes the flag requirement. The fix is listed as "Always strip fields that a field filter doesn't allow" (#16704), and the release notes are unambiguous about the priority: "We highly recommend updating to this version as this is a valuable security fix." There is no CVE or advisory ID attached, which matters if your patching is driven by advisory feeds rather than by someone reading release notes. Nothing is going to page you about this one.

A filter that never ran is worse than no filter at all

A missing check is a known gap. Somebody eventually greps for it. A configured check that silently no-ops is worse, because it satisfies the review, satisfies the checklist, and shows up in the diff as evidence that the concern was handled. Every subsequent decision — what you put in metadata, which columns you add to a custom module, which route you expose to the storefront — gets made on the assumption that the filter is doing its job.

So the real remediation here is not just the version bump. It is going back over the decisions you made while believing the filter worked.

What a field-filter bypass actually returns

Medusa shapes responses from the fields query parameter, constrained by what the route permits. The bypass means that constraint was not enforced, so the ceiling on a response becomes whatever the caller can name.

In practice that is two layers. First, every column on the entity the route resolves — including the ones you added yourself and never intended to publish. Second, everything reachable by expansion through link modules, because Medusa's query layer will happily traverse a link if you ask for it by path.

The fields that hurt, in rough order of how often they turn up:

  • metadata. Teams stash internal state there because it is free and untyped — supplier IDs, landed cost, fraud scores, migration bookkeeping, feature toggles. It is one JSON column and it comes back whole.
  • Customer PII on order- or cart-adjacent routes: email, phone, billing address, on a storefront endpoint that only needed line items and totals.
  • Cost and margin fields, price list rules, and anything you added to support B2B pricing.
  • Columns on custom modules built for ops — approval state, internal notes, integration cursors.

None of that requires an attacker. A curious customer with browser devtools and a guess at a field name is enough.

2.20.1 is the version that fixes it, not 2.20.0

The field-filter fix appears under 2.20.1's bug fixes. If your upgrade ticket says "get onto the 2.20 line," that is not precise enough — pin 2.20.1 or later. Whatever 2.20.0 contains, it does not contain this.

2.20.1 carries two other framework changes you should read before deploying, because one of them can break a build rather than a request:

  • Search: the correlated flag was removed until it is supported (#16703).
  • Schema: using JSON for index specification is now disallowed (#16692). If any custom module defines an index that way, expect it to be rejected after the upgrade and budget for a schema change plus a migration.

Upgrading from 2.19.x

Bump every @medusajs/* package in lockstep — framework, medusa, CLI, admin SDK, and any first-party modules. Mixed versions across that set produce failure modes that look nothing like their cause.

Then, in order: install, run npx medusa db:migrate, rebuild the admin, and boot locally before anything touches staging. Check third-party plugins separately; a plugin compiled against 2.19 framework internals is the most likely thing to break, and the field-filter change alters what routes return, which is exactly the kind of thing a plugin can be quietly depending on.

Verify the fix directly rather than trusting the version string. Pick a route where you configured allowed, and ask for a field that is not on the list:

curl -s "$STORE_URL/store/your-route?fields=id,metadata" \
  -H "x-publishable-api-key: $PK" | jq 'keys'

Before 2.20.1 with the RBAC flag off, metadata comes back. After, it should be gone. Do the same against an admin route with a restricted field. If a field you expected to be stripped is still present after the upgrade, the problem is your route config, not the framework — which is now something you can actually find out.

The surfaces worth re-checking first

Work through these in priority order, because the upgrade closes the framework gap but does not tell you where your configuration was wrong to begin with:

  1. Custom API routes under src/api that set a query config, and any that don't — an unconfigured route was never protected by either mechanism.
  2. Middlewares that build query config dynamically. A conditional that was never exercised is a conditional that was never tested.
  3. Link modules. Every link is an expansion path from one entity to another; enumerate them and ask what the far end exposes.
  4. metadata on every entity a storefront route can reach. If it holds anything you would not print on the packing slip, move it to a typed column on a module you control, and restrict it.
  5. Cached responses. If a CDN or Redis layer cached over-broad payloads, upgrading does not evict them. Purge after deploy.

Add a regression test so the next one fails in CI

This class of bug — config present, enforcement absent — is invisible to code review and visible to a test. Assert on the response key set, not on the absence of one field:

it("never returns restricted fields, whatever is requested", async () => {
  const res = await api.get(
    "/store/your-route?fields=id,metadata,customer.email",
    storeHeaders
  )

  expect(res.status).toEqual(200)
  expect(Object.keys(res.data.your_entity[0]).sort()).toEqual(["id", "title"])
})

Write one per publicly reachable custom route and request the fields you most want never to leave the server. It runs in a second and it fails loudly the next time an upgrade, a flag, or a refactor changes enforcement behavior underneath you. That is the durable fix; the version bump is the immediate one.

Do this before your next deploy

Pin @medusajs/* to 2.20.1 or later, run migrations, and verify with a real request against a route you configured. Then spend an hour on the metadata audit and add the regression tests, because that is where the actual exposure lives and no release will close it for you.

If you are mid-migration and this landed while you were still deciding on a target version, pin the 2.20.1 floor in your plan now rather than discovering it at cutover — we cover that sequencing in our Magento 2 and Adobe Commerce to Medusa migration work. If you want a second pair of eyes on which of your routes and links are actually reachable from the storefront, get in touch.

Need this done on a real stack?

Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.

Contact us