@W-22476773@ Storefront Preview / HttpOnly - foundations for dynamic SameSite#3850
Conversation
|
Git2Gus App is installed but the |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Signed-off-by: vcua-mobify <47404250+vcua-mobify@users.noreply.github.com>
| * SPDX-License-Identifier: BSD-3-Clause | ||
| * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause | ||
| */ | ||
| import cookie from 'cookie' |
There was a problem hiding this comment.
nit: We are importing the cookie package but we didn't add it as a dependecy.
Should we import the dependency or replace it with an inline parser e.g. req.headers.cookie?.split('; ') ?
There was a problem hiding this comment.
I'm now using an inline parser
| function detectTrustedPreviewParent(req) { | ||
| if (req.method !== 'GET') return undefined | ||
| if (req.headers?.[SEC_FETCH_DEST] !== 'iframe') return undefined | ||
| if (req.headers?.[SEC_FETCH_SITE] !== 'cross-site') return undefined |
There was a problem hiding this comment.
We may want to widening to also accept same-site for scenarios when we are testing Preview with non production domains and both RA and the storefront end up sharing the same domain e.g. *.mobify-storefront.com, in those scenarios the browser sends same-site, not cross-site.
| if (req.headers?.[SEC_FETCH_SITE] !== 'cross-site') return undefined | |
| const fetchSite = req.headers?.[SEC_FETCH_SITE] | |
| if (fetchSite !== 'cross-site' && fetchSite !== 'same-site') return undefined |
| // secure context. We always set Secure on this cookie, so the prefix adds | ||
| // defense in depth at zero cost. (`__Host-` won't work because the cookie | ||
| // can carry Domain= when commerceAPI.cookieDomain is configured.) | ||
| export const STOREFRONT_PREVIEW_CTX_COOKIE = '__Secure-pwakit_preview_ctx' |
There was a problem hiding this comment.
Should we use __Host- here instead of __Secure-? it only needs to be readable on the host that wrote it
per MDN docs on CHIPS pattern:
https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy_sandbox/Partitioned_cookies#:~:text=you%20can%20use%20the%20__Host%20prefix%20when%20setting%20partitioned%20cookies%20to%20bind%20them%20only%20to%20the%20current%20domain%20or%20subdomain%2C%20and%20this%20is%20recommended%20if%20you%20don%27t%20need%20to%20share%20cookies%20between%20subdomains
__Host- is browser-enforced to require Path=/ and forbid Domain,
which lets you drop the cookieDomain && {domain} conditional
| // Mirrors IFRAME_HOST_ALLOW_LIST in commerce-sdk-react/src/constant.ts. | ||
| // Kept in sync by a parity test in preview-context.test.js — drift will | ||
| // fail the test. | ||
| export const STOREFRONT_PREVIEW_PARENT_ALLOW_LIST = Object.freeze([ |
There was a problem hiding this comment.
should we include soak environment as well?
There was a problem hiding this comment.
Yup, I've added both the soak and test environments just now
…ceCommerceCloud/pwa-kit into vc/http-only-storefront-preview
Summary
Foundations for a 2-part fix that lets the SLAS proxy issue session cookies as
SameSite=None; Secure; Partitioned(CHIPS) when the storefront is loaded inside a trusted Storefront Preview iframe, while keepingSameSite=Laxfor top-level traffic.This PR is plumbing only — no behavior change for any existing flow. The marker cookie is written under qualifying conditions but is not yet read on SLAS responses, so all session cookies still ship
SameSite=Lax. PR 2/2 adds the read path and flips the attribute.The second part of this change is #3851
Partitioned(CHIPS) attribute support tocookieAsString.STOREFRONT_PREVIEW_CTX_COOKIE(the marker name) andSTOREFRONT_PREVIEW_PARENT_ALLOW_LIST(server mirror ofIFRAME_HOST_ALLOW_LISTfromcommerce-sdk-react).getValidatedCookieDomain+INVALID_COOKIE_DOMAIN_PATTERNintocookie-domain.jsso bothprocess-token-response.jsand the newpreview-context.jscan share the validator without circular imports.preview-context.jsexportingtryWriteStorefrontPreviewMarker(req, res, options)andreadStorefrontPreviewMarker(req)._setupCommonMiddleware, gated onMRT_ENABLE_HTTPONLY_SESSION_COOKIES === 'true'.Why
With
MRT_ENABLE_HTTPONLY_SESSION_COOKIES=true, the BFF currently writescc-at,cc-nx-g,cc-nx,idp_access_token,idp_refresh_token(and the indicator cookies) withSameSite=Laxhardcoded inhttponly-cookie-config.js. Storefront Preview iframes the storefront under a cross-site Runtime Admin parent (e.g.runtime-admin-preview.mobify-storefront.com). On the iframe's subresource fetches the browser stripsLaxcookies, so SLAS refresh fails, every SCAPI proxy call returns400 {"message":"access_token_cookie_missing"},getUsidForPreviewtimes out, and Runtime Admin reports "Failed to receive a response to 'requestUsid' within the 10000ms timeout." The same storefront URL opened in a top-level tab works fine — the bug is specific to the cross-site iframe boundary.The fix is to relax
SameSiteonly when the BFF can prove the storefront is inside a trusted preview iframe. We do this in two steps, entirely on the server:Sec-Fetch-Dest: iframe,Sec-Fetch-Site: cross-site, andReferer: https://<parent>/...(preserved by Runtime Admin'sreferrerpolicy="strict-origin-when-cross-origin"). None of these are settable from cross-origin JS, so an attacker page cannot trigger the gate. When all three line up and theRefererorigin is on the allow-list, we set a server-only marker cookie (__pwakit_preview_ctx=<parent>; Path=/; Secure; HttpOnly; SameSite=None; Partitioned).setHttpOnlySessionCookiesreads the marker, re-validates the value, and switches per-request toSameSite=None; Partitioned.CHIPS partitions the marker by the legitimate parent's top-level site, so even if the cookie value was somehow influenced (it isn't — it's set under server-attested conditions only), partition isolation bounds the blast radius. CHIPS is unsupported in Firefox/Safari today; those browsers ignore
Partitionedand the marker degrades gracefully to plainSameSite=None; Secure.MRT impact
None. Verified — the fix uses only browser-set request metadata (
Sec-Fetch-*,Referer) and a newSet-Cookieresponse header. Both already traverse MRT/CloudFront unchanged forSet-Cookies emitted by the SSR app, andSec-Fetch-*/Refererare standard request headers that aren't filtered. No WAF allow-list change required (no new request header introduced).Changes
pwa-kit-runtimepackages/pwa-kit-runtime/src/utils/ssr-proxying.js—cookieAsStringlearns to emitPartitioned. One new branch after the existingSameSitebranch.packages/pwa-kit-runtime/src/ssr/server/constants.js— addsSTOREFRONT_PREVIEW_CTX_COOKIEandSTOREFRONT_PREVIEW_PARENT_ALLOW_LIST(frozen mirror ofIFRAME_HOST_ALLOW_LISTincommerce-sdk-react/src/constant.ts— kept honest by a parity test landing in PR 2/2).packages/pwa-kit-runtime/src/ssr/server/cookie-domain.js— extractsgetValidatedCookieDomainandINVALID_COOKIE_DOMAIN_PATTERN(previously local toprocess-token-response.js) so the newpreview-context.jscan share the validator without circular imports.packages/pwa-kit-runtime/src/ssr/server/process-token-response.js— drops the local copies of those helpers, imports fromcookie-domain.js. No behavioral change.packages/pwa-kit-runtime/src/ssr/server/preview-context.js:tryWriteStorefrontPreviewMarker(req, res, options)— appendsSet-Cookie: __pwakit_preview_ctx=<origin>; Path=/; Secure; HttpOnly; SameSite=None; Partitioned(withDomain=whencommerceAPI.cookieDomainis configured) whenreq.method === 'GET',Sec-Fetch-Dest: iframe,Sec-Fetch-Site: cross-site, and the parsedRefererorigin is on the allow-list. No-op otherwise.readStorefrontPreviewMarker(req)— returns the validated origin orundefined. Re-validates the cookie value against the allow-list so a stale or hostile value can't bypass the gate.packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js— registers the marker-writer middleware in_setupCommonMiddleware(right afterprepNonProxyRequest, beforessrMiddleware), gated onMRT_ENABLE_HTTPONLY_SESSION_COOKIES === 'true'.Tests
packages/pwa-kit-runtime/src/utils/ssr-proxying.test.js— two newcookieAsStringcases assertingPartitionedemit on/off.packages/pwa-kit-runtime/src/ssr/server/preview-context.test.js— 14 cases:test.eachover every entry inSTOREFRONT_PREVIEW_PARENT_ALLOW_LIST; negative cases for non-GET, wrongSec-Fetch-Dest, wrongSec-Fetch-Site, untrusted Referer origin, missing Referer, unparseable Referer,Sec-Fetch-*absent (older browser — fail closed);Domain=is emitted whencookieDomainis set and omitted when malformed.undefinedwhen absent, returns the validated origin on a happy match, returnsundefinedfor a non-allow-listed value (re-validation), returnsundefinedwhen the cookie name isn't present.packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js— two cases inside the existing_setupCommonMiddlewaredescribe block:MRT_ENABLE_HTTPONLY_SESSION_COOKIES=true→ marker middleware is registered immediately afterprepNonProxyRequest, and forwarding it an iframe-shaped request appends aSet-Cookie: __pwakit_preview_ctx=....mockApp.usecalled exactly 3 times).Behavioral notes
MRT_ENABLE_HTTPONLY_SESSION_COOKIESset: completely unchanged. The marker is gated on the env var.SameSite. You can deploy this PR alone and verify in DevTools that__pwakit_preview_ctxshows up on iframe document loads from the trusted parents — without affecting the existing broken-iframe behavior.Sec-Fetch-*is absent (very old browsers), the marker is not written and traffic stays on the existingLaxpath.Expires/Max-Age). Re-issued on every qualifying request; clears when the preview window closes.Test plan
npm testinpackages/pwa-kit-runtime— 669/669 passing.npm run lintinpackages/pwa-kit-runtime— 0 errors (7 pre-existing warnings in files this PR doesn't touch).MRT_ENABLE_HTTPONLY_SESSION_COOKIES=true, load it insidehttps://runtime-admin-preview.mobify-storefront.com, and confirm in DevTools →Application → Cookies that
__pwakit_preview_ctxappears withSameSite=None,Partitioned,Secure,HttpOnly,Path=/, value = parent origin.__pwakit_preview_ctxis not set.https://example.com. Confirm__pwakit_preview_ctxis not set.MRT_ENABLE_HTTPONLY_SESSION_COOKIESunset, repeat the trusted-iframe load. Confirm__pwakit_preview_ctxis not set.access_token_cookie_missing(this PR does not yet fix the iframe; that's PR 2/2).SameSite=Lax.cookieDomaininterop — setcommerceAPI.cookieDomain, repeat the trusted-iframe load, confirm__pwakit_preview_ctxincludesDomain=....