Skip to content

@W-22476773@ Storefront Preview / HttpOnly - dynamic SameSite for HttpOnly session cookies #3851

Merged
vcua-mobify merged 25 commits into
developfrom
vc/http-only-storefront-preview-samesite
May 27, 2026
Merged

@W-22476773@ Storefront Preview / HttpOnly - dynamic SameSite for HttpOnly session cookies #3851
vcua-mobify merged 25 commits into
developfrom
vc/http-only-storefront-preview-samesite

Conversation

@vcua-mobify
Copy link
Copy Markdown
Contributor

@vcua-mobify vcua-mobify commented May 22, 2026

Summary

Completes the 2-part fix for cross-site iframe cookie loss in Storefront Preview. PR #3850 added the __Secure-pwakit_preview_ctx marker cookie writer (built on Sec-Fetch-Dest/Sec-Fetch-Site/Referer browser-attested headers); this PR is the consumer that flips session-cookie attributes based on the marker.

  • Strips the hardcoded sameSite: 'lax' from every entry in SESSION_COOKIE_CONFIG so SameSite is resolved per-request.
  • setHttpOnlySessionCookies now reads __Secure-pwakit_preview_ctx via readStorefrontPreviewMarker (re-validates against the allow-list) and threads {sameSite: 'none', partitioned: true} into every Set-Cookie when the marker validates, {sameSite: 'lax'} otherwise.
  • expireHttpOnlySessionCookies (logout path) does the same for deletion writes — Partitioned-cookie deletion is partition-keyed, so the deletion attributes must match the original write or the cookie won't actually clear. Logout also clears the marker cookie itself.
  • New clearStorefrontPreviewMarker(res, options) helper in preview-context.js so the marker writer and clearer live in the same module.

After this PR lands, the storefront iframe under a trusted Runtime Admin preview parent gets session cookies that survive the cross-site boundary; SLAS refresh works, SCAPI calls return 200 instead of 400 access_token_cookie_missing, and getUsidForPreview resolves before the 10 s requestUsid timeout.

Why

With MRT_ENABLE_HTTPONLY_SESSION_COOKIES=true, the BFF wrote session cookies (cc-at, cc-nx-g, cc-nx, idp_access_token, idp_refresh_token, plus indicator cookies) with SameSite=Lax hardcoded in httponly-cookie-config.js. Storefront Preview iframes the storefront under a cross-site Runtime Admin parent (e.g. runtime-admin-preview.mobify-storefront.com). Browsers strip Lax cookies on the iframe's subresource fetches, so SLAS refresh fails, every SCAPI proxy call returns 400 {"message":"access_token_cookie_missing"}, getUsidForPreview times 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.

#3850 captured the trust signal at the iframe document load (browser-attested Sec-Fetch-Dest: iframe + Sec-Fetch-Site: cross-site + Referer origin on the allow-list) into a server-only marker cookie. This PR consumes that signal on SLAS responses to make the actual attribute switch.

MRT impact

None. Same as #3850: the change is entirely in pwa-kit-runtime Set-Cookie response headers and per-request resolution. CloudFront/MRT does not strip or rewrite the SameSite/Partitioned attributes, the cookies stay scoped to the storefront origin via the existing cookieDomain flow, and no new request headers are introduced. No WAF allow-list change required.

Changes

pwa-kit-runtime

  • packages/pwa-kit-runtime/src/ssr/server/httponly-cookie-config.js — removed sameSite: 'lax' from every entry's attributes.
    The remaining static attributes (httpOnly, secure, path) stay frozen on the config. Updated the doc-comment to point readers at preview-context.js for where SameSite/Partitioned are now resolved.

  • packages/pwa-kit-runtime/src/ssr/server/process-token-response.js

    • Imports readStorefrontPreviewMarker and clearStorefrontPreviewMarker from ./preview-context.
    • Adds two frozen constants — DEFAULT_SITE_ATTRS = {sameSite: 'lax'} and PREVIEW_IFRAME_SITE_ATTRS = {sameSite: 'none', partitioned: true} — and a getSiteAttrsForRequest(req) helper that picks one based on whether the marker validates.
    • makeAppendCookie now takes a third siteAttrs argument and applies it to both the primary write and the host-scoped cleanup write. The cleanup-write change is load-bearing: Partitioned-cookie deletion is partition-keyed, so the deletion's attributes must match the original write or the cookie persists.
    • setHttpOnlySessionCookies and expireHttpOnlySessionCookies compute siteAttrs once per call and thread it through.
    • expireHttpOnlySessionCookies now calls clearStorefrontPreviewMarker(res, options) after expiring the session cookies, so logout sweeps the iframe-context state too.
  • packages/pwa-kit-runtime/src/ssr/server/preview-context.js — adds clearStorefrontPreviewMarker(res, options) mirroring the writer's attributes (Path, Secure, HttpOnly, SameSite=None, Partitioned, optional Domain). When cookieDomain is configured, also emits a host-scoped deletion to clean up any pre-cookieDomain marker.

