FlexPlat logoFlx Modernize

16. Storefront: Cart, Checkout and My account

You will: carry the design through to the pages that hold addresses, passwords and payments, without sending any of it to the renderer.

Result: composed pages 100% / 100% against native with no theme, a real order placed through them in CI, and 100 hostile shells refused.

Why the earlier approach can't work

These pages are personal throughout. WooCommerce's checkout block preloads the shopper's data into page scripts, so even the head and footer output are personal. A page built with an empty cart is just an empty cart. And whatever the renderer sends sits beside password and card fields.

An empty shell, filled in locally

The renderer draws only the header and footer, with empty slots, and WordPress fills them on its own server:

<head><!--flx:slot:head--></head>
<body>
<!--flx:slot:before_site-->
<header>...<!--flx:slot:search-->...<!--flx:slot:header_cart--></header>
<!--flx:slot:main-->
<footer>...<!--flx:slot:handheld_bar--></footer>

Two separate requests make this work:

  • Build. A signed request from the site to itself, with no cookies, builds the shell, has the renderer draw it, checks it and stores it. It ends there, with no page.
  • Serve. A shopper's request never calls the renderer. WordPress renders its normal page inside core's output buffer, markers mark the parts, and they go in the stored shell's slots.

Our first design built the shell inside the shopper's request. A review found two flaws. The build changed the native page that followed, through form counters, menu state and enqueued scripts. And anything derived from the visitor, such as a geolocated country, could land in a shell cached for everyone. Building it separately removes both, and also takes renderer outages and delays out of checkout.

An allowlist, not a blocklist

A compromised renderer or hosting account could otherwise reach a password field. Our first check listed forbidden things, and review showed why that can't work: an unclosed form captures the login fields, a slot placed inside an attribute sends the page off-site, and parsers disagree about <svg> and <noscript>.

So everything in the shell has to be on a list, walked with WordPress's WP_HTML_Tag_Processor, and open and close tags must match exactly, so the walk and a browser can't disagree about where a slot sits. See the rules.

The next review found a better attack against that list: it allowed the site's search form, and a shell could dress that form as a login, so a typed password went to the renderer as a search query. The fix wasn't a tighter check on the form. The shell has no forms at all, and WordPress prints the search in a slot. If you can remove a capability instead of checking it, do.

Other details from review:

  • No query strings in URLs. <img src="/?add-to-cart=15"> fills a guest's cart, with no nonce.
  • Ids are limited too. A shell using the id of a field on the page could confuse a label or a script.
  • Add headers, don't replace them. A Content-Security-Policy sent with header()'s default replaces WooCommerce's frame-ancestors.
  • Single-use signatures. A logged build URL can't be replayed against the renderer.

Cut the page in the right places

Check each marker against the theme's files. These took real runs to find:

  • The cart must be a slot. The widget that loads WooCommerce's fragments script is hidden on Cart and Checkout, so a shell's "0 items" would never correct itself.
  • </div><!-- #page --> sits between storefront_after_footer and wp_footer, so they need separate slots.
  • The search part must end at priority 40, not 41: Storefront closes its header container at 41. Fidelity fell from 100% to about 91% when this was wrong, from one stray </div>.
  • wp_body_open prints WooCommerce's store notice, so it needs a slot too.
  • Late styles are moved to just before </head>, after wp_head has ended, so cut up to </head>.

Prove it, and prove the proof

  • A spy. A CI-only plugin writes every request to the renderer to a file. Force a cold build while a shopper with a cart and address browses, on Cart and on Checkout. Their values must not appear, and the shell must be byte-identical to one built with no shopper. Our first version passed falsely: the log escaped its slashes, the search for the route found nothing, and "no secrets found" ran against nothing. Make the test fail if the log is empty.
  • Attacks. A list of about 100 hostile shells, run inside real WordPress, and a selection through the whole path: refused, the stored shell removed, and the page byte-identical to native.
  • The worst that passes. Load it in a browser on the login page. It can overlay the page with a link, but no request leaves the site, and the real form still posts to the site.
  • A real order, with cash on delivery on in CI only.
  • Broken markers, through a plugin that removes a hook: the page equals native.

What this doesn't protect

The allowlist covers these pages. Catalogue pages still print what the renderer sends, including scripts, on the same site. So a compromised renderer could run script on /shop/ and could likely reach what is typed into a same-origin frame or window of these pages. We reasoned that through; we didn't run it. It's acceptable for a demo with no real payments. A real store has to decide whether to treat the renderer account as part of the shop's security, or limit catalogue pages as well.

Logged-in customers stay normal WordPress, so a customer who logs in leaves the design at checkout and in My account.

Next: 17. Go live, and your own store.

Published from rickey29/twenty-twenty-five at commit 837705d · Flx Modernize 1.11.0 · Flx Render 1.11.0