fix(ep-commerce): stop the shopper's EP token reaching the browser - #422
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Push —
providerProps()returned the token, the catchall page handed it toglobalContextsProps, 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'stoNextJsHandler./ep/refreshwas the sharpest: it rotates the token and returns the new value, so a script got the token the session then used./ep/anonymousneeds no cookie at all.The session is filtered to an allowlist —
id,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 missedepAccountToken, the account-management credential set at login and strictly stronger than the anonymous token. An allowlist withholds fields nobody has invented yet.epCartIdis 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_datais 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
serverCartModetoggle that selected it are gone, along with the fourcart/use-*hooks that drove it — all four called EP directly from the browser using the leaked token. Replacements are theshopper-contexthooks andEPAddToCartButton.getElasticPathProvider/getCommerceProviderno longer takeserverToken, the provider no longer carriescart, andinitElasticPathClienttakes credentials only.serverTokenandserverCartModeremain in the registered prop schema, hidden and inert. Registered props on a hostless package are append-only — removing one makesupdateHostlessPackagethrow and takes down hostless publishing for every package, not just this one (#417).Also deleted: the
getServerInfobridge and theauth/ep-*-server-infomodules. They targeted an upstream API that was reverted and were never wired to a component; server-side data flows through Studio Server Queries andwithEpSession.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
/productsHTML carriedserverToken":"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/refreshfor the second leak.After the fix, on a production build: no credential in the HTML of
/products,/new-pageor/product/[slug], and none in the responses from/api/ep/get-session,/ep/anonymous,/ep/refresh,/ep/cart,/api/ep/cart,/api/checkout/sessions/currentor/api/plasmic-registry.epCartIdstill resolves, the cart route still answers, and product data still server-renders on the PDP — which is the evidence that removing thegetServerInfochain 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.