Tests

  • packages/pwa-kit-runtime/src/ssr/server/httponly-cookie-config.test.js — updated the legacy "sets secure, sameSite lax, and
    path / on all cookies" assertion to "sets secure and path / on all cookies" and added a sibling assertion that no entry declares a static sameSite (resolved per-request now).

  • packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js

    • New describe('trusted Storefront Preview iframe') block, 7 cases:
      • Every session cookie carries SameSite=none; Partitioned when the marker validates.
      • Per-cookie static attributes (Secure, HttpOnly-where-applicable, Path, Expires) preserved under the iframe path.
      • Marker value not on the allow-list → cookies fall back to SameSite=Lax, no Partitioned.
      • No marker present (common path) → cookies stay SameSite=Lax.
      • expireHttpOnlySessionCookies under the iframe path emits SameSite=None; Partitioned on every deletion (verifies partitioned-cookie clearing).
      • Marker cookie itself is cleared on logout with Path=/; Secure; HttpOnly; SameSite=none; Partitioned; Expires=epoch-0.
      • When cookieDomain is set, two marker deletions are emitted (domain-scoped + host-scoped).
    • Updated two existing logout-cookie-count assertions ('expires all session cookies for the given site' and 'expireHttpOnlySessionCookies expires both domain-scoped and host-scoped versions') to
      account for the +1/+2 marker deletion.

Behavioral notes

  • Top-level traffic, no marker present: completely unchangedSameSite=Lax, no Partitioned. The default path is exactly today's behavior.
  • Marker present and value validates: switches to SameSite=None; Partitioned for every session cookie on that response. CHIPS partitions by the parent's top-level site, so the cookies are isolated to the legitimate Runtime Admin preview origin's partition.
  • Logout sweeps the marker so a subsequent direct (non-iframe) navigation in the same browser doesn't see a stale marker. The marker re-issues on the next qualifying iframe document load.
  • Per-request resolution is the only state involved. No env vars, no project-side config changes — the gate is whether the request carries a validated marker.

Known limitation: multi-tab in non-CHIPS browsers

Browsers without Partitioned support (Firefox/Safari today) ignore the partition attribute and store the marker as plain SameSite=None; Secure; HttpOnly scoped to the storefront's eTLD+1. In a multi-tab scenario where the user has Storefront Preview open in Tab 1 (iframe under Runtime Admin) and then opens the storefront directly in Tab 2 (top-level navigation in the same browser), Tab 2's request carries the marker too. Any SLAS response on Tab 2 (e.g. token refresh) will then issue session cookies as SameSite=None instead of SameSite=Lax.

Practical impact is small: cookies remain Secure; HttpOnly; the BFF still enforces SLAS token validation and the SCAPI proxy guards. The CSRF margin from Lax → None narrows slightly in those browsers, but state-changing endpoints don't auth purely from the cookie.

In CHIPS-supporting browsers (Chrome/Edge) — the documented preview browsers — partitioning isolates the marker to the Runtime Admin partition and Tab 2 sees no marker, so it falls back to SameSite=Lax exactly as expected. No fix at the cookie-jar layer is possible in non-CHIPS browsers without browser-level partition support; treating Chromium as the supported preview browser is the pragmatic stance.

