Skip to content

fix(ep-commerce): stop the shopper's EP token reaching the browser - #422

Merged
field123 merged 8 commits into
masterfrom
fix/ep-282-remove-browser-token-leak
Aug 14, 2026
Merged

fix(ep-commerce): stop the shopper's EP token reaching the browser#422
field123 merged 8 commits into
masterfrom
fix/ep-282-remove-browser-token-leak

Conversation

@field123

@field123 field123 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #282.

The shopper's Elastic Path access token was reaching the browser. Elastic Path authorises on bearer possession alone, so whoever holds that 40-character string is the shopper — the whole design exists to keep it out of the browser.

It was escaping by two routes. The issue names the first; the second turned up while verifying the fix for it, and a third variant came out of independent review.

PushproviderProps() returned the token, the catchall page handed it to globalContextsProps, and Plasmic serialized it into the page HTML. It now returns {}.

Pull — every endpoint on the auth handler returns the session record, and the session carries the shopper's EP credentials. Mount the handler with createEpAuthRoutes(epAuth) rather than better-auth's toNextJsHandler. /ep/refresh was the sharpest: it rotates the token and returns the new value, so a script got the token the session then used. /ep/anonymous needs no cookie at all.

The session is filtered to an allowlistid, userId, expiresAt, createdAt, updatedAt, epCartId, epExpires — on every response, not a denylist on one route. That matters: the first attempt named three fields to strip and missed epAccountToken, the account-management credential set at login and strictly stronger than the anonymous token. An allowlist withholds fields nobody has invented yet. epCartId is kept because the checkout components read it and it is not a credential; the better-auth session id is withheld, because it lives in an HttpOnly cookie precisely so scripts cannot read it.

The encrypted cookie was never the weak point — better-auth.session_data is a genuine JWE (A256CBC-HS512), verified by decoding it. Both leaks were application code copying the plaintext out of it into somewhere readable.

Breaking changes

Cart and checkout now always run through the server routes. The browser-direct EP SDK cart path and the serverCartMode toggle that selected it are gone, along with the four cart/use-* hooks that drove it — all four called EP directly from the browser using the leaked token. Replacements are the shopper-context hooks and EPAddToCartButton. getElasticPathProvider / getCommerceProvider no longer take serverToken, the provider no longer carries cart, and initElasticPathClient takes credentials only.

