Skip to main content

Per-product HTML embed (sandboxed iframe)

Audience: developers & AI agents · Scope: the admin-uploaded HTML that shows in a sandboxed iframe below the Tech Specs section of a product's configurator page — its storage, the serving endpoint, and the security model · Last reviewed: 2026-07-22

TL;DR: An admin uploads an .html file on the product edit screen (a field in the "Product HTML Embed" group on ConfigboxModelAdminproducts, next to Product Features / Product Gallery). It is stored as a file in the private store (<store>/private/custom_media/product_embeds/<productId>.embed) — with a deliberately non-.html extension — and rendered on that product's configurator page inside an <iframe sandbox="allow-scripts"> below the #specs (Tech Specs) section and above the #configurator section. A dedicated endpoint serves the HTML with a Content-Security-Policy: sandbox header, so the content is always isolated (opaque origin) — it cannot read the page's cookies, DOM, or session. The iframe auto-resizes to its content via a locked-down, height-only postMessage channel.

Why the private store, with a non-.html extension (and not in the DB)

The uploaded HTML "may contain all sorts of things" (arbitrary scripts/markup). Even though only admins can upload it, we treat it as untrusted content and isolate it completely. Two hard rules:

  1. A raw request must never execute as same-origin HTML. A raw .html file below docroot is reachable top-level and would then run same-origin as the site — i.e. stored XSS (it could read a visitor's Joomla session cookie). The data/store/private/.htaccess deny from all is Apache-only, and this stack runs nginx in dev and prod, which ignores .htaccess — so being in the private store is not by itself enough. The defence is the extension: the files are stored as <productId>.embed, an extension nginx doesn't map to a MIME type, so a direct request is served as application/octet-stream (a download), never text/html on our origin — its scripts therefore cannot run same-origin. (Verified: a raw request to the stored file returns Content-Type: application/octet-stream; only the serve endpoint emits it as text/html, always sandboxed.) The files are also never linked by URL — only the serve endpoint ever reads them. (We use a file rather than a DB column because these embeds run to a few MB — a self-contained 3D viewer with an inlined model — which is a poor fit for a text column.)
  2. Every delivery is sandboxed. The endpoint sets Content-Security-Policy: sandbox allow-scripts. The CSP sandbox directive applies even on direct top-level navigation, not just when framed, so the document is always an opaque origin with no access to the real site.

History: embeds originally lived one level above the web root (dirname(JPATH_ROOT).'/configbox_product_embeds/<productId>.html') to keep them off any URL entirely. Migration updates/0.5.78.php moved them into the private store and renamed .html.embed; new writes go straight there.

Data flow

Product edit form (ConfigboxModelAdminproducts, via model_property_customization/adminproducts.php)
└─ property "embed_html" (type bcembedhtml), in the "Product HTML Embed" group
├─ getDataFromRequest(): reads the uploaded file / delete checkbox
└─ store(): ConfigboxModelBcproductembed->saveEmbedHtml($productId, $html)
└─ writes <store>/private/custom_media/product_embeds/<productId>.embed

Frontend configurator page (templates/configuratorpage/complete_page.php)
└─ ConfigboxModelBcproductembed->getEmbedInfo($this->product->id) ← lightweight: file exists? size? mtime?
└─ if present: render <iframe src="/…&controller=bcproductembed&task=serve&productId=…&v=<mtime>">
(v = file mtime → cache-buster) + a parent-side height listener

Serve endpoint (ConfigboxControllerBcproductembed::serve)
└─ ConfigboxModelBcproductembed->getEmbedHtml($productId) ← reads the file
└─ output with CSP sandbox header + injected height-reporter script

There is no database table: a file's existence IS the presence, its mtime is "updated", its size is the byte count.

Auto-height (the one open channel)

True "no communication with the parent" and automatic height are mutually exclusive — the height has to come from inside the frame. The compromise is a one-way, height-only channel that cannot leak parent data or run parent code:

  • Child → the serve endpoint injects a tiny script that measures the document height (load / resize / ResizeObserver / a short polling tail) and postMessages { __cbProductEmbed: 'height', height: <int> } to parent.
  • Parent (complete_page.php) listens, and only acts if event.source === iframe.contentWindow (this exact iframe) and the payload is the expected shape; it then sets iframe.style.height to the integer, clamped to [0, 20000]. It never reads anything else from the message and never sends anything back. Worst case for a malicious payload is a clamped resize — no data path exists.

Because the frame is an opaque origin, the parent also cannot read into the child (iframe.contentDocument is blocked) — verified in the browser.

Fullscreen must be excluded from the loop (or the height sticks)

Auto-height is a feedback loop: the parent sizes the frame from the child's measurement, and the child's measurement depends on the frame's size — these embeds are viewport-sized (body { min-height: 100vh }). Fullscreen used to poison that loop permanently:

  1. The Fullscreen button promotes the iframe itself to the top layer, sized to the screen (in the parent, document.fullscreenElement === iframe).
  2. The child measured its now screen-sized viewport and reported it; the parent wrote it to style.height.
  3. On exit the iframe dropped back into the page still carrying the fullscreen height. The child then re-measured 100vh of that stale box, got the same number it last sent, and its height === last de-dupe suppressed the message — so the parent was never told to shrink. A stable fixed point: a screen-tall frame with a big empty gap under the content, which nothing later recovered from.

The rule is therefore no height flows while fullscreen, on both sides:

  • Child (ConfigboxControllerBcproductembed::getHeightReporterScript) returns early from send() whenever document.fullscreenElement is set, and on fullscreenchange resets last and re-measures over ~1 s (the transition is async) so a size change made during fullscreen is still reported afterwards.
  • Parent (complete_page.php) ignores height messages while the iframe is the fullscreen element, remembers the last non-fullscreen height, and restores it on fullscreenchange. This half is the one that actually breaks the loop — a child-side guard alone cannot, because after exit the stale height is self-consistent and there is nothing left to report.

Both listen for the webkit-prefixed event too. Verified in Chrome against the real Bellows viewer: two enter/exit cycles, zero messages sent while fullscreen, height returns to exactly its pre-fullscreen value.

The files

FileRole
data/customization/models/bcproductembed.phpConfigboxModelBcproductembed — the only code that touches storage: reads/writes/deletes the per-product .embed files under <store>/private/custom_media/product_embeds/. No DB.
data/customization/properties/bcembedhtml.phpKenedoPropertyBcembedhtml — file-upload field that hands the uploaded contents to the model (no base-table column). Model-agnostic: keys on the edit record's primary key (the product id on the product form).
data/customization/properties/tmpl/bcembedhtml.phpThe upload widget (mirrors the core file widget so the admin JS works).
data/customization/model_property_customization/adminproducts.phpAdds the embed_html field (in the "Product HTML Embed" group, next to Product Features / Gallery) to the product edit form.
data/customization/controllers/bcproductembed.phpConfigboxControllerBcproductembed::serve — the sandboxed serving endpoint + height-reporter injection.
data/customization/templates/configuratorpage/complete_page.phpRenders the iframe (between #specs and #configurator) + the parent height listener, keyed by $this->product->id.
data/customization/updates/0.5.76.phpNo schema (embeds are files) — just drops any legacy _product_embeds / _page_embeds table from earlier iterations.
data/customization/updates/0.5.78.phpMoves existing embed files from the old above-docroot location into the private store, renaming .html.embed. Idempotent, fails soft.

Gotchas / notes

  • Storage location must be writable. The files sit in the private store at <store>/private/custom_media/product_embeds/ (getEmbedDir()), created on demand; if it can't be written the feature degrades to "no embed" (nothing is shown, nothing errors). Safety comes from the .embed extension (raw requests download rather than execute — see above), not from the location being non-web — so if you ever change where the files live, keep the non-.html extension.
  • Size cap: uploads are capped at 5 MB and must be valid UTF-8 (KenedoPropertyBcembedhtml::MAX_BYTES) — enough for a self-contained embed with a base64-inlined 3D model.
  • Sandbox is minimal by design: allow-scripts only. Scripts, styles, images, and external sub-resources all load, but forms, popups (target=_blank), and top-window navigation are blocked. If a future embed genuinely needs one of those, widen the sandbox on both the iframe attribute and the CSP header (ConfigboxControllerBcproductembed::serve) — never add allow-same-origin, which would defeat the isolation.
  • Fullscreen is allowed (e.g. a 3D viewer's fullscreen button). The iframe carries allow="fullscreen" (plus the legacy allowfullscreen attribute), delegating the Fullscreen Permissions-Policy feature to the opaque-origin frame. This is orthogonal to the sandbox token set (fullscreen isn't a sandbox flag) and to the CSP sandbox header — neither needs changing — and it grants no same-origin/DOM/cookie access, so the isolation is unaffected.
  • $this->product is available for free: the configurator page template already has the product (->setProduct($this->product) on the downloads section), so the embed integrates without a separate query. One embed per product; it shows on that product's configurator page.
  • Cache-busting: the iframe src includes &v=<mtime>; the endpoint responds immutable, so a replaced upload (new mtime) busts the cache automatically.