Test plan

  • npm test in packages/pwa-kit-runtime678/678 passing (a flaky express.test.js case unrelated to this PR sometimes drops to 677/678; passes on retry).
  • npm run lint in packages/pwa-kit-runtime0 errors (7 pre-existing warnings in files this PR doesn't touch).

Manual smoke (replace once verified):

  • Trusted iframe (Chrome) — load the storefront inside https://runtime-admin-preview.mobify-storefront.com. DevTools → Application → Cookies on the iframe origin: token cookies (cc-at_<siteId>,
    cc-nx-g_<siteId>/cc-nx_<siteId>, idp_refresh_token_<siteId>) all show SameSite=None, Partitioned, Secure, HttpOnly. Indicator cookies show the same minus HttpOnly. SCAPI XHRs return 200; no
    access_token_cookie_missing. The parent receives the USID via MessageChannel reply within ~1 s.
  • Top-level (Chrome) — open the same storefront URL directly. Cookies remain SameSite=Lax, no Partitioned. Marker is not present.
  • Untrusted parent — iframe the storefront under https://example.com. Marker is not set (PR 1/2 gate); cookies stay Lax.
  • Marker tampering — manually set __Secure-pwakit_preview_ctx=https://attacker.example.com via DevTools, trigger a SLAS refresh. BFF re-validates, falls back to Lax. (Confirmed in unit tests;
    smoke optional.)
  • Logout — sign out from the preview iframe; verify Set-Cookie headers expire all session cookies AND __Secure-pwakit_preview_ctx. Reload the iframe; marker re-issues from PR 1/2's middleware on
    the document load.
  • cookieDomain interop — set commerceAPI.cookieDomain, trigger SLAS in the iframe; verify session cookies include Domain=...; SameSite=None; Partitioned and the host-scoped duplicate. Sign
    out; verify both versions of every cookie (including the marker) are expired.
  • Firefox sanity — confirm iframe SLAS still works (Partitioned ignored, falls back to plain SameSite=None; Secure); accept the multi-tab limitation noted above as known-and-documented.

@git2gus
Copy link
Copy Markdown

git2gus Bot commented May 22, 2026

Git2Gus App is installed but the .git2gus/config.json doesn't have right values. You should add the required configuration.

@cc-prodsec
Copy link
Copy Markdown
Collaborator

cc-prodsec commented May 22, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@vcua-mobify vcua-mobify changed the base branch from develop to vc/http-only-storefront-preview May 22, 2026 23:49
@vcua-mobify vcua-mobify marked this pull request as ready for review May 22, 2026 23:56
@vcua-mobify vcua-mobify requested a review from a team as a code owner May 22, 2026 23:56
Signed-off-by: vcua-mobify <47404250+vcua-mobify@users.noreply.github.com>
…efront-preview-samesite

Signed-off-by: vcua-mobify <47404250+vcua-mobify@users.noreply.github.com>
@vcua-mobify
Copy link
Copy Markdown
Contributor Author

vcua-mobify commented May 25, 2026

Screenshot 2026-05-25 at 11 16 22 AM

The image shows that on storefront preview, cookies with SameSite=None Partitioned are created. Cookies that are not partitioned (created from accessing the site directly) have SameSite=Lax

Update: I pushed a change to clean up the host-scoped marker cookie if we create another marker cookie with a custom cookie domain

* `{sameSite: 'lax'}` (the existing top-level behavior).
* @private
*/
function getSiteAttrsForRequest(req) {
Copy link
Copy Markdown
Contributor

@adamraya adamraya May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case with multiple tabs: in Firefox and Safari Partitioned is ignored, so the __Host-pwakit_preview_ctx cookie becomes a plain SameSite=None; Secure; HttpOnly cookie under the storefront domain.

If the merchant opens a second tab with the storefront, the cookie will be carried and here getSiteAttrsForRequest here will flip the session cookies to SameSite=None; Partitioned even though the storefront in the new tab is not iframed.

Non blocking, cookie is the session scoped so it's more a browser compatibility note

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, there's a slight degradation in Firefox / Safari as noted in the PR description but it should be fine as cookies are still Secure, HttpOnly

adamraya
adamraya previously approved these changes May 26, 2026
unandyala
unandyala previously approved these changes May 26, 2026
Base automatically changed from vc/http-only-storefront-preview to develop May 27, 2026 16:43
@vcua-mobify vcua-mobify dismissed stale reviews from adamraya and unandyala May 27, 2026 16:43

The base branch was changed.

@vcua-mobify vcua-mobify enabled auto-merge (squash) May 27, 2026 17:03
@vcua-mobify vcua-mobify merged commit 5086c43 into develop May 27, 2026
41 checks passed
@vcua-mobify vcua-mobify deleted the vc/http-only-storefront-preview-samesite branch May 27, 2026 17:09
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.

4 participants