Skip to content

Opaque-origin published viewer and opt-in opaque editor preview - #56

Draft
erseco wants to merge 81 commits into
mainfrom
feature/secure-iframe-sandbox
Draft

Opaque-origin published viewer and opt-in opaque editor preview#56
erseco wants to merge 81 commits into
mainfrom
feature/secure-iframe-sandbox

Conversation

@erseco

@erseco erseco commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Published viewer

Extracted packages render in a sandboxed opaque-origin iframe, so author HTML
and JavaScript cannot reach the WordPress page, its cookies or the REST nonce.
External video still plays: the shared shim demotes each provider iframe inside
the package and a relay on the trusted page overlays the real player.

EXELEARNING_UNSAFE_LEGACY_IFRAME is a development-only escape hatch for the
php-wasm Playground. It is not an administration setting and never applies to the
editor preview.

Editor preview

Filtered by default. Enabling active content POSTs the project as one snapshot
to an authenticated route and serves it from an authless capability URL under a
sandbox CSP, with a 30-minute idle TTL:

POST/DELETE  {REST}/exelearning/v1/preview-session/*   cookie + X-WP-Nonce + upload_files
GET          {REST}/exelearning/v1/preview/{id}/*      authless, unguessable UUID

Snapshots are capped at 1 GB and 10 000 entries, both overridable
(EXELEARNING_PREVIEW_MAX_BYTES / exelearning_preview_max_bytes, and the
_files equivalents). Over the limit the grant fails closed and the filtered
preview stays. Archives are vetted before a byte is written: entry count,
declared uncompressed size, path traversal, reserved names and symlinks.

Also here

A loading indicator while an embedded package paints, and the attachment UI
aligned with native WordPress.

The frontend shortcode and Gutenberg block embedded .elpx content with
sandbox="allow-scripts allow-same-origin allow-popups", served same-origin. With
allow-same-origin the arbitrary author HTML/JS can read the WordPress page's
cookies/DOM and reach window.parent. Add an exelearning_iframe_sandbox_mode option
(secure default | legacy): secure drops allow-same-origin so the content runs in an
opaque origin and is isolated from the page; legacy restores the previous behaviour
for environments that need it (e.g. WordPress Playground, whose service worker only
serves same-origin documents).

A single ExeLearning_Iframe_Sandbox helper owns the option and the per-mode sandbox
tokens, consumed by both the shortcode and the block. Teacher mode used same-origin
contentDocument access, which cannot run against an opaque iframe; in secure mode the
desired state is carried on the iframe src (exe-teacher / exe-teacher-toggler) and the
content proxy applies it server-side (hide-toggler style and mode-teacher class), so
no parent-to-iframe DOM access is involved. The legacy contentDocument path is kept
for legacy mode. No CSP change is needed: the proxy's existing default-src 'self'
resolves to the serving origin and loads same-host subresources under the opaque
origin (verified in a browser). An admin setting under Settings -> eXeLearning lets
admins switch modes, and the Playground blueprint forces legacy.

Adds unit tests for the helper, the shortcode and block sandbox tokens per mode, the
secure teacher-mode src params, and the proxy's server-side teacher-mode injection.
@github-actions

github-actions Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Test in WordPress Playground

Test the plugin with the code from this branch:

Preview in WordPress Playground

ℹ️ The eXeLearning editor is fetched from the shared release and unpacked into the plugin when the playground boots, so the first load may take a few extra seconds. ELP upload, shortcode, Gutenberg block and preview work normally.

erseco added 3 commits June 13, 2026 17:44
Adds the new "Security" settings card strings to the POT and translates them in all
maintained locales (ca, ca_valencia, de_DE, eo, es_ES, eu, gl_ES, it_IT, pt_PT, ro_RO)
so the untranslated-strings check passes. Also drops the redundant card subtitle and
shortens the field help text.
… hardening)

Even with the secure iframe, the proxied content is served same-origin with an
executable CSP, so opening the raw /wp-json/exelearning/v1/content/{hash}/index.html URL
top-level (e.g. manual navigation) would run author JS as the WordPress origin. Add a
`sandbox allow-scripts allow-popups` CSP directive for HTML in secure mode so the
document keeps an opaque origin however it is loaded; legacy is unchanged. Extracted a
testable build_html_csp() helper.

Verified in wp-env: opening the raw content URL top-level now reports
window.origin === 'null' and document.cookie throws SecurityError, while the content
still renders.
@erseco

erseco commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Live verification (secure mode, default)

Brought this branch up with wp-env (:8890) and embedded an .elpx via [exelearning id=…]. Inspecting the rendered iframe from the host page:

  • sandbox="allow-scripts allow-popups" — no allow-same-origin → opaque origin.
  • From the parent: iframe.contentDocument is null and iframe.contentWindow.location throws SecurityError.

By the symmetry of the same-origin policy, the author content cannot read the WordPress DOM, cookies or nonce, nor reach window.parent. The same-origin escape chain (forge an authenticated request with a nonce scraped from the parent) is cut.

The backing content-proxy hardening checks out: HTML responses carry script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self'; frame-ancestors …; form-action 'self' and, in secure mode, a response-level sandbox allow-scripts allow-popups directive — so the content keeps an opaque origin even when opened directly (new tab / escaped popup / raw content URL), and connect-src 'self' blocks external exfiltration.

External-embed compatibility (worth a docs note). Because the content runs in an opaque origin and connect-src/frame-src are restricted:

  • External images and direct HTML5 video/audio files over https work (img-src/media-src include https:).
  • YouTube/Vimeo <iframe> are allowed by frame-src https: but the no-allow-same-origin sandbox propagates to the nested player, which then loses its own origin/cookies and typically fails — i.e. third-party video embeds do not work in secure mode. A plain PDF in an <iframe> may still render; <object>/<embed> are blocked (object-src).

This is an inherent trade-off of opaque-origin isolation, not a bug — just worth documenting for authors.

Note on #54 (asset proxy): both PRs touch class-content-proxy.php and class-admin-settings.php — expect a merge conflict, and a combined test that a proxied .js still executes inside the opaque iframe would be worthwhile.

erseco added 6 commits June 14, 2026 06:02
In secure mode the .elpx content runs in an opaque-origin sandbox, so cross-origin
players (YouTube/Vimeo) and PDFs render blank (the sandbox flag propagates to nested
iframes; Chrome also blocks its PDF viewer without allow-same-origin). Promote those
embeds to the embedding page: the content proxy injects a shim that replaces
whitelisted-video / .pdf iframes with placeholders and reports their geometry via
postMessage; a relay enqueued on the shortcode/block page validates + rebuilds the URL
and overlays the real player inline over each placeholder.

- assets/js/exe-embed-shim.js / exe-embed-relay.js: the shared shim + relay.
- class-content-proxy.php: inject the shim into served HTML (secure only).
- class-iframe-sandbox.php: embed_whitelist() + enqueue_embed_relay().
- class-shortcodes.php / class-elp-upload-block.php: enqueue the relay (secure).

PDFs: local package PDFs always render; any https .pdf renders; same-origin PDFs must
belong to this package (served as application/pdf, never executable HTML). Tests in
ContentProxyTest + IframeSandboxTest. Verified live in wp-env: YouTube, Vimeo and
remote + local PDF render inline; a non-whitelisted iframe is not promoted.
The shim runs inside the content, so it resolves each iframe src against the content
location and reports the ABSOLUTE URL. The parent relay resolves URLs against the host
page, so a relative src (e.g. a locally-packaged PDF) would otherwise be rejected.
Keeps parity with the mod_exelearning + omeka shim.
Add a self-contained Playwright/Firefox end-to-end test that loads the
real exe-embed shim and relay against a static harness (no WordPress
runtime needed) and verifies that whitelisted video and local PDF embeds
are promoted to inline players on the parent page while other origins are
rejected. Document the external-embed flow and how to run the test in the
README.
…ywright config

The dedicated playwright-embed.config.cjs runs the external-embed e2e in Firefox against
its own static harness; the main config (chromium + wp-env) was auto-discovering it and
failing. Exclude it via testIgnore. Also assert the non-whitelisted host with an exact
hostname check (new URL().hostname) instead of a URL substring match.
…av fix

Bring the WordPress embed relay/shim in line with the canonical mod_exelearning
source:

- Add Dailymotion and EducaMadrid/Mediateca de Madrid external-embed providers
  (allowlist hosts + per-provider canonical-URL validators in the relay).
- Clamp the relayed player overlay to the placeholder box (clickjacking defence in
  depth; the overlay already clips with overflow:hidden).
- Add allow-forms to the secure sandbox tokens, including the response-level CSP
  sandbox directive, so the form-based eXeLearning iDevices can submit inside the
  opaque sandbox. Align the legacy tokens with the canonical set.
- Fix a lingering external embed when the eXe content pages to another view: the
  in-iframe shim restarts its embed-id counter per page, so a reused id could keep
  the previous page's player; tag each player with its URL and replace it when a
  reused id maps to a different URL.

Update the IframeSandbox and ContentProxy unit tests for the new tokens and hosts.
@erseco
erseco marked this pull request as draft June 14, 2026 12:21
erseco added 14 commits June 14, 2026 16:16
…) + embed policy

Bring the WordPress embed relay/shim in line with the canonical mod_exelearning DEC-0061
change: drop the host allowlist for the default 'open' policy and promote any iframe whose
src is https AND cross-origin to the WordPress host (rejecting same-origin, sub/superdomains,
IP/loopback/local hosts and userinfo); a 'strict' policy keeps the allowlist + per-provider
reconstruction. The promoted video player is sandboxed (allow-scripts allow-same-origin
allow-popups allow-forms allow-presentation; no top-navigation/modals) so an arbitrary embed
cannot redirect the tab while the cross-origin provider still renders; PDFs stay unsandboxed.
Add the D1 same-origin-landing guard and the D2 forged-message defence (promoted players
tagged data-exe-embed-player, excluded from the content-source lookup).

Add an exelearning_embed_mode option (open default, fail-safe to strict) + an "External embed
policy" select in the admin Security settings, inject {mode, whitelist} into the relay config,
and update the IframeSandbox tests. Mirrors mod's logic (no drift).
…pen default + exelearning_embed_mode option

The 10 maintained .po files require every string translated; the new select
labels were untranslated and no canonical pot regeneration is available here.
The embed policy still resolves from the exelearning_embed_mode option
(default open), so the feature is unchanged; a localized UI can follow.
- Relay/shim: port the trailing-dot FQDN-root host normalization
  (normalizeHost) so the served host in 'host.' form is treated as same-host
  and not promoted as a cross-origin player.
- Relay: hoist the content-iframe rect read out of the per-embed loop (one
  reflow per sync) and adopt the canonical dual-export tail (Node-requireable
  for tests; browser auto-run unchanged).
- Content proxy: drop the unused window.__exeEmbedWhitelist injection;
  Referrer-Policy same-origin -> no-referrer on served files.
- Sandbox: ship the embed whitelist only in strict mode.
- Add a Vitest relay unit suite (happy-dom) covering the structural gate,
  including the trailing-dot cases.
…-sandbox

# Conflicts:
#	languages/exelearning-ca.mo
#	languages/exelearning-ca.po
#	languages/exelearning-ca_valencia.mo
#	languages/exelearning-ca_valencia.po
#	languages/exelearning-de_DE.mo
#	languages/exelearning-de_DE.po
#	languages/exelearning-eo.mo
#	languages/exelearning-eo.po
#	languages/exelearning-es_ES.mo
#	languages/exelearning-es_ES.po
#	languages/exelearning-eu.mo
#	languages/exelearning-eu.po
#	languages/exelearning-gl_ES.mo
#	languages/exelearning-gl_ES.po
#	languages/exelearning-it_IT.mo
#	languages/exelearning-it_IT.po
#	languages/exelearning-pt_PT.mo
#	languages/exelearning-pt_PT.po
#	languages/exelearning-ro_RO.mo
#	languages/exelearning-ro_RO.po
#	languages/exelearning.pot
#	tests/unit/ContentProxyTest.php
Reconcile teacher-mode handling with main (#58). main retired host-side CSS/JS
injection in favour of the package's own ?exe-teacher=1 URL parameter, so the
secure-iframe branch is aligned to the same contract:

- public/class-shortcodes.php, includes/class-elp-upload-block.php: keep the
  secure-mode embed-relay enqueue and opaque-iframe rendering, but drop the
  legacy teacher injection. The selector is offered by appending ?exe-teacher=1
  to the iframe src when teacher_mode_visible (or the legacy teacher_mode attr)
  is on. This rides through the secure-mode content proxy too: the package reads
  its own location.search even under the opaque origin, so no host injection is
  needed.
- includes/class-content-proxy.php: retire inject_teacher_mode() and the
  exe-teacher-toggler / exe-teacher server-side rewriting. It injected the exact
  #teacher-mode-toggler-wrapper CSS that #58 retired and, worse, auto-activated
  mode-teacher on ?exe-teacher=1 — which contradicts core (the parameter only
  offers the selector, it never auto-reveals). Keep inject_embed_shim().
- Tests: update ShortcodesTest / ElpUploadBlockTest secure-mode cases to assert
  the param-based contract (exe-teacher=1, no exe-teacher-toggler, no
  contentDocument) and drop the ContentProxyTest inject_teacher_mode cases.

Other conflicts:
- package.json: union — main's @playwright/test and @wordpress/env bumps plus
  the branch's vitest + happy-dom (used by tests/js). Lockfile regenerated.
- languages: msgcat union of both sides (main's strings authoritative, the
  branch's secure-mode admin strings preserved), .mo recompiled, JED .json taken
  from main (elp-upload.js is byte-identical to main), .pot unioned. CI
  regenerates references.

The admin block-editor live preview (assets/js/elp-upload.js) keeps main's
same-origin preview behaviour unchanged.
…067)

Mirror the id-only channel (extractProvider/reconstructProvider) into the embed shim/relay. Add exe-media-policy.js + exe-media-host.js (vendored from mod_exelearning) and enqueue_media_host() in ExeLearning_Iframe_Sandbox, called from the block + shortcode renderers: parent-side host for the interactive-video iDevice via raw postMessage (no YouTube IFrame API/Vimeo SDK). No-op in legacy.
… on re-open

M-3: makePlayer() rendered a cross-origin .pdf in a fully unsandboxed iframe, so
an author-supplied https://evil/x.pdf serving HTML could top-navigate the host
tab to a phishing page (reachable in both open and strict mode). Mirror the
canonical Moodle 3-way branch: a same-origin package PDF stays unsandboxed (the
browser PDF viewer needs it), a cross-origin PDF gets sandbox="allow-same-origin"
(no allow-scripts, no allow-top-navigation).

L-2: the modal media host appended a new <dialog> and overwrote session.adapter
on every 'open' without tearing down the previous one, so repeated 'open'
commands could stack modals and orphan provider players. openMedia() now discards
the prior media/poll-timer/dialog first (single active media per session).

Tests: cross-origin vs same-origin PDF sandbox; media-host single-active teardown.
…nly escape hatch

The content iframe is now always opaque-origin: the same-origin admin mode was removed.
A dev-only escape hatch (EXELEARNING_UNSAFE_LEGACY_IFRAME constant, default off, never in
the admin UI) restores same-origin only for environments that cannot serve opaque subframes
(the php-wasm WordPress Playground). It surfaces a loud admin warning when active.

- class-iframe-sandbox: mode() is always secure unless the unsafe constant is defined;
  embed_mode() defaults to strict (open is opt-in); add a CSP profile (strict default,
  exelearning_csp_profile filter -> compatible).
- content-proxy: build_html_csp() strict by default (no bare https: in script/img/media-src;
  frame-src limited to the maintained providers) with a documented-weaker compatible profile.
- admin settings: replace the secure/legacy selector with a read-only status + escape-hatch
  warning. blueprint.json defines the unsafe constant instead of update_option('...','legacy').
- Tests: legacy option ignored, strict embed/CSP defaults, compatible profile, escape hatch
  off by default. Add testing-the-sandbox.elpx fixture.

NOTE: validated locally by php -l + vitest (28 JS tests). The PHPUnit + phpcs run in PR CI
(no composer/WP test harness here).
…ways-secure settings card

The php-wasm Playground service worker only serves same-origin documents, so the opaque
content iframe could not load its CSS/JS. The escape hatch is now delivered by a Playground-only
mu-plugin (loads before all plugins, defines a real boolean EXELEARNING_UNSAFE_LEGACY_IFRAME)
instead of defineWpConfigConsts, and is_unsafe_legacy() accepts any truthy value (filter_var).

Also removes the read-only "always secure" Security card from the admin settings (it only
stated the obvious); the dev-only escape-hatch warning now renders only when the hatch is active.
…u-plugin

The admin-settings warning for the EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch was
the only new translatable string in this PR, and the check-untranslated CI gate requires
every languages/*.po to be fully translated — so it failed. The warning only ever shows
when the dev-only hatch is active, which in practice is the WordPress Playground. Move it
into the Playground-only mu-plugin (plain English, not scanned by make-pot) as an
admin_notices banner, and drop render_security_section() from the shipped plugin. The
hatch itself (is_unsafe_legacy) is unchanged and still covered by IframeSandboxTest.
…yground comment

Two PHPUnit tests still drove the same-origin sandbox via the removed admin option
(update_option(OPTION, MODE_LEGACY)) and asserted allow-same-origin. Always-opaque
ignores that option, so they failed once the untranslated gate (which masked them)
passed. Rewrite both as regression guards: the ignored legacy option must keep the
content/block iframe opaque (no allow-same-origin); the dev-only constant hatch is the
only same-origin path (covered by IframeSandboxTest). Also trim the Playground mu-plugin
comment — the full php-wasm rationale now lives in the PR description.
eXeLearning core (public/app/common/exe_embed_bridge/) is now the canonical source
for the promote-to-parent embed relay/shim; this copy mirrors it. Header comment only —
no logic change (verified by core's scripts/check-embed-sync.mjs: no drift).
@erseco

erseco commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

The external-embed bridge (exe-embed-shim.js / exe-embed-relay.js) header now declares this copy as a mirror of eXeLearning core, the canonical source: exelearning/exelearning public/app/common/exe_embed_bridge/ (PR exelearning/exelearning#1968). Comment-only change; no logic change (the invariant checker scripts/check-embed-sync.mjs in core reports no drift). Note: this copy is an older structural revision reformatted to this project's code style — functionally in sync with core; a full structural re-vendor from core is a documented follow-up, not required now.

erseco added 2 commits July 6, 2026 13:19
…rence)

Mirror of the eXeLearning core canonical contract
(exelearning/doc/development/preview-serving-contract.md): serve the editor
preview of untrusted author content over an authless capability URL in an opaque
origin, via this host's own cookieless serving primitive, so the preview gets
real per-page URLs (working navigation + open-in-new-tab) instead of the srcdoc
fallback. The sandbox-first CSP is emitted verbatim from core's previewCspHeader()
on every scriptable document type (text/html, image/svg+xml, application/xml,
application/xhtml+xml). Reference endpoint + docs; the session store, management
API and tests are follow-up per repo.
Remove stray markdown (a code fence + wiring-notes prose) accidentally left after
the class closing brace, convert the aligned route-list inline comment to a block
comment, capitalize a docblock short description, and end the file with a single
newline. phpcs --standard=.phpcs.xml.dist now clean.
@erseco erseco changed the title Secure opaque-origin content iframe + external-media bridge + HTTP editor preview (serving contract v2) Secure opaque-origin content + HTTP editor preview v2 Jul 12, 2026
erseco added 4 commits July 21, 2026 08:22
…-sandbox

# Conflicts:
#	languages/exelearning-ca.mo
#	languages/exelearning-ca.po
#	languages/exelearning-ca_valencia.mo
#	languages/exelearning-ca_valencia.po
#	languages/exelearning-de_DE.mo
#	languages/exelearning-de_DE.po
#	languages/exelearning-eo.mo
#	languages/exelearning-eo.po
#	languages/exelearning-es_ES.mo
#	languages/exelearning-es_ES.po
#	languages/exelearning-eu.mo
#	languages/exelearning-eu.po
#	languages/exelearning-gl_ES.mo
#	languages/exelearning-gl_ES.po
#	languages/exelearning-it_IT.mo
#	languages/exelearning-it_IT.po
#	languages/exelearning-pt_PT.mo
#	languages/exelearning-pt_PT.po
#	languages/exelearning-ro_RO.mo
#	languages/exelearning-ro_RO.po
#	package-lock.json
#	package.json
#	public/class-shortcodes.php
…-sandbox

# Conflicts:
#	.phpcs.xml.dist
#	languages/exelearning-ca.po
#	languages/exelearning-ca_valencia.po
#	languages/exelearning-de_DE.po
#	languages/exelearning-eo.po
#	languages/exelearning-es_ES.po
#	languages/exelearning-eu.po
#	languages/exelearning-gl_ES.po
#	languages/exelearning-it_IT.po
#	languages/exelearning-pt_PT.po
#	languages/exelearning-ro_RO.po
#	languages/exelearning.pot
…-sandbox

# Conflicts:
#	languages/exelearning-ca.mo
#	languages/exelearning-ca.po
#	languages/exelearning-ca_valencia.mo
#	languages/exelearning-ca_valencia.po
#	languages/exelearning-de_DE.mo
#	languages/exelearning-de_DE.po
#	languages/exelearning-eo.mo
#	languages/exelearning-eo.po
#	languages/exelearning-es_ES.mo
#	languages/exelearning-es_ES.po
#	languages/exelearning-eu.mo
#	languages/exelearning-eu.po
#	languages/exelearning-gl_ES.mo
#	languages/exelearning-gl_ES.po
#	languages/exelearning-it_IT.mo
#	languages/exelearning-it_IT.po
#	languages/exelearning-pt_PT.mo
#	languages/exelearning-pt_PT.po
#	languages/exelearning-ro_RO.mo
#	languages/exelearning-ro_RO.po
#	languages/exelearning.pot
@erseco

erseco commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Security evidence — opaque-sandbox containment (main vs. patch)

Screenshots and a reusable test package demonstrating that the opaque-origin content viewer contains untrusted eXe content while legitimate embedded media keeps working.

How this was produced (Playwright + wp-env): a malicious .elpx was loaded via the [exelearning] shortcode and captured with Playwright in two modes on the same site:

  • PATCH — default behavior on this branch: sandbox="allow-scripts allow-popups allow-forms", opaque origin.
  • MAIN (pre-patch) — the same content with the dev hatch EXELEARNING_UNSAFE_LEGACY_IFRAME=true, i.e. allow-same-origin (reproduces the pre-hardening same-origin iframe).

The package embeds a safe, read-only isolation probe (15 checks, booleans only — never reads or transmits real cookie/token values, never navigates, no network POST) that renders its results in-frame: green = safe/contained, red = dangerous capability reached, gray = informational.

1. Isolation probe — the untrusted content trying to reach the host

probe main vs patch

Probe check MAIN (allow-same-origin) PATCH (opaque)
isOpaqueOrigin false true
sandboxAllowsSameOrigin 🔴 true 🟢 false
canAccessParent 🔴 true 🟢 false
canReadParentDocument 🔴 true 🟢 false
canReadParentCookie 🔴 true 🟢 false
canFindSesskey false false
canAccessTop 🔴 true 🟢 false

On main the untrusted eXe content reads the host WordPress DOM and cookies. On the patch every origin-crossing vector is blocked. (canOpenPopups / canUsePostMessage are allowed browser features, not origin escapes — they render gray/informational.)

2. Legitimate media still works under the patch

video main vs patch

The test package is a 6-page eXe export, each page carrying a different cross-origin embed (YouTube-nocookie, Vimeo, Dailymotion, a generic cross-origin iframe, and an in-package PDF). Under the opaque sandbox the embed shim/relay promotes each to a parent-hosted sandboxed player, so videos play and the content stays contained:

patch video embeds contained

3. Full probe panel (patch) — corrected colors + host takeover buttons

probe panel wp+moodle buttons

Under the patch every escape vector is green (isOpaqueOrigin: true, canReadParentDocument: false, …) and every parent access throws SecurityError. The panel ships opt-in demo buttons (Moodle and WordPress) that, only when clicked and only in same-origin/legacy mode, perform authorized reversible actions (rename the WP display name via profile.php, swap the admin-bar avatar, Moodle course/forum actions); under the opaque origin they all return SecurityError.

Full-panel detail — patch (all safe) vs main (escapes)

patch probe all safe
main probe escapes

Test package

sandbox-video-probe.zip — rename .zip.elpx to upload to the Media Library. Multi-page, multiple video providers, plus the read-only isolation probe on every page. The media embeds + probe are injected into content.xml (each text iDevice's htmlView + textTextarea) and the exported HTML, so opening the project in the eXeLearning editor regenerates the pages with the content — not only the HTML export. Built from a benign eXe export; the probe is didactic and non-destructive.

One-click reproduction (WordPress Playground, legacy same-origin):
https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/erseco/lms-untrusted-content-security-paper/main/poc/playground-blueprint.json

erseco added 11 commits July 22, 2026 22:57
…op-level open

Serve a response-level sandbox CSP for every scriptable type (PDF, SVG, XML),
not only HTML — closing the gap where a PDF was served without a sandbox. When
untrusted content is opened as a top-level navigation (Sec-Fetch-Dest: document),
serve a friendly "this content lives inside a page" notice instead of running it,
so untrusted markup never executes outside its embedding context.
The "Edit in eXeLearning" action in the attachment meta box and media modal
navigated to a full page instead of opening the existing in-page editor modal.
Bind a delegated handler so any .exelearning-edit-page-button[data-attachment-id]
opens the modal (Save / Close) in place.
Align eXeLearning attachment previews with the native "Edit image" pattern:
a large interactive preview in the two-column details modal, a compact scaled-
down thumbnail plus a plain blue link in the narrow media selection sidebar,
and a centered edit button in the meta box.

Remove the duplicate edit button, the "open in new tab" links, the fullscreen
control and the separate "eXeLearning Metadata" box. Enqueue the embed relay in
the admin so embedded media renders inside admin previews.
The snapshot store hard-coded 100 MB / 5000 entries with no way to adjust
them, while the upload extractor already accepted 1 GB / 10000 entries via
`exelearning_max_extract_bytes` and `exelearning_max_extract_files`. A course
package that WordPress had accepted and extracted could therefore not be
previewed: real packages routinely reach 200-500 MB once their media counts,
and enabling external scripts on one failed closed every time.

Raise the defaults to match the upload extractor and expose both limits the
way the rest of the plugin does, with a constant for site owners and a filter
for code:

    EXELEARNING_PREVIEW_MAX_BYTES / exelearning_preview_max_bytes
    EXELEARNING_PREVIEW_MAX_FILES / exelearning_preview_max_files

A non-positive override falls back to the default, so the zip-bomb guard
cannot be switched off. The guard itself is unchanged: the ZIP is still
validated before extraction, and a rejected snapshot still fails closed to
the filtered preview.
The opaque snapshot proxy took over the preview routes and the v2 controller
tests were already deleted, but the six implementation files were left on
disk, deliberately unloaded. They were dead weight (~100 KB) that could no
longer be exercised, and `class-preview-proxy.php` declared a second
`ExeLearning_Preview_Proxy`, so PHPCS failed the whole run with
Generic.Classes.DuplicateClassName before reaching anything else.

Delete all six and update the loader comment. Nothing outside the group
referenced them: the only external mention of `ExeLearning_Preview_Proxy`
resolves to the snapshot class that stays.

Also exclude the git-ignored `artifacts/` scratch directory from PHPCS. It
holds manual test seeds and Playwright reports that never reach CI, so
linting them only broke `make lint` locally. `make lint` is clean again.
The block renders an iframe that has to download, parse and lay out a whole
package before anything appears, so a visitor sees an empty bordered frame
for a noticeable moment and cannot tell whether it is loading or broken.
Large packages make it worse.

Cover that gap with a spinner overlaying the frame. It is revealed by the
script itself, never server-side, so a visitor without JavaScript never gets
an overlay that nothing could clear. It is announced with role="status" so
it is not a silent animation, respects prefers-reduced-motion, and clears on
the iframe's load event plus one animation frame, because the theme builds
its navigation right after load and clearing immediately can uncover a frame
that is still blank. A timeout is the backstop for an embed whose load event
never fires.
Cleanup pass over the spinner work; no behaviour change intended.

Move the loader from a per-instance inline <script> to one enqueued asset
that binds every .exelearning-embed-loader on the page. Five embeds shipped
five uncached copies of the same ~1.7 KB IIFE, each re-running the same
lookups, and inline script is the first thing a hardened Content-Security-
Policy drops. The plugin already ships this shape for the fullscreen button.

Gate the spinner on intersection. The iframe is loading="lazy", so a block
below the fold had not begun loading while its spinner was already running —
it would have spun until the 20 s backstop over an idle frame.

Also: drop the data-exe-loader attribute nothing read, move the spinner's
static styling out of the .is-loading rule so only visibility depends on
state, and rename service_worker_guard_script to
purge_stale_editor_caches_script — it stopped guarding anything when the
registration stub was removed and now only purges caches. The build recipe's
NOTE about a patch that is no longer applied was changelog text with no code
near it.
build_preview_http_config() still emitted protocolVersion 2 with the
management and serving base URLs of the removed HTTP transport. The
bootstrap never emits previewHttp and the bundled editor never reads it,
so its only callers were its own two tests. The opaque preview now
travels as a whole-project snapshot; pretty_permalinks_enabled() and its
admin notice stay, because the snapshot serving route still needs them.

While there, correct the doc block on purge_stale_editor_caches_script():
it still described the removed registration stub and an HTTP-v2 editor
build that is never coming.

public/views/elp-list.php declared Exelearning_Admin, a leftover from the
plugin generator that no require_once reaches and nothing instantiates.
It was also the last file failing the WordPress-standard file-naming
sniff.
exelearning.php require_once's includes/class-exelearning-preview-proxy.php
and ExeLearning::__construct instantiates ExeLearning_Preview_Proxy, but the
file was never added to the index when the protocol-v2 classes came out, so
the pushed tree fataled on load and PreviewSnapshotStoreTest referenced an
undefined class. Local runs passed only because the file existed on disk.

Bring in the companion changes the removal left half-done:

- The activator/deactivator still scheduled and cleared a WP-Cron event
  keyed on ExeLearning_Preview_Proxy::CRON_HOOK. The snapshot store has no
  cron: it reclaims expired capabilities lazily on each request, so the
  activation hooks now do nothing and say why.
- exe-media-host.js gains the closeAll() the editor needs, and the editor
  calls it before opening its modal so a promoted player in a top-layer
  <dialog> cannot float above it.
- MediaLibraryTest drops the meta-box test the native-attachment-UI
  refactor superseded.
Everything outside languages/ merged cleanly. The catalogs conflicted in
all ten locales because both sides regenerated them.

Resolved as a union rather than a side: main's editor-bundle notices keep
the translations main shipped, and this branch's preview/media strings keep
theirs (msgcat --use-first ours theirs). Then regenerated the whole set from
the merged source with composer make-translations, so the .pot no longer
carries the removed installer strings.

"Loading content…", added with the embed spinner, had never reached the
catalogs; it is now translated in all ten locales, which is what the
pipeline's untranslated guard requires.
Comment thread includes/class-exelearning-preview-snapshot-store.php Fixed
Comment thread includes/class-exelearning-preview-snapshot-store.php Fixed
Comment thread includes/class-exelearning-preview-snapshot-store.php Fixed
Comment thread includes/class-exelearning-preview-snapshot-store.php Fixed
Code scanning flagged extract() at an NPath complexity of 3084 against a
threshold of 500 (alert #40); replace() was worse at 10368 and would have been
the next alert. Both had grown into long straight-line methods where every
guard multiplied the path count.

Decomposing them in place was not enough: PHPMD sums complexity per class, so
splitting a method only moves it, and the store went over the class threshold
instead. The archive checks are a separate job anyway — the store writes a tree
atomically, the inspector decides whether an archive may be written at all — so
they move to ExeLearning_Preview_Zip_Inspector, which now owns the entry loop,
the symlink test and the path rules. The store keeps max_files()/max_bytes() as
the configuration surface and passes them in, so the inspector does not point
back at it.

replace() is now four named phases: authorize, stage, write metadata, publish.

Behaviour is unchanged and the path rules were moved verbatim; the only visible
difference is that a failed extractTo() no longer reports "must contain
index.html", which was never what it meant. The store's file is clean under the
CI ruleset; handle_upload() and ExeLearning_Content_Proxy stay flagged, both
pre-existing on main.
@erseco erseco changed the title Secure opaque-origin content + HTTP editor preview v2 Opaque-origin published viewer and opt-in opaque editor preview Jul 25, 2026
erseco added 2 commits July 25, 2026 12:45
The protocol-v2 classes came out but this document stayed, still opening with
"This plugin implements eXeLearning Preview Serving Contract v2" and documenting
a previewHttp block, layered resource revisions and a fixed-resource manifest —
none of which exist here any more.

Rewritten around the snapshot contract, and three claims corrected against the
code while doing it:

- the management routes carry the attachment id
  (/preview-session/{attachmentId}[/{previewId}]), which the old text omitted;
- pretty permalinks are required for a practical reason the old text got wrong.
  The configuration is NOT omitted under plain permalinks; it is emitted and an
  admin notice is raised. What actually breaks is client-side: the editor
  resolves {previewId}/index.html against servingBaseUrl, and resolving a
  relative path against a ?rest_route= URL drops the query string;
- serving is no-store for everything. The ETag/Range tier described here is the
  Moodle adapter's, not this one's.
Comment thread includes/class-exelearning-preview-proxy.php Fixed
The capability route sent every file as `Cache-Control: no-store` with no ETag
and no `Accept-Ranges`, so a video or audio track the author put inside the
project could not seek: the browser re-fetched from byte zero on each attempt,
or refused to scrub at all. Every other asset also re-downloaded in full on each
preview refresh. The three sibling adapters already serve a revalidating tier;
this brings WordPress in line.

A scriptable document keeps `no-store` — it is rewritten on every refresh and
always sent whole. Everything else now revalidates with an ETag and supports
`If-None-Match` (304) and a single Range (206/416). A 304 and a 416 send no
body, and a 206 streams only its window, so nothing here reads more of a file
than it is about to send. `readfile()` still handles a full response, which is
the one thing this route already had right.

The ETag is built from identity rather than from hashing bytes, which would
defeat the point by reading the whole file to decide whether to send none of it.
Path plus mtime plus size is not enough on its own: mtime has one-second
granularity, so an author refreshing twice within the same second with an edit
that keeps a file the same length would produce the same tag and be handed a 304
for the previous bytes. The snapshot directory's inode joins the tag — every
publish renames a freshly built directory into place, so it always turns over.

parse_range() and if_none_match_matches() are pure and unit-tested, including
the contract's distinction between a range that is ignored (200) and one that is
unsatisfiable (416).
@erseco
erseco force-pushed the feature/secure-iframe-sandbox branch from 904f0b5 to 11726b4 Compare July 25, 2026 20:07
PHPMD flagged an NPath complexity of 864 against a threshold of 500. The method
was deciding three separate things at once: whether the header is one this
server honours at all, and then how a suffix range and an anchored range each
resolve against the entity size.

The guard and the dispatch stay in parse_range(); suffix_range() and
offset_range() take one form each. Same behaviour — the range tests, including
the ignored-versus-unsatisfiable distinction, pass unchanged.
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.

2 participants