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:
- A raw request must never execute as same-origin HTML. A raw
.htmlfile 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). Thedata/store/private/.htaccessdeny from allis 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 asapplication/octet-stream(a download), nevertext/htmlon our origin — its scripts therefore cannot run same-origin. (Verified: a raw request to the stored file returnsContent-Type: application/octet-stream; only the serve endpoint emits it astext/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.) - Every delivery is sandboxed. The endpoint sets
Content-Security-Policy: sandbox allow-scripts. The CSPsandboxdirective 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. Migrationupdates/0.5.78.phpmoved 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) andpostMessages{ __cbProductEmbed: 'height', height: <int> }toparent. - Parent (
complete_page.php) listens, and only acts ifevent.source === iframe.contentWindow(this exact iframe) and the payload is the expected shape; it then setsiframe.style.heightto 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:
- The Fullscreen button promotes the iframe itself to the top layer, sized to the screen (in the parent,
document.fullscreenElement === iframe). - The child measured its now screen-sized viewport and reported it; the parent wrote it to
style.height. - On exit the iframe dropped back into the page still carrying the fullscreen height. The child then
re-measured
100vhof that stale box, got the same number it last sent, and itsheight === lastde-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 fromsend()wheneverdocument.fullscreenElementis set, and onfullscreenchangeresetslastand 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 onfullscreenchange. 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
| File | Role |
|---|---|
data/customization/models/bcproductembed.php | ConfigboxModelBcproductembed — 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.php | KenedoPropertyBcembedhtml — 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.php | The upload widget (mirrors the core file widget so the admin JS works). |
data/customization/model_property_customization/adminproducts.php | Adds the embed_html field (in the "Product HTML Embed" group, next to Product Features / Gallery) to the product edit form. |
data/customization/controllers/bcproductembed.php | ConfigboxControllerBcproductembed::serve — the sandboxed serving endpoint + height-reporter injection. |
data/customization/templates/configuratorpage/complete_page.php | Renders the iframe (between #specs and #configurator) + the parent height listener, keyed by $this->product->id. |
data/customization/updates/0.5.76.php | No schema (embeds are files) — just drops any legacy _product_embeds / _page_embeds table from earlier iterations. |
data/customization/updates/0.5.78.php | Moves 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.embedextension (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-.htmlextension. - 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-scriptsonly. 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 addallow-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 legacyallowfullscreenattribute), delegating the Fullscreen Permissions-Policy feature to the opaque-origin frame. This is orthogonal to thesandboxtoken set (fullscreen isn't a sandbox flag) and to the CSPsandboxheader — neither needs changing — and it grants no same-origin/DOM/cookie access, so the isolation is unaffected. $this->productis 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
srcincludes&v=<mtime>; the endpoint respondsimmutable, so a replaced upload (new mtime) busts the cache automatically.