serverToken and serverCartMode remain in the registered prop schema, hidden and inert. Registered props on a hostless package are append-only — removing one makes updateHostlessPackage throw and takes down hostless publishing for every package, not just this one (#417).

Also deleted: the getServerInfo bridge and the auth/ep-*-server-info modules. They targeted an upstream API that was reverted and were never wired to a component; server-side data flows through Studio Server Queries and withEpSession.

Verification

Run against the example app on a real project, EP Provider pointing at integration.

A control run came first, because EP tokens are 40-char opaque hex and look like content hashes — "no token found" proves nothing unless the detector is known to fire. On pre-fix code /products HTML carried serverToken":"159ea435…", and that string used as a bearer against EP returned 200 where a same-shaped garbage token returned 401. The same control was re-run against /ep/refresh for the second leak.

After the fix, on a production build: no credential in the HTML of /products, /new-page or /product/[slug], and none in the responses from /api/ep/get-session, /ep/anonymous, /ep/refresh, /ep/cart, /api/ep/cart, /api/checkout/sessions/current or /api/plasmic-registry. epCartId still resolves, the cart route still answers, and product data still server-renders on the PDP — which is the evidence that removing the getServerInfo chain didn't cost SSR.

Suites: jest 118/1926, vitest 14/106.

Known and accepted

Under next dev, Next's RSC debug instrumentation serializes server-component locals — including the session — into the page source. Absent from production builds, not suppressible from this package. Now documented in both READMEs with the practical consequence: a dev server carries a live shopper credential.

Follow-up worth filing separately

The credential lives on the session object, so anything returning the session returns the credential — the safe default is inverted, and the allowlist is a filter at the exit rather than a guarantee at the source. Two independent leaks from one root cause is the symptom. Moving the credential somewhere the session object cannot reach is a larger change than this issue.

`providerProps()` returned the session's EP access token, which the
catchall page fed to `globalContextsProps` and Plasmic serialized into
the document — readable by any script on the page. It now returns `{}`.

Cart and checkout always run through the server routes; the
browser-direct EP SDK cart path and the `serverCartMode` toggle that
selected it are gone, along with the four `cart/use-*` hooks that drove
it. `EPCheckoutCartSummary` reads `useCheckoutCart()` instead.

The `getServerInfo` bridge and the `auth/ep-*-server-info` modules go
too: they targeted a reverted upstream API and were never wired to a
component. Server-side data flows through Studio Server Queries and
`withEpSession`.

`serverToken` and `serverCartMode` stay in the registered prop schema,
hidden and inert — registered props on a hostless package are
append-only, and removing one breaks hostless publishing for every
package.

Closes #282
…packages

The example resolved every Plasmic/EP dependency from the registry, so it
was exercising a stale snapshot — EP commerce 0.1.0 against 0.2.0 source,
loader-nextjs 2.0.2 against 2.0.21. file: links keep it on current source.
tsdx bundles typescript@3.9.10, which predates inline type specifiers in
import statements. tsc and both test suites run a modern TypeScript, so
this only surfaced at build time.
better-auth's /get-session returns the whole session record, and this
package keeps the shopper's EP access token on it. Mounting better-auth's
toNextJsHandler directly therefore handed that token to any same-origin
script for the cost of one fetch — the same exposure the serverToken prop
created, in pull form rather than push.

createEpAuthRoutes wraps the handler and strips epAccessToken, epClientId
and epHost from that response. epCartId stays: it is not a credential, and
EPCheckoutProvider and EPPromoCodeInput read it.

Also corrects the README's cookie and route tables, which described
ep_token / ep_account / ep_cart cookies the app never sets and a PATCH
cart route that is actually PUT.
…uth route

Review found the /get-session redaction was too narrow in two ways.

Every endpoint on the auth handler returns the session record, not just
/get-session — /ep/anonymous, /ep/refresh, /ep/account/login, /ep/account/logout
and /ep/cart all did too. /ep/refresh is the sharpest: it rotates the EP token
and returns the new value, and /ep/anonymous needs no cookie at all. Redaction
now applies to every response and the path check is gone.

The denylist also missed epAccountToken, the account-management credential set
on the session at login and strictly stronger than the anonymous token. The
session is now filtered to an allowlist, so a field added later is withheld by
default. The better-auth session id is withheld too: it lives in an HttpOnly
cookie so that scripts cannot read it.

Drops a copied Content-Length that would overstate the rebuilt body.

The example app goes back to registry versions; a README records how to point
it at local source for testing unreleased changes, and the package README now
documents that `next dev` serializes the session into the page source.
…user id

Round-two review found the allowlist withheld `session.token` while keeping
fields that spell it out: `buildAnonymousSnapshot` derived id, userId, user.id
and user.email from the same random value the token used, so `session.id`
minus its prefix was the token. The comment and CHANGELOG claimed a protection
the code did not deliver.

Giving the token its own value makes the claim true and stops the cookie's
non-secret half being derivable from data the storefront can read.

Also from that review: the example pinned `*`, which resolved to 0.0.3 — a
version with no `/server` subpath at all, so none of its imports could resolve.
Dependencies now carry real ranges. The app needs 0.2.1 for `createEpAuthRoutes`
and the README says so rather than implying `yarn install` works today.

`yarn dev` now serves 3456. `lib/ep-auth.ts` defaults `baseURL` there, so the
previous bare `next dev` produced an app on 3000 whose origin was untrusted —
silently failing cart writes, which is what the README warns about.
This release carries breaking changes, so it is 0.3.0. A caret range on a
0.x version pins the minor, so ^0.2.1 resolves to >=0.2.1 <0.3.0 and would
have excluded the release that adds createEpAuthRoutes.
Next ships no "exports" map, so Node's ESM resolver cannot resolve the bare
specifier `next/server` that esbuild emits when `next` is externalised. The
package's exports map sends `import` to dist/server.mjs, so any consumer
loading /server through ESM fails:

  Cannot find module '.../node_modules/next/server' imported from
    .../plasmic-ep-commerce-elastic-path/dist/server.mjs
  Did you mean to import "next/server.js"?

Rewrite the specifier to the file itself in the ESM output only; CJS keeps
the bare specifier, which require() resolves fine.

This was latent because a stale example lockfile pinned Next 15.5.15, whose
route graph loads the package through require(). A clean install today gets
15.5.23 and loads it through import. #397 recorded it as unconfirmed.
@field123
field123 merged commit 267c92b into master Aug 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: remove browser-readable EP token leak (#279 HIGH-3)

1 participant