Skip to content

Secure opaque-origin content + HTTP editor preview v2#56

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

Secure opaque-origin content + HTTP editor preview v2#56
erseco wants to merge 61 commits into
mainfrom
feature/secure-iframe-sandbox

Conversation

@erseco

@erseco erseco commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

This branch delivers two related security features:

  1. An opaque-origin viewer for published eXeLearning content with the shared external-media bridge.
  2. A host-served HTTP editor preview implementing eXeLearning Preview Serving Contract v2.

The iframe never receives allow-same-origin in production. Author HTML and JavaScript therefore cannot reach the WordPress page, cookies, REST nonce, storage, or editor APIs.

Published-content viewer

  • Opaque sandbox by default.
  • Strict external-provider policy by default.
  • Shared eXeLearning embed shim, relay, media policy, and media host.
  • Response hardening for scriptable content.
  • Development-only EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch for the php-wasm Playground published viewer. It is not an administration setting and is not used by editor preview.

HTTP editor preview: protocol v2

The plugin exposes two deliberately separate surfaces:

POST/DELETE {REST}/exelearning/v1/preview-session/*
GET         {REST}/exelearning/v1/preview/{previewId}/*

Management is authenticated with the WordPress cookie/REST path, requires X-WP-Nonce, checks upload_files, and enforces session ownership. Serving is an authless, cookieless capability URL backed only by an unguessable preview UUID and TTL.

Protocol v2 separates:

  • fixed installation resources, resolved through the editor build manifest and never uploaded;
  • immutable project assets, uploaded once per session;
  • generated documents, published as atomic incremental revisions.

Private session storage

The production proxy now creates one shared session store in a site-scoped private system-temporary directory:

{system-temp}/exelearning-preview-{site-hash}

It no longer uses wp-content/uploads as the production default. This makes authored HTML, SVG, XML, CSS and JavaScript private by construction and prevents a direct web-server path from bypassing the serving controller's sandbox CSP.

exelearning_preview_store_dir may select another private directory. Apache guards, index.php, and nginx guidance remain defense in depth, not the primary isolation boundary.

The management and serving controllers share the same store and fixed-resource resolver, reducing duplicate lazy construction.

Serving behavior

  • Sandbox-first CSP on HTML, SVG, XML, text/xml, and XHTML from every layer.
  • nosniff, no-referrer, restrictive Permissions-Policy and appropriate CORS headers, including errors.
  • Relative 302 from the bare capability root to {previewId}/index.html.
  • 206 for a valid satisfiable single range.
  • 416 for a valid unsatisfiable range, including bytes=-0.
  • Full 200 response when Range is malformed, multi-range, uses another unit, or is inverted.
  • ETag and conditional requests for immutable session assets.
  • Failed writes are never indexed and partial revisions are never activated.

Editor configuration

The bootstrap injects:

{
  "previewHttp": {
    "protocolVersion": 2,
    "managementBaseUrl": "{rest_url}/exelearning/v1/preview-session",
    "servingBaseUrl": "{rest_url}/exelearning/v1/preview",
    "managementHeaders": {
      "X-WP-Nonce": "..."
    }
  }
}

Pretty permalinks are required because the plain ?rest_route= form cannot provide a stable capability subtree. When they are disabled, previewHttp is omitted and the editor fails closed with an administration notice.

Activation status

The currently bundled released editor predates HttpPreviewProvider. The host routes, storage, bootstrap wiring, contract tests and security headers are implemented, but a released editor does not drive the HTTP preview yet.

Production activation depends on a core release containing:

  • HttpPreviewProvider;
  • StaticServiceWorkerPreviewProvider;
  • bundles/preview-fixed-resources.json.

No full browser activation is claimed until that editor is installed and tested inside WordPress.

Tests

  • WordPress coding standards and plugin checks.
  • PHPUnit for management, serving, storage, fixed resources, headers and contract vectors.
  • Tests proving the default store is private and site-scoped.
  • Write-failure and atomic-revision regressions.
  • Vitest for the shared bridge.
  • Playwright bootstrap/embed coverage.
  • PHPMD and Playground workflows.

Canonical source

eXeLearning core is the canonical source for the serving contract, scriptable MIME policy, CSP, bridge files, and conformance vectors. WordPress maintains a host adapter and synchronized mirrors rather than a protocol fork.

Known limitations

  • Browser activation remains gated on a compatible core editor release.
  • Pretty permalinks are required for editor preview.
  • A custom exelearning_preview_store_dir must remain outside any publicly served tree.

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 4 commits July 11, 2026 06:36
Follow core commit 054abfd0 (canonical exe_embed_bridge) in the plugin mirrors:

- shim: report(force) with a JSON dirty-check so observer-driven syncs skip
  identical geometry while initial/load/parent-request pings always post;
  MutationObserver now also watches class/style/hidden/open attributes; add
  transitionend/animationend + a ResizeObserver on documentElement/body so a
  class-toggled (animated) layout change re-measures the settled geometry.
- relay: positionOverlay records entry.lastRect; new checkDrift() re-pins any
  overlay whose content iframe box moved with no scroll/resize (sidebar/panel
  toggles), exposed on the relay and run from a 300 ms interval in init().

Port the core checkDrift() unit test. 28 JS tests green.
The store lives under wp-content/uploads (web-servable). Untrusted author HTML
written at uploads/exelearning-preview/{uuid}/revisions/{n}/documents/*.html
could be fetched directly, served SAME-ORIGIN WITHOUT the sandbox CSP that the
REST serving route adds, defeating the opaque-origin isolation (the CSP-bypass
the published-content path already guards against via the uploads .htaccess).

On first use the store now drops a deny guard into its base dir (idempotent,
self-healing), mirroring WordPress core's protected-upload-subdir pattern:
- .htaccess: Require all denied / Deny from all (cascades to every session
  subdirectory on Apache);
- empty index.php: no directory listing.

.htaccess is the Apache guard; nginx must place the store outside the web root
or deny the location (documented in the serving-contract mirror). Add TDD tests
asserting both guard files exist with deny content and self-heal, plus explicit
per-session ownership (403) tests for the revision and delete routes.
A files[] part whose $_FILES error is not UPLOAD_ERR_OK (truncated, oversized,
partial) arrives with an empty temp file. Previously it slipped through:
apply_revision would copy the empty path (crashing on PHP 8's "Path cannot be
empty") or publish a 0-byte document, and store_assets silently reported it
rejected while returning 200 — a silent-empty-document bug (parity with the
Moodle/Omeka #14 class).

check_uploaded_parts() now validates every part BEFORE the store runs and
rejects the whole batch, naming the offending #index (filename): 413 for
UPLOAD_ERR_INI_SIZE / UPLOAD_ERR_FORM_SIZE, 400 for any other error or an
absent/unreadable temp file. An empty file is never substituted; index
alignment (writes[i] <-> files[i]) is preserved.

TDD tests: an oversized (INI_SIZE) revision part -> 413 with the store untouched
(revision unchanged, no revisions/1 dir, no 0-byte document); a partial part ->
400; an errored asset part -> 400 with nothing stored.
…rdown)

Follow core (canonical exe_embed_bridge): the drift-check interval was left
running for the page lifetime. createRelay() now tracks driftTimer + started;
init() is idempotent (a second init() no longer stacks a duplicate interval and
window listeners) and stores the interval handle; dispose() clears the overlays
and, in the browser, clears the drift timer and removes the message/resize/
scroll/load listeners, then resets started so a reused relay can init() again.

Adds an internal clear() (the mirror never exposed core's clear()/reflow()) so
dispose() tears down overlays; only dispose is exposed, per the minimal mirror
surface. Port the dispose() unit test (tears down overlays like clear, safe
before init and when called twice). 29 JS tests green.
Comment thread includes/class-preview-proxy.php Fixed
Comment thread includes/class-preview-proxy.php Fixed
Comment thread includes/class-preview-proxy.php Fixed
Comment thread includes/class-preview-proxy.php Fixed
Comment thread includes/class-preview-proxy.php Fixed
Comment thread includes/class-preview-session-store.php Fixed
Comment thread includes/class-preview-session-store.php Fixed
Comment thread includes/class-preview-session-store.php Fixed
Comment thread includes/class-preview-session-store.php Fixed
Comment thread includes/class-preview-session-store.php Fixed
erseco added 11 commits July 11, 2026 10:11
The preview v2 WP-Cron feature added one user-facing string, the 15-minute
cleanup interval label __( 'Every 15 minutes (eXeLearning preview cleanup)' ),
which was untranslated in all 10 locales and turned the check-untranslated CI
gate red. Add the msgid to the .pot and each locale .po with its translation and
recompile the .mo (via the plugin's wp i18n toolchain). Minimal, per-string
edit — no reflow of unrelated entries.
Pure structural refactor — identical status codes, error bodies and side
effects (the 66-test preview suite is unchanged and green). Extract cohesive
private helpers so every flagged method drops below the CC=15 / NPath=500
thresholds:

- proxy: upload_assets -> validate_asset_entries / check_declared_asset_budget /
  build_asset_store_entries; publish_revision -> validate_revision_meta (+
  validate_revision_collections) / buffer_revision_writes; build_serve_response
  -> build_asset_response.
- store: apply_revision -> validate_revision_order / normalize_revision_paths /
  check_asset_refs / check_fixed_refs / compute_revision_budget; store_assets ->
  classify_asset_entry (+ classify_new_asset); serve_lookup ->
  resolve_served_file.

Guard-clause flattening collapses the multiplicative NPath (e.g. apply_revision
2519425 -> under 500). For the two class-level ExcessiveClassComplexity findings
(WMC, which method extraction does not lower): both classes are the single
cohesive implementation of the serving-contract-v2 protocol; splitting would
only relocate WMC into a tightly-coupled collaborator and fragment one state
machine / break the tested build_serve_response surface, so each carries a
justified @SuppressWarnings(PHPMD.ExcessiveClassComplexity). Method-level
complexity — the meaningful gate — is now under threshold everywhere.
The preview classes tripped the CI phpcs step (the repo's own `/exelearning/*`
exclude-pattern matches any local checkout path containing a bare `exelearning`
segment, so local `make phpcs`/wp-env runs silently skip the whole plugin;
CI's `wp-exelearning` checkout path is scanned normally). Verified by linting
the files at a neutral path with the same standard.

Auto-fixes (phpcbf): @param docblock alignment, array double-arrow alignment,
equals-sign alignment. Manual:
- proxy: Yoda condition in is_intish() (hoist the float, `floor($value) ===
  $value`); phpcs:ignore for the range-streaming fread().
- store: hoist count() out of the enforce_per_user_cap() loop condition
  (decrement in-loop, behavior identical); capitalize the ASSET_KEY_REGEX
  docblock; reword the normalize_content_path comment that read as commented-out
  code; fold the touch() ignore onto one directive so both sniffs are suppressed.
- fixed-resources: rename the reserved-keyword `$parent` param to `$root`.

phpcs (neutral path, full standard) clean on every touched PHP file; PHPMD still
clean; preview PHPUnit 66 tests green.
…alse-green

The bare `<exclude-pattern>/exelearning/*</exclude-pattern>` matched the FULL
path, so any checkout under a directory containing an `exelearning/` segment
(e.g. .../ate/exelearning/wp-exelearning/, or the wp-env mount
.../plugins/exelearning/) excluded the ENTIRE plugin — local `make phpcs`
"passed" in 61ms by scanning nothing, while CI (checkout .../wp-exelearning/)
scanned normally and failed. Anchor it to the intended target only, the
WordPress uploads extraction dir.

Verified from the real checkout path: `phpcs -q .` now scans the plugin (3.5s,
was 61ms), is clean, a probe violation under includes/ is caught, and a probe
under wp-content/uploads/exelearning/ stays excluded. No other ruleset behavior
changed.
Run the canonical pipeline (wp i18n make-pot -> pot-remove-ctime -> update-po
-> make-mo) so the committed catalog matches the current branch source: refresh
every `#:` source reference, drop strings removed by earlier work, and add the
ones added, re-sorted to the extractor's order. The preview-cleanup cron label
translations are preserved by update-po in all 10 locales.

composer untranslated is clean and `msgfmt -c` passes on all 10 .po.
…ollers

Splits ExeLearning_Preview_Proxy (which carried an @SuppressWarnings
PHPMD.ExcessiveClassComplexity) into two focused classes over a small shared
header helper, and lands the contract v2.1 serving deltas the split made natural
to place:

- ExeLearning_Preview_Http_Headers: the byte-identical sandbox CSP, the
  scriptable-type set and the extension->MIME map. Adds text/xml to the
  scriptable set to match eXe core's isScriptableDocumentType, closing a CSP
  coverage gap (a stray text/xml document is as scriptable as application/xml).
- ExeLearning_Preview_Serving_Controller: the authless read side. Serves a bare
  capability URL as a 302 to index.html (so a page's relative subresources
  resolve against the index base), and treats a malformed / multi-range /
  non-bytes Range as "ignore -> 200 full body" instead of 416 (416 stays only
  for a syntactically valid, single, unsatisfiable range).
- ExeLearning_Preview_Management_Controller: the owner-scoped write side.
- ExeLearning_Preview_Proxy: now a thin registrar wiring both controllers and
  the cleanup cron over one shared store + resolver. The PHPMD suppression is
  gone naturally, not moved.

The store keeps its own suppression (its cohesion is real). Behaviour is guarded
by the shared conformance vectors (replayed unchanged) and the split unit tests.
…ker viewer

Wires the opaque HTTP preview transport (serving contract v2) into the editor
bootstrap and removes the legacy Service Worker /viewer/ machinery the plugin's
own contract forbids on the WordPress origin.

- editor-bootstrap.php now emits `previewHttp` in __EXE_EMBEDDING_CONFIG__
  (protocolVersion 2, the two management/serving base URLs, X-WP-Nonce header),
  and no longer monkey-patches navigator.serviceWorker or rewrites the preview
  iframe /viewer/ src. The CSS/idevice 404 shim and UI hiding stay.
- previewHttp is emitted only under pretty permalinks; under plain permalinks
  rest_url() yields the ?rest_route= form that cannot carry a /{id}/{path}
  suffix, so the bootstrap omits previewHttp (the editor fails closed, no silent
  same-origin fallback) and ExeLearning_Editor::maybe_warn_preview_permalinks
  surfaces a WP admin notice. The gate + config live in
  build_preview_http_config()/pretty_permalinks_enabled() (single source).
- The Playground legacy hatch (EXELEARNING_UNSAFE_LEGACY_IFRAME + mu-plugin) is
  untouched; it targets the published viewer, a separate system.

The bundled editor (v4.0.2) predates HttpPreviewProvider, so the wiring has no
in-editor consumer until a newer build ships; it is wired now and covered
headlessly by EditorTest. The Playwright spec asserts the injection when a
capable build is present and skips honestly otherwise.
- Rewrites the "Editor activation" section to the normalized previewHttp
  two-URL model (drops the dead previewTransport + previewBasePath single-base
  vocabulary) and documents the pretty-permalink requirement, the fail-closed
  behaviour and the editor-build dependency (endpoints dormant until a build
  with HttpPreviewProvider + bundles/preview-fixed-resources.json ships).
- Documents the serving deltas: the bare-capability-URL 302 to index.html and
  the Range fallback (malformed / multi-range / non-bytes -> 200 full).
- Adds text/xml to the scriptable-CSP list and rewrites the implementation
  bullets for the serving/management/header split.
- Fixes the vectors README path mismatch (the WordPress mirror lives at
  docs/preview-serving-contract.md; vectors.json is vendored from core) and
  notes the Range/bare-root deltas are covered by unit tests pending a
  re-vendor of vectors.json from core (do not fork the JSON).
Adds translations for the two new admin-notice strings introduced by the
opaque HTTP preview wiring (the pretty-permalink requirement warning and its
"Change Permalink Settings" link) across all ten locales, and refreshes the
POT/PO source references. Isolated in its own commit per repo convention.
The managementHeaders X-WP-Nonce is a standard wp_rest nonce: valid for
nonce_life (default 24h) across the current + previous 12h tick, so it survives
at least 12h and up to 24h — well beyond an editing session, and unlike a fixed
short CSRF-token expiry it will not break a preview mid-session. Documents the
window and the reload-to-re-mint fallback.
@erseco erseco changed the title Add secure (opaque-origin) content iframe mode Secure opaque-origin content iframe + external-media bridge + HTTP editor preview (serving contract v2) Jul 11, 2026
erseco added 5 commits July 11, 2026 14:01
… re-vendor vectors

Adversarial review (Agent K) found parse_range checked satisfiability
($start >= $total) BEFORE structural invalidity ($end < $start), so bytes=15-2
on a 10-byte body returned 416 instead of being ignored. RFC 9110: an inverted
byte-range-spec (last-byte-pos < first-byte-pos) is invalid and MUST be ignored,
regardless of whether the first-byte-pos is also past the end.

- Reorder parse_range: the last<first invalidity check now precedes the
  satisfiability check, so bytes=5-2 AND bytes=15-2 → null → 200 full body.
- Bare-root redirect now emits a RELATIVE Location `{previewId}/index.html`
  (byte-identical to eXe core) instead of an absolute rest_url(); it resolves
  against the trailing-slash-free `.../preview/{previewId}` and stays correct
  under BASE_PATH / app://.
- Re-vendor tests/fixtures/preview-contract/vectors.json verbatim from core
  (adds serve-bare-root-redirect + zero-suffix/inverted/inverted-oob/multi/
  non-bytes/garbage Range cases); adapt the conformance harness only to
  substitute {previewId} in expected header values (the bare-root Location).

Conformance replay green (120 assertions); serving unit tests cover bytes=5-2,
bytes=15-2 and the relative Location.
…ity with core

Adversarial review: SANDBOX_CSP ended with a trailing ';' so it was NOT
byte-identical to core's previewCspHeader() (which joins its directive array
with '; ' and ends at "frame-ancestors 'self'"), contradicting the byte-identity
the docs claim. The drift gate only checks directive substrings, so it missed
this. Functionally inert as a header value, but the contract is byte-identity —
dropped the trailing ';' and adjusted the two test literals. Verified equal to
core's reconstructed output.
…TP-v2 interim

Adversarial review: removing the /viewer/ monkey-patch left NOTHING stopping the
BUNDLED pre-v2 editor (v4.0.2, which still calls loadPreviewFromServiceWorker()
-> /viewer/index.html) from registering preview-sw.js on the WordPress origin —
a same-origin, un-sandboxed preview of untrusted author content, exactly what the
opaque HTTP transport avoids.

Add ExeLearning_Editor::service_worker_guard_script(): a first-thing IIFE
(injected right after <head>, before any editor script) that stubs
navigator.serviceWorker.register to a resolved no-op registration, mirroring the
Nextcloud/Moodle resilience shim. NOT a path rewrite. Interim protection until an
HTTP-v2 editor build ships. Unit-tested for presence + shape.
Adversarial review [HIGH]: the store lives under
wp-content/uploads/exelearning-preview/ guarded only by the Apache .htaccess deny
the store self-heals. nginx ignores .htaccess, so on a default nginx WordPress a
direct GET to a materialized preview document serves untrusted author HTML
SAME-ORIGIN and WITHOUT the sandbox CSP — a second, un-sandboxed serving path
that defeats the opaque-origin isolation.

Ship nginx-exelearning-preview.conf (location ^~ .../exelearning-preview/ ->
403), mirroring Omeka's data/nginx-exelearning.conf, to `include` from the site
server block. Unit test asserts the snippet ships with the deny rule.
…erim

- Bare-root redirect documented as a RELATIVE Location {previewId}/index.html
  (BASE_PATH / app:// safe) and Range as invalidity-before-satisfiability
  (bytes=5-2 and bytes=15-2 -> 200-ignore, never 416).
- Drop the trailing ';' from the documented CSP so it matches core byte-for-byte.
- Point the nginx storage guard at the shipped nginx-exelearning-preview.conf;
  add a README "Server configuration (nginx)" note (pretty-permalink requirement
  + preview-store deny include).
Comment on lines +351 to +395
private function parse_range( $value, $total ) {
if ( empty( $value ) ) {
return null;
}
// Malformed / multi-range / non-bytes unit: ignore -> 200 full body.
if ( ! preg_match( '/^bytes=(\d*)-(\d*)$/', trim( $value ), $m ) ) {
return null;
}
$raw_start = $m[1];
$raw_end = $m[2];
if ( '' === $raw_start && '' === $raw_end ) {
return null;
}
if ( '' === $raw_start ) {
$suffix = (int) $raw_end;
if ( 0 === $suffix || 0 === $total ) {
return 'unsatisfiable';
}
return array(
'start' => max( 0, $total - $suffix ),
'end' => $total - 1,
);
}
$start = (int) $raw_start;
// Structural invalidity (last-byte-pos < first-byte-pos) is checked
// BEFORE satisfiability: an inverted spec is IGNORED (-> 200 full body),
// even when the first-byte-pos is ALSO past the end. bytes=15-2 on a
// 10-byte body must be 200, never 416.
if ( '' !== $raw_end && (int) $raw_end < $start ) {
return null;
}
if ( $start >= $total ) {
return 'unsatisfiable';
}
if ( '' === $raw_end ) {
return array(
'start' => $start,
'end' => $total - 1,
);
}
return array(
'start' => $start,
'end' => min( (int) $raw_end, $total - 1 ),
);
}
erseco added 4 commits July 11, 2026 14:16
Adversarial review (K, conflicting reports — verified against the code): the
store DISCARDED its write return values. copy_file() ignored copy(); atomic_write()
ignored file_put_contents()/rename(). So:
  - store_assets() bumped assetBytes and reported the key in `stored` even when
    the asset copy failed, and
  - stage_and_publish() swapped the `current` pointer even when a document copy
    or the publish rename failed,
violating §5 ("failed writes are never indexed" / "abort before the pointer
swap"). The earlier "integrity OK" review saw the validation + staging structure
but missed the discarded returns.

Fix, matching Moodle/NC:
  - copy_file()/atomic_write() now throw \RuntimeException on any failed write
    (atomic_write also cleans its orphaned temp).
  - store_assets() catches it per entry -> `rejected: write-failed`, never
    indexing the key or bytes.
  - stage_and_publish() return-checks the publish rename, removes the partial
    staging on any failure, and only swaps the pointer once the full revision is
    staged and renamed (the commit point).
  - apply_revision() maps a write failure to a clean 500 with the active revision
    unchanged.

Regression tests force real write failures (copy into a directory target;
rename into a non-empty target) and assert write-failed rejection / no assetBytes
bump, and 500 + unswapped pointer + no stray staging.
…ntract §5)

Follow-up to the §5 write-return fix (643da31), addressing the reviewer's
short-write note and the reference pattern Omeka landed (ce538b1):

- atomic_write() now compares file_put_contents()'s return against the payload
  length, not just `=== false`. A disk-full SHORT write returns a byte count
  below the length (not false), so without this a truncated meta.json / refs.json
  / pointer could be renamed into place and served. A failed/short write drops
  the temp file (new drop_temp helper) and throws.
- Regression test: a revision-2 document copy is forced to fail (write points at
  a directory) AFTER a good revision 1 — asserts 500, the pointer stays on
  revision 1, revision 1's document is still served intact, and revisions/2 was
  never created (prior revision survives, no partial publish).

copy_file() already fails closed on partial copies (copy() returns false on any
failure). WP uses throw/catch where Omeka returns bool; the §5 semantics —
never index a failed write, abort before the pointer swap — are identical.
@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
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