Skip to content

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

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

Opaque-origin published viewer and opt-in opaque editor preview#56
erseco wants to merge 95 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 added 6 commits July 24, 2026 20:36
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
erseco and others added 6 commits July 25, 2026 21:13
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.
# Conflicts:
#	.phpcs.xml.dist
#	assets/css/exelearning.css
#	assets/js/exelearning-embed-loader.js
#	includes/class-elp-upload-block.php
#	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
#	package-lock.json
#	package.json
#	tests/unit/ElpUploadBlockTest.php
#	vitest.config.mts
Pull in the shared exe-external-media child/host bundle (built from
eXeLearning core commit 3bee9769e) under assets/js/exe_external_media/,
replacing the ad hoc media bridge glue with the versioned, checksummed
artifact the other host plugins already consume.

Rework the embed shim's activation from a one-shot promote-on-load into
an addressed hello/welcome handshake: the shim announces itself on a
retry schedule and only ever promotes once the parent relay answers
with an addressed 'welcome', so an embed opened where no relay exists
(file://, a bare LMS iframe, an ePub reader) is left exactly as
authored instead of stuck as a permanent black box. The relay's
broadcast 'request' ping stays a passive geometry probe that can
prompt another hello but never unlock on its own.

Update ci.yml, the content proxy, the ELP upload block, and the
iframe sandbox to match, with unit/e2e coverage for the new handshake
and upload preview paths.
@codecov-commenter

codecov-commenter commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.66758% with 529 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.46%. Comparing base (a1428c4) to head (6de2ead).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
assets/js/exe-media-host.js 33.10% 192 Missing ⚠️
assets/js/exe-embed-shim.js 0.00% 149 Missing ⚠️
assets/js/exe-media-policy.js 21.83% 68 Missing ⚠️
assets/js/exe-embed-relay.js 82.85% 42 Missing ⚠️
includes/class-exelearning-preview-proxy.php 88.42% 22 Missing ⚠️
assets/js/exelearning-media-modal.js 51.21% 20 Missing ⚠️
...ludes/class-exelearning-preview-snapshot-store.php 90.28% 17 Missing ⚠️
includes/class-content-proxy.php 90.80% 8 Missing ⚠️
...cludes/class-exelearning-preview-zip-inspector.php 92.15% 4 Missing ⚠️
assets/js/elp-upload.js 77.77% 2 Missing ⚠️
... and 4 more
Additional details and impacted files
@@             Coverage Diff              @@
##               main      #56      +/-   ##
============================================
- Coverage     96.87%   87.46%   -9.42%     
- Complexity      864     1077     +213     
============================================
  Files            39       49      +10     
  Lines          4323     5583    +1260     
============================================
+ Hits           4188     4883     +695     
- Misses          135      700     +565     
Flag Coverage Δ
javascript 67.69% <42.16%> (-28.02%) ⬇️
php 96.29% <91.62%> (-0.96%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
admin/views/editor-bootstrap.php 92.94% <100.00%> (+1.39%) ⬆️
exelearning.php 0.00% <ø> (ø)
includes/class-activator.php 0.00% <ø> (ø)
includes/class-deactivator.php 0.00% <ø> (ø)
includes/class-elp-upload-block.php 100.00% <100.00%> (ø)
includes/class-exelearning-editor.php 93.92% <100.00%> (+0.54%) ⬆️
includes/class-exelearning.php 97.29% <100.00%> (+0.07%) ⬆️
includes/integrations/class-media-library.php 100.00% <100.00%> (ø)
public/class-shortcodes.php 100.00% <100.00%> (ø)
...exe_external_media/exe-external-media-child.min.js 0.00% <0.00%> (ø)
... and 13 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

erseco and others added 9 commits August 4, 2026 18:17
…-sandbox

Brings in the coverage work (#88), the editor bootstrap refactor and the
block's move to Block API version 3 (#89).

Conflicts, and how they were taken:

admin/views/editor-bootstrap.php -- both sides changed how the <base> tag
is injected. This branch also injects a cache-purge script at the same
point; main had fixed the pattern that finds the head element, because
`<head[^>]*>` also matches the editor's own `<header id="head">`. Kept
this branch's purge script with main's `\b` and the replace limit: without
them the purge script was being injected, and run, twice.

languages/* -- regenerated rather than hand-merged. main added no msgid,
so this branch's translations carry over untouched and only the `#:`
source references move.

Tests that main added and this branch had already superseded:

- vitest.config.mts: main disabled happy-dom iframe page loading for the
  whole suite, which breaks exe_embed.test.js -- that suite drives real
  iframes. Scoped the setting to wp_exe_download.test.js, the one file
  that needs it, with a @vitest-environment-options docblock.

- elp_upload.test.js: the teacher-mode CSS-injection hack this branch
  replaced with `?exe-teacher=1` on the preview URL. Dropped; the
  replacement is already covered by elp_upload_preview.test.js.

- exelearning_media_modal.test.js: the native-attachment-UI refactor
  removed the bespoke metadata panel, the "preview in new tab" link and
  the whole two-column actions row -- runAllUpdates() no longer calls
  addEditButtonToAttachmentInfo(). Those tests are gone. The details-panel
  ones are rewritten against what the panel does now: the preview replaces
  the thumbnail, and a single "Edit in eXeLearning" link sits below it
  carrying the class exelearning-editor.js binds to.

- ContentProxyServeTest: one assertion compared the whole served document
  byte for byte, which no longer holds now that the proxy appends the
  embed shim. It asserts what it was about -- the absolute inline-style
  URL surviving untouched.

1007 PHP tests, 253 JS tests, PHPCS clean, translations deterministic.
runAllUpdates() now calls only replaceElpThumbnail() and
addElpPreviewToDetails(), so the two-column actions row has no way in.
addEditButtonToAttachmentInfo() had no callers at all, and it was the only
caller of insertEditButtonInActions() and insertProcessButtonInActions();
addEditButton() was orphaned separately. 198 lines that could not run,
shipped and parsed on every admin page that enqueues the script. They date
from the initial release, not from the refactor that replaced them, and
git has them if the actions row ever comes back.

Also removes the .exelearning-edit-button rules from the admin stylesheet
(nothing emits that class now) and drops .exelearning-process-button-actions
from the sweep in reprocessAttachment(), which was clearing a class that no
longer exists.

Two things this turned up, both consequences of the same refactor:

The "Edit in eXeLearning" link duplicated on every save. exelearning-editor.js
clears the marker classes after saving so the panel rebuilds, and swept
.exelearning-preview-link / .exelearning-metadata / .exelearning-edit-button
-- all three removed by the refactor -- while addExeEditAction(), unlike the
addEditButton() it replaced, carries no duplicate guard. So the rebuild
appended a second link, then a third. The sweep now names the class that
exists, and the guard is back. Covered by a regression test.

openFullscreenOverlay() is orphaned and is NOT removed here. It is new work
on this branch, but data-fullscreen-src was only ever emitted by
insertEditButtonInActions() -- no PHP emits it, despite the handler's "meta
box + modal" comment -- so the overlay can never open. Whether to wire it up
or drop it is a decision for the feature, not for this cleanup.

Media modal coverage 53.38% -> 87.8%; the remaining gap is almost entirely
that orphaned overlay. Two tests added for the picker-sidebar rendering,
which the refactor introduced and nothing covered.

1007 PHP tests, 256 JS tests, PHPCS clean.
…factor

ab0fdc4 merged main independently and resolved two files by taking this
branch's side wholesale, which quietly undid work main had just landed:

- admin/views/editor-bootstrap.php went back to echoing and exiting, and
  includes/class-exelearning-editor.php back to a bare include(). That is
  self-consistent, so nothing broke -- but it reverts SDD-0004, which made
  the view return its HTML so the 84 lines building the editor's contract
  with WordPress could be tested at all.
- tests/unit/EditorBootstrapPageTest.php, added by main, was deleted with
  it. Sixteen tests covering the REST URL, the wp_rest nonce, the <base>
  tag, the style registry and the front controller end to end.
- The <base> injection went back to `/(<head[^>]*>)/i`, which also matches
  the editor's own `<header id="head">`. On this branch that is worse than
  on main: the cache-purge script is injected at the same point, so it was
  being placed, and run, twice.

This merge keeps main's side for those, so the refactor, its tests and the
fix survive. Everything else from ab0fdc4 comes through unchanged.

1007 PHP tests, 256 JS tests, PHPCS clean, translations deterministic.
happy-dom will really resolve and fetch any iframe src, so the safe
default is off and the exception is the file that needs real frames:
exe_embed.test.js drives the relay across content iframes and reads their
geometry. It opts back in with a @vitest-environment-options docblock.

This was the other way round a commit ago -- one file opting out, everyone
else exposed -- which is the wrong default for a rule that exists so the
suite never depends on DNS.
The coverage gate came back to 94 with main, and the branch's own preview
code was what sat under it: the proxy at 40.1% with 115 uncovered lines,
and nothing at all testing who may write a capability or what one will
serve.

27 tests, aimed at the properties rather than the lines:

- Permission: upload rights alone do not reach another author's
  attachment, and the attachment's author does.
- Upload: a missing snapshot, a failed upload, and -- the one that
  matters -- a tmp_name that was never uploaded. Without the
  is_uploaded_file() guard that parameter would let a caller name any
  file the web server can read and have it unpacked into a capability
  they then fetch.
- Scope: a capability cannot be deleted by another user, nor through
  another attachment, and an unknown one reports 404 rather than success.
- Containment: four traversal shapes, percent-encoded included, and the
  snapshot's own metadata, all refused; a blocked traversal is answered
  exactly like a file that is simply not there.
- Streaming: whole documents, byte windows, suffix ranges, an
  unsatisfiable range and a matching entity tag both sending no body, and
  a document ignoring a conditional request because the editor rewrites
  it on every refresh.

serve_preview() and everything under it ended each path in exit(), so
none of it could run under PHPUnit. finish() isolates that, following
ExeLearning_Admin_Styles::finish_request() and
ExeLearning_Editor::send_and_exit(); a test subclass records the end of
the response instead.

Nothing was found broken. Two things were checked and hold: every MIME
the store maps for a type a browser renders as a document -- html, htm,
xhtml, xml, svg -- is in SCRIPTABLE_TYPES and gets the sandbox CSP, with
everything else falling to application/octet-stream; and the realpath
containment in get() stops each traversal shape tried.

class-exelearning-preview-proxy.php 40.1% -> 87.9%, the store 87.7% ->
90.5%, suite 93.84% -> 96.09% against a 94% gate.
`make architecture-check` failed with seven "references retired identifier"
problems: this branch was written before both repositories moved from the
global `ADR-NNNN` counter to tracking-number identifiers, so its citations
still name `ADR-0017`, `ADR-0018` and `ADR-0021`.

None of the three is a record of this repository. They are decisions taken in
`exelearning/exelearning`, where the external-media family is built and
published and where they were renumbered too:

  ADR-0017 -> ADR-2199-08  the in-content shim stays inert until a handshake
  ADR-0018 -> ADR-2199-09  dual-license the shared embedder family
  ADR-0021 -> ADR-2199-12  core is canonical for the external-media family

Each mapping is the `legacy_id` frontmatter of the renamed record, read from
the branch of `exelearning/exelearning#2199`, not inferred from the slug.

Citations authored here now name the repository -- `exelearning/exelearning
ADR-2199-12` -- so a foreign decision cannot be read as a local record next to
this repository's bare `ADR-72-01` style. `verify.mjs` is the exception: it is
a vendored mirror whose own header says so, and core's copy already carries the
new identifier, so taking core's line back makes the file byte-identical to the
published one again instead of drifting one comment further from it.

The mappings are recorded in `docs/architecture/migration-map.md` under a new
section, so the next contributor who greps for `ADR-0021` does not have to
re-derive them from an unmerged core branch.

Nothing is renamed, added to the validator allowlist, or weakened: the three
decisions this branch depends on are unchanged, only their names are current.
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.

3 participants