diff --git a/.gitignore b/.gitignore index a4dca9c..fb06583 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ public/style/workarea/*.css.map # Test results test-results/ test-results/* +playwright-report/ # Built static editor - download from releases or build with `make build-editor` dist/static/ diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 5273f60..d96a67d 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -9,9 +9,16 @@ /tests/* /includes/vendor/* /dist/* - /exelearning/* + + */wp-content/uploads/exelearning/* /bin/* + + /artifacts/* diff --git a/README.md b/README.md index b17cc3f..83a410f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ EXELEARNING_EDITOR_REF=my-feature EXELEARNING_EDITOR_REF_TYPE=branch make build- > **Important:** For production use, always install an official release from [Releases](https://github.com/exelearning/wp-exelearning/releases): release packages include the embedded editor pre-built under `dist/static/`, and that bundle is the only editor the plugin ever uses. The plugin never downloads editor code at runtime, and administrators cannot update the editor independently of the plugin — updating the editor means updating the plugin (a new plugin release is published automatically for every editor release). Source checkouts do not contain `dist/static/`; build it with `make build-editor` as shown above. See [ADR-0002](docs/architecture/adr/ADR-0002-bundle-editor-exclusively-in-release-packages.md). +### Server configuration (nginx) + +The live editor **preview** requires **pretty permalinks** (Settings → Permalinks; any structure other than *Plain*) — under plain permalinks the plugin fails closed and shows an admin notice. + +On **nginx**, also block direct web access to the ephemeral preview session store: the plugin serves those bytes through an opaque-origin capability URL with a sandbox CSP, and unlike Apache (`.htaccess`) nginx will otherwise serve the materialized author HTML same-origin without that CSP. Include the shipped snippet from your `server { … }` block: + +```nginx +include /path/to/wp-content/plugins/exelearning/nginx-exelearning-preview.conf; +``` + +See [`nginx-exelearning-preview.conf`](nginx-exelearning-preview.conf) and [`docs/preview-serving-contract.md`](docs/preview-serving-contract.md) for details. + ## Usage ### Uploading ELPX Files @@ -103,6 +115,30 @@ Administrators can upload eXeLearning style packages and control which styles th Uploaded ZIPs are validated against path traversal, absolute paths, oversize archives (default 20 MB, filterable via `exelearning_styles_max_zip_size`), and a strict file-extension allow-list. +## External embeds in secure mode + +In secure mode the `.elpx` content runs in a sandboxed, opaque-origin iframe. That +opaque origin propagates to any nested iframe, so cross-origin video players and PDF +viewers render blank. To keep them working, whitelisted video embeds (YouTube and +Vimeo hosts), any cross-origin `https` `.pdf`, and the package's own local PDFs are +*promoted* to the trusted parent page and rendered inline on top of the content. + +Two cooperating scripts make this work: + +- `assets/js/exe-embed-shim.js` runs inside the content iframe, replaces each + promotable iframe with a same-size placeholder, and `postMessage`s its geometry + and URL to the parent. +- `assets/js/exe-embed-relay.js` runs on the host page, validates each reported URL + against the whitelist, rebuilds the canonical player URL, and overlays the real + player exactly over the placeholder. + +A static Firefox end-to-end test exercises the real shim and relay against a +self-contained harness (no WordPress runtime needed): + +```bash +npm run test:e2e:embed +``` + ## Developer hooks The plugin exposes a set of WordPress actions and filters (all prefixed with diff --git a/admin/views/editor-bootstrap.php b/admin/views/editor-bootstrap.php index 492f597..814b1a7 100644 --- a/admin/views/editor-bootstrap.php +++ b/admin/views/editor-bootstrap.php @@ -111,6 +111,21 @@ 'fallbackTheme' => 'base', ); +// Opaque snapshot editor-preview (capability contract v1). The editor POSTs a +// whole-project ZIP snapshot to the authenticated management route and loads the +// result from an authless, opaque-origin capability URL. REST URLs resolve under +// both pretty and plain permalinks. wp_json_encode() output is valid JS syntax. +$exelearning_preview_delete_url = rest_url( + 'exelearning/v1/preview-session/' . $exelearning_attachment_id . '/__PREVIEW_ID__' +); +$exelearning_preview_snapshot = array( + 'managementUrl' => rest_url( 'exelearning/v1/preview-session/' . $exelearning_attachment_id ), + 'servingBaseUrl' => rest_url( 'exelearning/v1/preview/' ), + 'deleteUrlTemplate' => str_replace( '__PREVIEW_ID__', '{previewId}', $exelearning_preview_delete_url ), + 'managementHeaders' => array( 'X-WP-Nonce' => $exelearning_nonce ), +); +$exelearning_preview_snapshot_js = "\n previewSnapshot: " . wp_json_encode( $exelearning_preview_snapshot ) . ','; + // Inject WordPress configuration BEFORE the closing tag. // phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template. $exelearning_wp_config_script = sprintf( @@ -200,15 +215,18 @@ enumerable: true, fileMenu: true, saveButton: true, userMenu: true, - }, + },%s }; - // TODO: Remove when editor ResourceFetcher handles 404 gracefully. - // Patch fetch and jQuery AJAX to handle CSS/idevices 404s without breaking. + // Embedded-editor shims: hide the chrome the host owns and soften CSS / + // idevice 404s so a missing optional asset never breaks boot. The live + // preview travels as an opaque snapshot capability (see previewSnapshot + // above), never a Service Worker on the WordPress origin — so there is no + // preview-sw / /viewer/ wiring here. + // TODO: Remove the 404 shim when the editor ResourceFetcher handles 404 + // gracefully. (function() { var editorBaseUrl = (window.__WP_EXE_CONFIG__ && window.__WP_EXE_CONFIG__.editorBaseUrl) || ""; - var editorBasePathname = ""; - var originalServiceWorker = navigator.serviceWorker || null; var forceHideSelectors = [ "#dropdownFile", "#head-top-save-button", @@ -218,12 +236,6 @@ enumerable: true, "#mobile-navbar-button-openuserodefiles" ]; - try { - editorBasePathname = editorBaseUrl ? new URL(editorBaseUrl, window.location.origin).pathname : ""; - } catch (e) { - editorBasePathname = ""; - } - function forceHideEmbeddedUi() { for (var i = 0; i < forceHideSelectors.length; i += 1) { var nodes = document.querySelectorAll(forceHideSelectors[i]); @@ -243,94 +255,20 @@ function forceHideEmbeddedUi() { } } - function normalizePreviewIframeSrc(url) { - if (!url || !editorBaseUrl) { - return url; - } - - var baseNoSlash = editorBaseUrl.replace(/\/$/, ""); - var raw = url; - - try { - if (raw.startsWith("http://") || raw.startsWith("https://")) { - raw = new URL(raw).pathname; - } - } catch (e) {} - - if (raw.indexOf("/wp-admin/admin.php/viewer/") === 0) { - return baseNoSlash + "/viewer/" + raw.substring("/wp-admin/admin.php/viewer/".length); - } - if (raw.indexOf("/viewer/") === 0) { - return baseNoSlash + raw; - } - if (raw.indexOf("viewer/") === 0) { - return baseNoSlash + "/" + raw; - } - - return url; - } - - function ensurePreviewIframeSrc() { - var previewIframe = document.getElementById("preview-iframe"); - if (!previewIframe) { - return; - } - - var currentSrc = previewIframe.getAttribute("src") || previewIframe.src || ""; - var fixedSrc = normalizePreviewIframeSrc(currentSrc); - if (fixedSrc && fixedSrc !== currentSrc) { - previewIframe.setAttribute("src", fixedSrc); - } - } - if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", forceHideEmbeddedUi); - document.addEventListener("DOMContentLoaded", ensurePreviewIframeSrc); } else { forceHideEmbeddedUi(); - ensurePreviewIframeSrc(); } var hideObserver = new MutationObserver(function() { forceHideEmbeddedUi(); - ensurePreviewIframeSrc(); }); hideObserver.observe(document.documentElement || document.body, { childList: true, - subtree: true, - attributes: true, - attributeFilter: ["src"] + subtree: true }); - // Fix preview service worker paths in WP mode. - if (originalServiceWorker && editorBasePathname) { - var registerOriginal = originalServiceWorker.register.bind(originalServiceWorker); - var getRegistrationOriginal = originalServiceWorker.getRegistration.bind(originalServiceWorker); - var fixedSwPath = editorBasePathname.replace(/\/$/, "") + "/preview-sw.js"; - var fixedScope = editorBasePathname.replace(/\/$/, "") + "/viewer/"; - - originalServiceWorker.register = function(scriptURL, options) { - var nextScript = scriptURL; - var nextOptions = options || {}; - if (typeof nextScript === "string" && nextScript.indexOf("preview-sw.js") !== -1) { - nextScript = fixedSwPath; - nextOptions = Object.assign({}, nextOptions, { scope: fixedScope }); - } - return registerOriginal(nextScript, nextOptions); - }; - - originalServiceWorker.getRegistration = function(clientURL) { - var nextClientUrl = clientURL; - if ( - !nextClientUrl || - (typeof nextClientUrl === "string" && nextClientUrl.indexOf("/wp-admin/") === 0) - ) { - nextClientUrl = fixedScope; - } - return getRegistrationOriginal(nextClientUrl); - }; - } - function normalizeEditorAssetUrl(url) { if (!url || typeof url !== "string" || !editorBaseUrl) { return url; @@ -482,6 +420,7 @@ function normalizeEditorAssetUrl(url) { wp_json_encode( $exelearning_editor_base_url ), wp_json_encode( $exelearning_i18n ), wp_json_encode( $exelearning_theme_registry_override ), + $exelearning_preview_snapshot_js, esc_url( $exelearning_plugin_assets_url ) ); // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript @@ -538,10 +477,23 @@ function normalizeEditorAssetUrl(url) { // Insert config script and styles before . $exelearning_template = str_replace( '', $exelearning_wp_config_script . $exelearning_page_styles . '', $exelearning_template ); -// Add tag to set the base URL for all relative paths. -// This ensures paths like "files/perm/..." resolve to the static editor directory. +// Add tag to set the base URL for all relative paths, and a first-thing +// Service Worker hygiene script that purges caches left by an earlier editor +// build (stale assets could otherwise drive the preview into a refresh loop). +// It does NOT block registration: the trust-boundary editor registers its own +// /viewer/ preview worker, which serves only DOMPurify-sanitized ("filtered") +// content from a real same-origin URL — required so whitelisted external videos +// play inline (see ExeLearning_Editor::purge_stale_editor_caches_script). Both are +// injected right after , before any editor script runs. +// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template. +$exelearning_cache_purge_tag = ''; +// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript $exelearning_base_tag = sprintf( '', esc_url( $exelearning_editor_base_url ) ); -$exelearning_template = preg_replace( '/(]*>)/i', '$1' . $exelearning_base_tag, $exelearning_template ); +$exelearning_template = preg_replace( + '/(]*>)/i', + '$1' . $exelearning_cache_purge_tag . $exelearning_base_tag, + $exelearning_template +); // Fix asset paths: Replace relative paths with absolute plugin paths. // The static build uses relative paths like "./app/", we need absolute paths. diff --git a/assets/css/exelearning.css b/assets/css/exelearning.css index 1285c74..0ab6441 100644 --- a/assets/css/exelearning.css +++ b/assets/css/exelearning.css @@ -291,6 +291,56 @@ max-width: 100%; } +/* Loading state for the embedded package. + The package is fetched, parsed and laid out inside the iframe before anything + paints, so without this the visitor stares at an empty frame. The spinner is + hidden unless the enqueued loader adds `is-loading`, so with JS disabled + nothing is shown that JS would never be able to clear. */ +.exelearning-embed-loader { + position: relative; +} + +.exelearning-embed-loader__spinner { + display: none; + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + gap: 0.6em; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + color: #50575e; + font-size: 0.875rem; +} + +/* Only the visibility depends on the state. */ +.exelearning-embed-loader.is-loading .exelearning-embed-loader__spinner { + display: flex; +} + +.exelearning-embed-loader__spinner::before { + content: ""; + width: 1.25em; + height: 1.25em; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: exelearning-embed-spin 0.8s linear infinite; +} + +@keyframes exelearning-embed-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .exelearning-embed-loader__spinner::before { + animation-duration: 3s; + } +} + .exelearning-screenshot-img { display: block; width: 100%; diff --git a/assets/js/exe-embed-relay.js b/assets/js/exe-embed-relay.js new file mode 100644 index 0000000..9a442b8 --- /dev/null +++ b/assets/js/exe-embed-relay.js @@ -0,0 +1,635 @@ +/** + * eXeLearning external-embed relay (runs on the HOST page, a trusted context). + * + * Companion to exe-embed-shim.js. Listens for geometry reports posted by the + * opaque content iframe(s), validates each embed URL, and renders the real + * player as an inline overlay positioned exactly over the placeholder inside the + * content iframe. + * + * Trust model (DEC-0061): the promoted player is rendered cross-origin and + * SANDBOXED, so the same-origin policy isolates it from this host page (it cannot + * read the DOM, cookies or session). Two modes: + * - 'open' (default): promote any iframe whose src is https AND cross-origin to + * the host (rejecting same-origin, sub/superdomains of the host, IP/loopback/ + * local hosts and userinfo). No host list. The host is irrelevant to escape; + * the residual is phishing/tracking, bounded to the content's own box. + * - 'strict': only a maintained host allowlist with per-provider canonical-URL + * reconstruction, for high-security deployments. + * "Any https .pdf" is always allowed (same-origin only for this package's own files). + * + * Messages are authenticated by event.source (the opaque origin has no usable + * event.origin); the host page is same-origin, so window.location.origin is the + * host origin and the cross-origin check correctly rejects the proxied content. + * + * Config: window.ExeEmbedRelayConfig = { mode: 'open'|'strict', whitelist: [...] }. + * + * MIRROR of the canonical eXeLearning embedder source in eXe core + * (public/app/common/exe_embed_bridge/exe_embed_relay.js). Keep the validate()/makePlayer()/sync() + * logic identical to core; only the export wrapper differs (this one is an auto-running + * IIFE). scripts/check-embed-sync.mjs in eXe core flags drift. + * + * @package Exelearning + */ +( function () { + 'use strict'; + + /** + * Build a host lookup map from a whitelist array (lowercased). Used by 'strict' mode. + * + * @param {string[]} list The whitelist of hostnames. + * @return {Object} Map of lowercase host => true. + */ + function buildWhitelist( list ) { + var map = {}; + ( list || [] ).forEach( function ( host ) { + map[ String( host ).toLowerCase() ] = true; + } ); + return map; + } + + /** + * Directory portion of the content iframe src (everything up to the last '/'). + * + * @param {string} src The content iframe src. + * @return {string} The base directory URL, or '' if it cannot be parsed. + */ + function contentDir( src ) { + try { + return new URL( src, window.location.href ).href.replace( /[^/]*([?#].*)?$/, '' ); + } catch ( e ) { + return ''; + } + } + + /** + * Long hex token shared by the content URL and its assets (null when there is + * none, e.g. content URLs that use numeric ids). + * + * @param {string} src The content iframe src. + * @return {?string} The hash, or null if none is found. + */ + function packageId( src ) { + var match = String( src ).match( /[a-f0-9]{12,}/i ); + return match ? match[ 0 ] : null; + } + + /** + * Whether a same-origin URL is one of this package's own extracted files: under + * the content's own directory, or carrying the package hash as a path segment. + * + * @param {URL} url The candidate URL (already same-origin). + * @param {string} contentSrc The content iframe src. + * @return {boolean} True when the URL belongs to this package. + */ + function isSameOriginPackageFile( url, contentSrc ) { + var dir = contentDir( contentSrc ); + if ( dir && 0 === url.href.indexOf( dir ) ) { + return true; + } + var id = packageId( contentSrc ); + return !! ( id && url.pathname.indexOf( '/' + id + '/' ) !== -1 ); + } + + /** + * Whether a host is an IP literal (v4/v6) or a loopback/local name. Such hosts are + * cross-origin to the host yet target the machine/internal network, so they are + * rejected even though SOP would isolate them. + * + * @param {string} host Lowercased URL.hostname. + * @return {boolean} True when the host is an IP or local name. + */ + function isIpOrLocalHost( host ) { + if ( ! host ) { + return true; + } + if ( 'localhost' === host || /\.localhost$/.test( host ) || /\.local$/.test( host ) ) { + return true; + } + if ( '[' === host.charAt( 0 ) || host.indexOf( ':' ) !== -1 ) { + return true; // IPv6 (bracketed). + } + if ( /^\d{1,3}(\.\d{1,3}){3}$/.test( host ) ) { + return true; // Any IPv4 literal. + } + return false; + } + + /** + * Lowercase a hostname and strip a single trailing dot. 'host.example.org.' (the + * FQDN-root form) resolves to the same vhost as 'host.example.org' but compares + * unequal as a raw string, so without this it would slip past the same-origin / + * related-to-host gate below and be promoted as a cross-origin player. + * + * @param {string} host The hostname to normalise. + * @return {string} The lowercased hostname without a trailing dot. + */ + function normalizeHost( host ) { + return ( host || '' ).toLowerCase().replace( /\.$/, '' ); + } + + /** + * Whether a host equals, is a subdomain of, or is a superdomain of the host page's + * host (dotted boundary so 'evil-host.example' does not match 'host.example'). Such + * hosts may share the host page's cookies, so they are rejected. Both sides are + * normalised so the trailing-dot FQDN-root form cannot evade the comparison. + * + * @param {string} host The candidate host. + * @param {string} lmsHost The host page's host. + * @return {boolean} True when the host is related to the host page. + */ + function isRelatedToLms( host, lmsHost ) { + host = normalizeHost( host ); + lmsHost = normalizeHost( lmsHost ); + if ( ! lmsHost ) { + return false; + } + return host === lmsHost || host.endsWith( '.' + lmsHost ) || lmsHost.endsWith( '.' + host ); + } + + /** + * The structural invariant: an https URL cross-origin to the host page and not + * pointing at a sub/superdomain, an IP/loopback/local host, or carrying userinfo. + * This is the only attacker-influenced gate in 'open' mode, and it is what makes + * the sandboxed player's allow-same-origin safe (the embed keeps ITS OWN origin, + * isolated by SOP). + * + * @param {URL} url The candidate URL. + * @return {boolean} True when the URL is a safe cross-origin https embed. + */ + function isCrossOriginHttps( url ) { + if ( 'https:' !== url.protocol ) { + return false; + } + if ( url.username || url.password ) { + return false; + } + if ( url.origin === window.location.origin ) { + return false; + } + var host = normalizeHost( url.hostname ); + if ( isIpOrLocalHost( host ) ) { + return false; + } + var lmshost = ( window.location && window.location.hostname ) ? window.location.hostname : ''; + if ( isRelatedToLms( host, lmshost ) ) { + return false; + } + return true; + } + + // Provider templates for the id-only channel (DEC-0067): the parent rebuilds the canonical + // embed URL from {provider, objectId} reported by the shim, re-checking the object id + // against a strict per-provider regex so it cannot carry a path/query/fragment and escape + // the template (e.g. '../../x' or 'a/b?c'). The reconstructed URL still runs through + // validate() (structural invariant / strict whitelist), so this narrows the surface for + // recognised providers; it is not, by itself, the trust gate. + var PROVIDER_TEMPLATES = { + youtube: { re: /^[A-Za-z0-9_-]{6,}$/, build: function ( id ) { return 'https://www.youtube-nocookie.com/embed/' + id; } }, + vimeo: { re: /^[0-9]+$/, build: function ( id ) { return 'https://player.vimeo.com/video/' + id; } }, + dailymotion: { re: /^[A-Za-z0-9]{5,}$/, build: function ( id ) { return 'https://www.dailymotion.com/embed/video/' + id; } }, + 'mediateca-madrid': { re: /^[A-Za-z0-9]{8,}$/, build: function ( id ) { return 'https://mediateca.educa.madrid.org/video/' + id + '/fs'; } } + }; + + /** + * Rebuild the canonical embed URL for a recognised provider from its object id, or null + * if the provider is unknown or the id is not the exact shape the template expects. + * + * @param {string} provider The provider key reported by the shim. + * @param {string} objectId The provider object id reported by the shim. + * @return {?string} The canonical embed URL, or null. + */ + function reconstructProvider( provider, objectId ) { + var t = PROVIDER_TEMPLATES[ provider ]; + if ( ! t || typeof objectId !== 'string' || ! t.re.test( objectId ) ) { + return null; + } + return t.build( objectId ); + } + + /** + * Validate an embed URL. Returns { url, kind ('video'|'pdf'), sameorigin? } or null. + * + * @param {string} raw The reported (absolute) embed URL. + * @param {string} contentSrc The src of the content iframe that reported it. + * @param {Object} opts { strict: boolean, whitelist: Object }. + * @return {?Object} The validated result, or null. + */ + function validate( raw, contentSrc, opts ) { + opts = opts || {}; + var url; + try { + // Parse as an ABSOLUTE URL (the shim always reports absolute). No base: + // a relative/scheme-relative value would otherwise inherit the host origin + // and pass as same-origin -- here it throws and is rejected instead. + url = new URL( raw ); + } catch ( e ) { + return null; + } + if ( url.username || url.password ) { + return null; // Reject userinfo, e.g. https://evil.com@youtube.com/. + } + var host = url.hostname.toLowerCase(); + + // PDFs: any cross-origin https .pdf, or a same-origin file that belongs to this + // package (served as application/pdf + nosniff, never executable HTML). + if ( /\.pdf$/i.test( url.pathname ) ) { + if ( url.origin === window.location.origin ) { + return isSameOriginPackageFile( url, contentSrc ) ? { url: url.href, kind: 'pdf', sameorigin: true } : null; + } + return isCrossOriginHttps( url ) ? { url: url.href, kind: 'pdf' } : null; + } + + // Strict mode: maintained host allowlist + per-provider canonical reconstruction. + if ( opts.strict ) { + var whitelist = opts.whitelist || {}; + if ( whitelist[ host ] && 'https:' === url.protocol ) { + var m; + if ( host.indexOf( 'youtube' ) !== -1 ) { + m = url.pathname.match( /^\/embed\/([A-Za-z0-9_-]{6,})$/ ); + return m ? { url: 'https://www.youtube-nocookie.com/embed/' + m[ 1 ], kind: 'video' } : null; + } + if ( host.indexOf( 'vimeo' ) !== -1 ) { + m = url.pathname.match( /^\/video\/([0-9]+)$/ ); + return m ? { url: 'https://player.vimeo.com/video/' + m[ 1 ], kind: 'video' } : null; + } + if ( host.indexOf( 'dailymotion' ) !== -1 ) { + m = url.pathname.match( /^\/embed\/video\/([A-Za-z0-9]{5,})$/ ); + return m ? { url: 'https://www.dailymotion.com/embed/video/' + m[ 1 ], kind: 'video' } : null; + } + if ( 'mediateca.educa.madrid.org' === host ) { + m = url.pathname.match( /^\/video\/([A-Za-z0-9]{8,})(?:\/fs)?$/ ); + return m ? { url: 'https://mediateca.educa.madrid.org/video/' + m[ 1 ] + '/fs', kind: 'video' } : null; + } + } + return null; + } + + // Open mode (default): any cross-origin https iframe is a video embed. + return isCrossOriginHttps( url ) ? { url: url.href, kind: 'video' } : null; + } + + /** + * Create a SANDBOXED player iframe for a validated embed. The video player gets + * allow-same-origin so the cross-origin provider keeps its own origin and renders, + * while NO allow-top-navigation/allow-modals stops a hostile embed from redirecting + * the host tab or spamming dialogs. A same-origin package PDF is left unsandboxed (the + * browser PDF viewer needs it); a CROSS-ORIGIN PDF is sandboxed with allow-same-origin + * only (no allow-scripts, no allow-top-navigation) so it cannot redirect the host tab. + * + * @param {Object} result { url, kind, sameorigin? } from validate(). + * @return {HTMLIFrameElement} The configured player iframe. + */ + function makePlayer( result ) { + var frame = document.createElement( 'iframe' ); + frame.style.cssText = 'position:absolute;border:0;pointer-events:auto;'; + // Mark as a player so it is never mistaken for a content source (message auth). + frame.setAttribute( 'data-exe-embed-player', '1' ); + if ( 'video' === result.kind ) { + frame.setAttribute( 'sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms allow-presentation' ); + frame.setAttribute( 'allow', 'autoplay; encrypted-media; fullscreen; picture-in-picture; clipboard-write' ); + frame.setAttribute( 'allowfullscreen', '' ); + frame.setAttribute( 'referrerpolicy', 'strict-origin-when-cross-origin' ); + } else if ( result.sameorigin ) { + // Same-origin PDF that belongs to THIS package: served application/pdf + nosniff + // (never executable HTML), left unsandboxed so the browser's built-in PDF viewer + // renders it (it shows the broken-document icon inside a sandbox). The load guard + // still removes it if it redirects to the host origin. + frame.setAttribute( 'allow', 'fullscreen' ); + frame.setAttribute( 'referrerpolicy', 'no-referrer' ); + } else { + // Cross-origin PDF whose URL comes from the untrusted package. A server can serve + // scripted HTML at a ".pdf" path; UNSANDBOXED, that frame could top-navigate the + // host tab to a phishing page. Sandbox it WITHOUT allow-top-navigation/allow-scripts; + // allow-same-origin keeps the provider's own origin (SOP-isolated from the host). + // Trade-off: a genuine cross-origin PDF may show the broken-document icon. (audit M-3) + frame.setAttribute( 'sandbox', 'allow-same-origin' ); + frame.setAttribute( 'allow', 'fullscreen' ); + frame.setAttribute( 'referrerpolicy', 'no-referrer' ); + } + frame.src = result.url; + // Tag with the URL it renders so sync() can detect when a reused embed id (the + // shim restarts its counter per page) now points at a different URL. + frame.setAttribute( 'data-exe-embed-src', result.url ); + return frame; + } + + /** + * Create a relay instance. + * + * @param {Object} config { mode: 'open'|'strict', whitelist: string[] }. + * @return {Object} The relay instance. + */ + function createRelay( config ) { + config = config || {}; + var strict = 'strict' === config.mode; + var whitelist = buildWhitelist( config.whitelist ); + var overlays = []; + // Handles for the listeners/timer init() installs, so dispose() can remove + // them and init() can stay idempotent (a second init() must not stack a + // second drift interval + duplicate window listeners on the same relay). + var driftTimer = null; + var started = false; + + function findOverlay( iframe ) { + for ( var i = 0; i < overlays.length; i++ ) { + if ( overlays[ i ].iframe === iframe ) { + return overlays[ i ]; + } + } + return null; + } + + // Resolve the CONTENT iframe a message came from. Promoted players are excluded + // (data-exe-embed-player): a sandboxed player with allow-same-origin could + // otherwise postMessage a forged 'sync' and impersonate a content source. + function frameForSource( source ) { + var frames = document.getElementsByTagName( 'iframe' ); + for ( var i = 0; i < frames.length; i++ ) { + if ( frames[ i ].getAttribute( 'data-exe-embed-player' ) ) { + continue; + } + if ( frames[ i ].contentWindow === source ) { + return frames[ i ]; + } + } + return null; + } + + function overlayFor( iframe ) { + var entry = findOverlay( iframe ); + if ( entry ) { + return entry; + } + var el = document.createElement( 'div' ); + el.className = 'exe-embed-overlay'; + el.style.cssText = 'position:absolute;overflow:hidden;pointer-events:none;z-index:2147483646;'; + document.body.appendChild( el ); + entry = { iframe: iframe, el: el, players: {} }; + overlays.push( entry ); + return entry; + } + + function positionOverlay( entry, rect ) { + rect = rect || entry.iframe.getBoundingClientRect(); + var scrollX = window.pageXOffset || document.documentElement.scrollLeft || 0; + var scrollY = window.pageYOffset || document.documentElement.scrollTop || 0; + entry.el.style.left = ( rect.left + scrollX ) + 'px'; + entry.el.style.top = ( rect.top + scrollY ) + 'px'; + entry.el.style.width = rect.width + 'px'; + entry.el.style.height = rect.height + 'px'; + // Remembered so checkDrift() can detect host-driven moves of the content + // iframe (panel toggles, sidebar show/hide) that fire no scroll/resize. + entry.lastRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; + } + + /** + * Re-position any overlay whose content iframe box moved since it was last + * placed. The host page can move or resize the content iframe without any + * scroll/resize event firing (editor sidebar toggles, panel slide-ins, CSS + * transforms), which would strand the overlay at its old position. Called from a + * low-frequency interval in init(); one getBoundingClientRect per overlay per + * tick. Returns how many overlays were re-positioned. + * + * @return {number} The number of overlays re-positioned. + */ + function checkDrift() { + var moved = 0; + for ( var i = 0; i < overlays.length; i++ ) { + var entry = overlays[ i ]; + var rect = entry.iframe.getBoundingClientRect(); + var last = entry.lastRect; + if ( + ! last || + rect.left !== last.left || + rect.top !== last.top || + rect.width !== last.width || + rect.height !== last.height + ) { + positionOverlay( entry, rect ); + moved += 1; + } + } + return moved; + } + + // D1: if a promoted embed lands SAME-ORIGIN to the host (e.g. a cross-origin URL + // that 30x-redirects to this origin), with allow-same-origin it would become + // scriptable against this page -> remove it. A genuine cross-origin player throws + // on contentWindow.document (expected, kept). Not armed for same-origin package + // PDFs (intentionally same-origin, served as application/pdf). + function armSameOriginGuard( entry, id, player ) { + player.addEventListener( 'load', function () { + try { + if ( player.contentWindow && player.contentWindow.document ) { + if ( player.parentNode ) { + player.parentNode.removeChild( player ); + } + if ( entry.players[ id ] === player ) { + delete entry.players[ id ]; + } + } + } catch ( e ) { /* cross-origin: expected, keep the player */ } + } ); + } + + function sync( entry, embeds, contentSrc ) { + // The content iframe's box is invariant across this sync pass (the loop only + // mutates the overlay and its players), so read it once and reuse it for the + // overlay position and every player clamp -- avoids a forced reflow per embed. + var rect = entry.iframe.getBoundingClientRect(); + positionOverlay( entry, rect ); + var seen = {}; + embeds.forEach( function ( embed ) { + if ( ! embed || typeof embed.id !== 'string' ) { + return; + } + if ( ! isFinite( embed.x ) || ! isFinite( embed.y ) || ! isFinite( embed.w ) || ! isFinite( embed.h ) ) { + return; + } + // id-only channel (DEC-0067): for recognised providers the shim reports + // {provider, objectId} and the parent rebuilds the canonical URL from a + // fixed template (the author URL never crosses for these). Unknown embeds + // keep the URL path. Either way validate() runs the structural invariant. + var raw = embed.url; + if ( embed.provider && embed.objectId ) { + raw = reconstructProvider( embed.provider, embed.objectId ); + if ( ! raw ) { + return; + } + } + var result = validate( raw, contentSrc, { strict: strict, whitelist: whitelist } ); + if ( ! result ) { + return; + } + seen[ embed.id ] = true; + var player = entry.players[ embed.id ]; + // After the content navigates, the shim reuses ids (exe-embed-1, ...) for + // the new page's embeds. If this id now renders a different URL, drop the + // stale player so the previous page's video does not linger here. + if ( player && player.getAttribute( 'data-exe-embed-src' ) !== result.url ) { + player.parentNode.removeChild( player ); + delete entry.players[ embed.id ]; + player = null; + } + if ( ! player ) { + player = makePlayer( result ); + entry.el.appendChild( player ); + entry.players[ embed.id ] = player; + if ( ! result.sameorigin ) { + armSameOriginGuard( entry, embed.id, player ); + } + } + // Defence in depth against clickjacking: the overlay is clamped to the + // content iframe's box and clips with overflow:hidden, so a player can + // never cover host UI outside the iframe. Cap the player size to the + // overlay too (the content reports geometry, the parent owns rendering). + // Reuses the iframe rect read once at the top of this pass. + player.style.left = embed.x + 'px'; + player.style.top = embed.y + 'px'; + player.style.width = Math.min( embed.w, rect.width ) + 'px'; + player.style.height = Math.min( embed.h, rect.height ) + 'px'; + } ); + Object.keys( entry.players ).forEach( function ( id ) { + if ( ! seen[ id ] ) { + entry.players[ id ].parentNode.removeChild( entry.players[ id ] ); + delete entry.players[ id ]; + } + } ); + } + + function onMessage( event ) { + var data = event.data; + if ( ! data || 'exe-embed' !== data.type || 'sync' !== data.action || ! Array.isArray( data.embeds ) ) { + return; + } + var iframe = frameForSource( event.source ); + if ( ! iframe ) { + return; + } + sync( overlayFor( iframe ), data.embeds, iframe.src ); + } + + // Ask every content iframe to report (covers the case where the shim fired its + // first report before this relay attached its listener). Promoted players are + // excluded so a sandboxed player is never pinged as a content source. + function pingAll() { + var frames = document.getElementsByTagName( 'iframe' ); + for ( var i = 0; i < frames.length; i++ ) { + if ( frames[ i ].getAttribute( 'data-exe-embed-player' ) ) { + continue; + } + try { + frames[ i ].contentWindow.postMessage( { type: 'exe-embed', action: 'request' }, '*' ); + } catch ( e ) { + // Cross-origin player iframes reject this; harmless. + } + } + } + + var scheduled = false; + function scheduleReflow() { + if ( scheduled ) { + return; + } + scheduled = true; + window.requestAnimationFrame( function () { + scheduled = false; + for ( var i = 0; i < overlays.length; i++ ) { + positionOverlay( overlays[ i ] ); + } + } ); + } + + // Remove every overlay and its players (teardown of the rendered layer). + function clear() { + for ( var i = 0; i < overlays.length; i++ ) { + var entry = overlays[ i ]; + var ids = Object.keys( entry.players ); + for ( var j = 0; j < ids.length; j++ ) { + var player = entry.players[ ids[ j ] ]; + if ( player && player.parentNode ) { + player.parentNode.removeChild( player ); + } + } + entry.players = {}; + if ( entry.el && entry.el.parentNode ) { + entry.el.parentNode.removeChild( entry.el ); + } + } + overlays.length = 0; + } + + // Tear down the overlays AND remove the window listeners + drift timer that + // init() installed, so a relay whose host is gone (preview panel disposed, + // tab closed) leaves nothing running. Idempotent; init() can run again + // afterwards on a reused relay. + function dispose() { + clear(); + /* v8 ignore start */ + if ( null !== driftTimer ) { + window.clearInterval( driftTimer ); + driftTimer = null; + } + window.removeEventListener( 'message', onMessage ); + window.removeEventListener( 'resize', scheduleReflow ); + window.removeEventListener( 'scroll', scheduleReflow, true ); + window.removeEventListener( 'load', pingAll ); + /* v8 ignore stop */ + started = false; + } + + return { + onMessage: onMessage, + sync: sync, + checkDrift: checkDrift, + dispose: dispose, + validate: function ( raw, contentSrc ) { + return validate( raw, contentSrc, { strict: strict, whitelist: whitelist } ); + }, + init: function () { + // Idempotent: a second init() on the same relay must not stack a + // second drift interval and duplicate window listeners. + if ( started ) { + return this; + } + started = true; + window.addEventListener( 'message', onMessage ); + window.addEventListener( 'resize', scheduleReflow ); + window.addEventListener( 'scroll', scheduleReflow, true ); + window.addEventListener( 'load', pingAll ); + pingAll(); + window.setTimeout( pingAll, 500 ); + // Host layout changes (sidebar toggles, panel slide-ins) move the + // content iframe with no scroll/resize event; keep the overlays pinned + // to it with a cheap low-frequency drift check. + driftTimer = window.setInterval( checkDrift, 300 ); + return this; + } + }; + } + + var exp = { + buildWhitelist: buildWhitelist, + contentDir: contentDir, + packageId: packageId, + isSameOriginPackageFile: isSameOriginPackageFile, + isIpOrLocalHost: isIpOrLocalHost, + normalizeHost: normalizeHost, + isRelatedToLms: isRelatedToLms, + isCrossOriginHttps: isCrossOriginHttps, + reconstructProvider: reconstructProvider, + validate: validate, + makePlayer: makePlayer, + createRelay: createRelay + }; + // Test runner (Vitest/Node) consumes module.exports. + if ( typeof module !== 'undefined' && module.exports ) { + module.exports = exp; + } + // Browser bootstrap: expose the factory and helpers, then auto-run a relay from the + // host-injected config (window.ExeEmbedRelayConfig is set before this script loads). + if ( typeof window !== 'undefined' ) { + window.exeEmbedRelay = exp; + createRelay( window.ExeEmbedRelayConfig || {} ).init(); + } +} )(); diff --git a/assets/js/exe-embed-shim.js b/assets/js/exe-embed-shim.js new file mode 100644 index 0000000..fa03239 --- /dev/null +++ b/assets/js/exe-embed-shim.js @@ -0,0 +1,280 @@ +/** + * eXeLearning external-embed shim (runs INSIDE the opaque-origin content iframe). + * + * In secure mode the .elpx HTML runs in a sandboxed, opaque-origin iframe. The + * sandbox origin flag propagates to any nested iframe, so cross-origin players + * (YouTube, Vimeo, ...) lose their own origin and render blank. This shim, injected + * by the content proxy only in secure mode, replaces each cross-origin (https) or + * .pdf ' ); + $iframe.attr( 'sandbox', sandbox ); + $iframe.attr( 'src', src ); + $bar.append( $close ); + $overlay.append( $bar ).append( $iframe ); + function remove() { + $( document ).off( 'keydown.exeFs' ); + $overlay.remove(); + } + $close.on( 'click', remove ); + $( document ).on( 'keydown.exeFs', function( e ) { + if ( 'Escape' === e.key ) { + remove(); + } + } ); + $( 'body' ).append( $overlay ); + } + + // Delegated click handler for every fullscreen button (meta box + modal). + $( document ).on( 'click', '.exelearning-fullscreen-btn[data-fullscreen-src]', function( e ) { + e.preventDefault(); + openFullscreenOverlay( $( this ).attr( 'data-fullscreen-src' ) ); + } ); + // Function to replace thumbnail with a preview iframe function replaceElpThumbnail() { $( '.attachment-preview.type-application' ).each( function() { @@ -166,25 +202,8 @@ jQuery( document ).ready( function( $ ) { var metadata = attachment.get( 'exelearning' ); - // Build metadata HTML - var metaHtml = ''; - if ( metadata.license || metadata.language || metadata.resource_type || metadata.version ) { - metaHtml = ''; - } + // The eXeLearning metadata (license, ...) is surfaced through the native + // attachment fields, so no extra "info" block is rendered here. // Check if this file has a preview if ( ! metadata.has_preview || ! metadata.preview_url ) { @@ -194,89 +213,84 @@ jQuery( document ).ready( function( $ ) { '
' + '' + ( strings.noPreview || 'No preview available' ) + '
' + ( strings.noPreviewDesc || 'This is an eXeLearning v2 source file (.elp). To view the content, open it in eXeLearning and export it as HTML.' ) + - '
' + metaHtml + '' ); - - // Still add the edit button for v2 files - addEditButton( attachment, $detailsThumbnail ); + addExeEditAction( attachment, $detailsThumbnail ); return; } // Mark as processed $detailsThumbnail.addClass( 'exelearning-details-preview-added' ); - // Replace image with a scaled iframe (zoom out) - // Container 4:3 aspect ratio for proper preview - var containerWidth = 320; - var containerHeight = 240; - var iframeWidth = 1200; - var iframeHeight = 900; - var scale = containerWidth / iframeWidth; - - // Set fixed dimensions on thumbnail container to prevent overflow - $detailsThumbnail.css({ - 'width': containerWidth + 'px', - 'height': containerHeight + 'px', - 'max-width': containerWidth + 'px', - 'max-height': containerHeight + 'px', - 'overflow': 'hidden', - 'margin-bottom': '15px' - }); - - // Add cache buster to prevent stale content var detailsIframeSrc = metadata.preview_url + ( metadata.preview_url.indexOf( '?' ) > -1 ? '&' : '?' ) + '_cb=' + cacheBuster; - $detailsThumbnail.html( - '
' + + var sandbox = ( settings.sandbox || 'allow-scripts allow-popups allow-forms' ); + + if ( $detailsThumbnail.closest( '.media-sidebar' ).length > 0 ) { + // Media selection sidebar: a compact, zoomed-out thumbnail (fit to + // width) so almost the whole resource is visible without dominating the + // picker. Non-interactive (it is only a thumbnail here). + var boxH = 220; + var boxW = Math.max( 200, $detailsThumbnail.width() || 340 ); + var srcW = 1200; + var s = boxW / srcW; + var srcH = Math.round( boxH / s ); + $detailsThumbnail.css({ + 'width': '100%', 'max-width': 'none', 'height': boxH + 'px', 'max-height': boxH + 'px', + 'overflow': 'hidden', 'margin-bottom': '12px', 'border': '1px solid #ddd', + 'border-radius': '4px', 'background': '#fff', 'display': 'block' + }); + $detailsThumbnail.html( + '' + ); + } else { + // Two-column details modal: large, interactive preview (like WordPress + // shows an image at full size). + $detailsThumbnail.css({ + 'width': '100%', 'max-width': 'none', 'height': 'auto', 'max-height': 'none', + 'overflow': 'visible', 'margin-bottom': '12px' + }); + $detailsThumbnail.html( '' + - '
' - ); - - // Build and insert elements in correct order after thumbnail - // Order: Preview → Edit button → Preview in new tab → Metadata - var $insertPoint = $detailsThumbnail; + 'style="width:100%;height:60vh;min-height:360px;border:1px solid #ddd;border-radius:4px;background:#fff;display:block;" ' + + 'sandbox="' + sandbox + '" referrerpolicy="no-referrer">' + ); + } - // Add "Edit in eXeLearning" button if user can edit - addEditButton( attachment, $insertPoint ); + // "Edit in eXeLearning" centered below the preview (like the native + // "Edit image" button). + addExeEditAction( attachment, $detailsThumbnail ); + } - // Add "Preview in new tab" link after the edit button - var $previewLink = $( - '' - ); - // Find the edit button in the parent container and insert after it - var $editBtn = $detailsThumbnail.parent().find( '.exelearning-edit-button' ); - if ( $editBtn.length > 0 ) { - $editBtn.after( $previewLink ); - $insertPoint = $previewLink; - } else { - $insertPoint.after( $previewLink ); - $insertPoint = $previewLink; + // Add the centered "Edit in eXeLearning" action below $anchor, mirroring the + // native "Edit image" button. The exelearning-edit-page-button class makes + // exelearning-editor.js open the in-page editor modal (href is the fallback). + function addExeEditAction( attachment, $anchor ) { + if ( ! attachment.get( 'exelearningCanEdit' ) ) { + return; } - - // Add metadata at the end (after buttons) - if ( metaHtml ) { - var $meta = $( metaHtml ); - $insertPoint.after( $meta ); + var editUrl = attachment.get( 'exelearningEditUrl' ); + var id = esc( attachment.get( 'id' ) ); + var label = ( strings.editInExe || 'Edit in eXeLearning' ); + + if ( $anchor.closest( '.media-sidebar' ).length > 0 ) { + // Media selection sidebar: a plain blue link, like the native + // "Edit image" link — editing is not the primary task here. + $anchor.after( + '

' + + '' + + label + '

' + ); + } else { + // Details modal: a prominent centered button, like "Edit image". + $anchor.after( + '

' + + '' + + label + '

' + ); } } @@ -531,35 +545,49 @@ jQuery( document ).ready( function( $ ) { return; } - // Create the edit button - styled prominently - var $editButton = $( - '' + - ( strings.editInExe || 'Edit in eXeLearning' ) + '' + - '
' + var metadata = attachment.get( 'exelearning' ) || {}; + + // Compact row: primary "Edit in eXeLearning" + a small fullscreen icon. + // Fullscreen opens the content embedded in an overlay (external videos still + // render via the host relay); the raw content URL is never opened top-level. + var fsButton = ''; + if ( metadata.has_preview && metadata.preview_url ) { + fsButton = ''; + } + var $row = $( + '' ); - $editButton.on( 'click', function( e ) { + $row.find( '.exelearning-edit-button-actions' ).on( 'click', function( e ) { e.preventDefault(); - - // Use the ExeLearningEditor modal if available if ( window.ExeLearningEditor && typeof window.ExeLearningEditor.open === 'function' ) { window.ExeLearningEditor.open( attachmentId, editUrl ); } else { - // Fallback: open in new window window.open( editUrl, '_blank', 'width=1200,height=800' ); } }); // Insert at the beginning of the actions div - $actions.prepend( $editButton ); + $actions.prepend( $row ); } // Run all update functions function runAllUpdates() { replaceElpThumbnail(); + // addElpPreviewToDetails() renders the large preview + a single centered + // "Edit in eXeLearning" action below it (addExeEditAction), mirroring the + // native "Edit image" button — in both the details modal and the selection + // sidebar. No separate button in the actions area, no fullscreen here. addElpPreviewToDetails(); - addEditButtonToAttachmentInfo(); } // Observe DOM changes to detect when attachments are added diff --git a/blueprint.json b/blueprint.json index b1539a7..9a54142 100644 --- a/blueprint.json +++ b/blueprint.json @@ -38,6 +38,11 @@ "step": "runPHP", "code": "set_permalink_structure('/%postname%/'); $wp_rewrite->flush_rules(true); ?>" }, + { + "step": "writeFile", + "path": "/wordpress/wp-content/mu-plugins/exelearning-playground-legacy.php", + "data": "

eXeLearning: the UNSAFE legacy same-origin iframe (EXELEARNING_UNSAFE_LEGACY_IFRAME) is enabled by this Playground-only mu-plugin. Embedded content runs same-origin because php-wasm cannot serve opaque subframes. This dev-only escape hatch must never ship in production.

\";\n} );\n" + }, { "step": "writeFile", "path": "/tmp/test-content.elpx", diff --git a/docs/experiments/secure-opfs-sandbox-analysis.md b/docs/experiments/secure-opfs-sandbox-analysis.md new file mode 100644 index 0000000..181417c --- /dev/null +++ b/docs/experiments/secure-opfs-sandbox-analysis.md @@ -0,0 +1,225 @@ +# Secure OPFS sandbox experiment: WordPress baseline analysis + +## Scope and evidence + +This document records the pre-implementation baseline for the OPFS + Service Worker experiment. It does not claim that OPFS is a security sandbox and does not activate or remove any transport. + +| Item | Value | +| --- | --- | +| Experimental branch | `feature/secure-opfs-sandbox` | +| Local `main` SHA | `faebab692e522d111f9c2db36654bb5109029430` | +| Experimental branch initial SHA | `faebab692e522d111f9c2db36654bb5109029430` | +| Reference PR | exelearning/wp-exelearning#56 | +| Reference branch inspected | `origin/feature/secure-iframe-sandbox` | +| Reference SHA | `3697029120bee28cd64ca63b2f2b9be0b5f877ca` | +| Stale local reference branch | `feature/secure-iframe-sandbox` at `30800a8479e45b3238d40fb60dfc0a204cf0d6e7` | + +The reference was analyzed with `git log main..reference`, `git diff --stat main...reference`, `git diff --name-status main...reference`, and the complete diff. The remote-tracking branch is used because it contains the PR tip; the similarly named local branch is older. + +## Invariant and experiment priority + +OPFS determines where bytes are stored. A Service Worker converts those bytes into virtual HTTP responses. The iframe origin and sandbox determine what authored JavaScript can attack. These properties are independent. + +The primary target is the static/PWA editor preview, whose generated documents and project asset bytes are already in the browser. The plugin experiment is a secondary adapter. If an OPFS viewer cannot satisfy the security or compatibility gates in WordPress, the opaque HTTP capability transport remains the supported fallback. Because no dedicated viewer origin is available for the current deployment, a same-origin OPFS result can only be classified as `same-origin-trusted`; it cannot replace the opaque boundary for untrusted authored content. + +## Current and reference designs + +### `main` + +WordPress embeds a static editor and currently carries path adaptations for its Service Worker viewer. Published ELPX content is extracted and served by the plugin's existing content proxy. Editor preview, published rendering, authenticated save/load, and media attachment storage are distinct concerns and must remain distinct. + +### Reference opaque HTTP preview + +PR #56 introduces Preview Serving Contract v2. The editor receives a `previewHttp` configuration with separate management and serving base URLs. Authenticated, nonce-protected management routes create a session, upload immutable assets, atomically publish document revisions, and delete the session. A cookieless capability route serves the active tree. Authored documents run in an iframe without `allow-same-origin` and scriptable responses receive a sandbox CSP. + +The private file-backed store defaults outside `wp-content/uploads`, is site-scoped, enforces owner/session/global budgets, retains revisions for in-flight reads, and is reclaimed through request-time expiry and WP-Cron. Fixed build resources are resolved rather than uploaded. The serving controller implements explicit MIME types, `nosniff`, ETag, single-range responses, relative bare-root redirects, and traversal-safe exact-key lookup. + +The adapter intentionally requires pretty permalinks because the capability subtree must preserve relative resource navigation. Its released editor artifact predates the HTTP provider, so endpoint tests alone do not prove end-to-end activation. + +## Relevant files in the reference branch + +### Editor integration and configuration + +- `admin/views/editor-bootstrap.php` +- `includes/class-exelearning-editor.php` +- `includes/class-exelearning.php` +- `exelearning.php` +- `blueprint.json` + +### HTTP preview management, storage, and serving + +- `includes/class-preview-management-controller.php` +- `includes/class-preview-serving-controller.php` +- `includes/class-preview-session-store.php` +- `includes/class-preview-proxy.php` +- `includes/class-preview-fixed-resources.php` +- `includes/class-preview-http-headers.php` +- `includes/class-activator.php` +- `includes/class-deactivator.php` +- `nginx-exelearning-preview.conf` +- `docs/preview-serving-contract.md` + +### Published-content isolation and bridges + +- `includes/class-content-proxy.php` +- `includes/class-iframe-sandbox.php` +- `public/class-shortcodes.php` +- `assets/js/exe-embed-relay.js` +- `assets/js/exe-embed-shim.js` +- `assets/js/exe-media-host.js` +- `assets/js/exe-media-policy.js` + +### Evidence and conformance + +- `tests/fixtures/preview-contract/vectors.json` +- `tests/unit/PreviewContractVectorsTest.php` +- `tests/unit/PreviewManagementControllerTest.php` +- `tests/unit/PreviewServingControllerTest.php` +- `tests/unit/PreviewSessionStoreTest.php` +- `tests/unit/PreviewFixedResourcesTest.php` +- `tests/unit/PreviewHttpHeadersTest.php` +- `tests/e2e/editor-preview-http.spec.js` +- `tests/e2e/embed.spec.cjs` +- `tests/js/exe_embed.test.js` +- `tests/js/exe_media_host.test.js` + +## Classification of reference changes + +| Category | WordPress reference implementation | OPFS experiment disposition | +| --- | --- | --- | +| 1. HTTP editor preview | `previewHttp` bootstrap and REST adapter | Candidate to bypass only for editor preview; keep as fallback | +| 2. Temporary preview storage | Private site-scoped file store | Candidate to replace with browser-local OPFS for preview only | +| 3. Capability serving URL | Cookieless `/preview/{previewId}/{path}` subtree | Not needed for a functional same-origin OPFS preview; still required by opaque HTTP fallback | +| 4. Authenticated session management | REST nonce, capability check, ownership | Preview-only routes may disappear; parent authentication and save/load remain | +| 5. Published-content CSP and headers | Content proxy and iframe response hardening | Must remain; OPFS preview does not replace publication policy | +| 6. Opaque published-content iframe | `IframeSandbox`, omitted `allow-same-origin`, sandbox CSP | Must remain for untrusted content unless equivalent evidence exists | +| 7. SCORM/xAPI bridge | Not the main focus of this WordPress PR | Preserve host-side credentials/tracking where present | +| 8. External-media bridge | Relay, shim, media host, provider policy | Reuse; do not remove without browser evidence | +| 9. Interactive-video bridge | Media relay lifecycle and geometry behavior | Reuse and test independently | +| 10. PDF and downloads | Media host, sandboxed players, Range serving | Reuse policy/tests; OPFS SW must independently prove Range/PDF behavior | +| 11. Fixed resources | Manifest-backed fixed-resource resolver | Reuse the layered model and canonical identifiers | +| 12. Incremental revisions | Immutable assets plus atomic document deltas | Reuse protocol semantics and conformance vectors | +| 13. Security tests | Nonce, ownership, CSP, traversal, iframe and media tests | Reuse as baseline; add OPFS/storage and management-channel attacks | +| 14. Integration tests | HTTP preview and embed Playwright flows | Keep HTTP baseline and add static/PWA-first OPFS A/B tests | +| 15. Shared reusable code | Vectors, MIME/CSP rules, path/range semantics, bridges | Prefer canonical core implementations and vendored conformance data | +| 16. Code potentially removable with OPFS | Preview REST routes, store, WP-Cron cleanup, private-store nginx rule, preview quotas, permalink dependency | Remove only in a separate, measured commit after gates pass; fallback requirement currently prevents removal | +| 17. Code still required with OPFS | Editor bootstrap, authentication, attachment access, save/load, publication proxy, iframe policy, bridges, tests | Preserve and keep security boundaries explicit | + +## Reusable components + +- The three-layer input model: generated documents, immutable project assets, and build-fixed resources. +- Exact path normalization, MIME classification, ETag, query-string independence, and single-range semantics. +- Atomic revision behavior: stage, verify, activate a small pointer, retain the previous revision. +- Canonical contract vectors and negative cases rather than framework-specific protocol invention. +- External media/PDF relay lifecycle, exact origin checks, and id-only authored messages. +- The split between credentialed management and credential-free serving. Even if management becomes a `MessageChannel`, this is a useful privilege-separation model. +- Tests proving owner separation, failed-write behavior, CSP byte identity, traversal rejection, and cleanup. + +## Preview-only candidates to disappear + +If and only if the browser-local experiment passes, the editor preview could avoid `class-preview-management-controller.php`, `class-preview-serving-controller.php`, `class-preview-session-store.php`, `class-preview-proxy.php`, preview activation/deactivation hooks, preview-specific WP-Cron cleanup, backend preview quotas, the private-store nginx rule, multipart upload handling, and the pretty-permalink requirement. It would also send zero generated documents and zero project asset bytes to a preview backend. + +These are not candidates for immediate deletion because the user-selected deployment has no dedicated viewer origin and explicitly retains HTTP as the plugin fallback. + +## Components that remain necessary + +- WordPress authentication, REST nonces, capability checks, and attachment authorization outside preview management. +- Existing save/load and ELPX upload/download flows. +- Published-content proxying, CSP, iframe sandboxing, access control, and URL handling. +- SCORM/xAPI or equivalent host-side tracking validation when applicable. +- External-media, interactive-video, fullscreen, resize, and PDF behavior until replacement tests pass. +- The trusted viewer shell and exact parent-origin validation for any OPFS experiment. +- Recovery UX when OPFS is unavailable, evicted, private-mode-limited, or quota-exhausted. + +## Risks of substitution + +1. A same-origin Service Worker response has a normal WordPress origin unless an effective response sandbox makes it opaque. `OPFS + SW` alone would expose the WordPress origin and is a security regression. +2. A CSP `sandbox` response may make the authored document opaque, but it may also prevent the document from acting as a controlled Service Worker client or loading viewer subresources. This must be tested in Chromium, Firefox, and WebKit; it cannot be inferred. +3. Authored code with the viewer origin may access that origin's OPFS, IndexedDB, CacheStorage, and Service Worker registrations. Random directory names are not isolation when enumeration is possible. +4. A WordPress-scoped worker must not control general administration, REST, media, or public pages. Scope and `Service-Worker-Allowed` behavior are hard gates. +5. Private browsing, storage partitioning, quota eviction, and third-party iframe policy may disable or reduce OPFS/SW persistence. +6. PDF native viewers and large media can behave differently for Service Worker responses; Range, reload, seek, cancellation, and files over 100 MiB require browser tests. +7. Removing the HTTP fallback would trade multi-replica backend complexity for browser protocol, persistence, lifecycle, and compatibility complexity without necessarily reducing total complexity. + +## Cross-repository dependencies + +- eXeLearning core is the canonical source for provider selection, OPFS protocol version, storage layout, viewer shell, Service Worker, CSP policy, and conformance vectors. +- WordPress must consume one reproducible static-editor artifact and record its core commit and SHA-256; it must not fork the protocol. +- Moodle, Omeka S, Nextcloud, and Procomun must use the same vectors so host deviations are explicit. +- The security paper must distinguish byte materialization from JavaScript execution origin and report same-origin OPFS as compatibility mode, not opaque isolation. +- Published-content integration remains host-specific because initial OPFS is empty and authorized package delivery still crosses the network. + +## Initial impact estimate + +The reference PR changes 75 files, with 16,767 insertions and 6,248 deletions including generated translations and lockfile churn. Excluding `languages/` and `package-lock.json`, it changes 52 files with approximately 9,136 insertions and 124 deletions. It adds six preview backend classes, at least seven preview-focused PHPUnit files, and a dedicated editor-preview E2E flow. + +| Area | Opaque HTTP reference | OPFS experiment target | Expected difference | +| --- | ---: | ---: | ---: | +| Preview backend routes | 5 operations | 0 for browser-local preview | -5 | +| Preview backend classes | 6 | 0 preview-session classes | -6 | +| Backend preview store | 1 file-backed store | 0 | -1 | +| Cleanup mechanisms | WP-Cron plus request expiry | Browser TTL/LRU/recovery | Backend -2; browser +1 subsystem | +| Browser transports | HTTP provider in core | OPFS/SW provider plus HTTP fallback | +1 while A/B is retained | +| Protocol operations | Create/assets/revision/delete/serve | MessageChannel session/batches/commit/status/clear | More browser states, fewer HTTP operations | +| Operational requirements | Private disk, TTL, quotas, cron, pretty permalinks | SW/OPFS support, scope, quota, persistence | Shift, not yet proven reduction | +| Security boundary | Opaque sandbox + capability response | Same-origin normal origin unless sandbox response works | Potential regression; hard gate | + +## Hypotheses to prove + +1. A static/PWA editor can generate documents, write them and project assets to OPFS, and navigate a multipage tree through normal Service Worker URLs without backend preview uploads. +2. The tree survives worker termination because all authoritative state is persisted, not held in a global `Map`. +3. Atomic pointer activation yields revision N or N+1, never mixed files. +4. Unchanged immutable assets are neither rewritten nor retransferred. +5. `Content-Security-Policy: sandbox allow-scripts` either preserves controlled navigation/subresource loading in every target engine or provides reproducible evidence that it does not. +6. Authored content cannot access the WordPress parent DOM, cookies, storage, request nonce, or management channel. +7. The worker's scope cannot cover unrelated WordPress paths and authored documents cannot replace or unregister the trusted worker. +8. PDF, audio, video, attachments, ESM, XML, SVG, fonts, and single-range reads behave correctly. +9. Failure modes (unsupported API, eviction, private mode, quota, interrupted batches) recover explicitly to the opaque HTTP transport rather than silently weakening security. +10. Total implementation and operational complexity is lower after counting the trusted shell, storage abstraction, protocol states, browser tests, and retained fallback. + +## Baseline measurements and required experiment outputs + +The figures above are source-diff measurements, not performance benchmark results. Core has now supplied functional and security evidence for the transport profiles; WordPress-specific runtime and performance cells remain `NOT EXECUTED`. A future compatible WordPress experiment would record JSON, CSV, and Markdown results for five cold and ten warm runs where practical: first render, text update, image replacement, page navigation, worker restart recovery, bytes uploaded to preview endpoints, OPFS bytes written, request count, memory estimate, cleanup time, and package mount cost for published content. + +Network interception must fail the editor-preview test if generated HTML/CSS/JavaScript or project images, PDF, audio, video, or attachments are uploaded to WordPress preview routes. Reading an asset that the editor does not yet possess is measured separately from re-uploading it to a preview server. + +## Empirical core results applied to WordPress + +The canonical core experiment produced the following results. `PASS` means the functional behavior under test worked; it does not imply that the profile passed the separate host-isolation requirement. + +| Profile / engine | Multipage OPFS/SW | Host isolation | Result for WordPress | +| --- | --- | --- | --- | +| Same-origin trusted / Chromium | `PASS` | `FAIL` | Functional only; unsafe for untrusted authored content on the WordPress origin | +| Same-origin trusted / Firefox | `PASS` | `FAIL` | Functional only; unsafe for untrusted authored content on the WordPress origin | +| Opaque CSP / Chromium and Firefox | `FAIL` | Opaque boundary requested | Multipage OPFS-backed navigation/subresources are incompatible with the required opaque profile | +| WebKit | `NOT SUPPORTED` | `NOT SUPPORTED` | Cannot satisfy the cross-engine acceptance criterion | +| Dedicated viewer origin | `NOT EXECUTED` | `NOT EXECUTED` | Not available in the target deployment and therefore not a deployable mitigation | + +The experiment confirms the distinction in this document's invariant: same-origin OPFS successfully stores and serves bytes, but it does not isolate authored JavaScript from WordPress. Applying the opaque CSP needed to restore that boundary breaks the multipage OPFS design. The only potentially viable normal-origin security profile, a dedicated sacrificial origin, is unavailable and untested here. + +## Host decision and retained code + +| Question | Decision | Code or boundary retained | +| --- | --- | --- | +| Add `opfsViewer` configuration to the WordPress bootstrap | No | Keep `previewHttp` configuration and its fail-closed checks | +| Add OPFS management or serving endpoints | No | Do not add insecure same-origin surfaces; retain the existing HTTP management and capability routes from the reference design | +| Replace private preview storage | No | Retain the private site-scoped preview session store, quotas, atomic revisions, expiry, and WP-Cron cleanup | +| Remove capability URLs or pretty-permalink validation | No | Retain cookieless capability serving and the permalink requirement while HTTP preview is used | +| Relax the iframe sandbox with `allow-same-origin` | No | Retain the opaque iframe and sandbox-first CSP for authored content | +| Remove publication proxy or headers | No | Retain `class-content-proxy.php`, publication access control, MIME hardening, and CSP | +| Remove media, PDF, or interactive bridges | No | Retain relay, shim, media host, media policy, Range behavior, fullscreen, geometry, and lifecycle tests | +| Use OPFS in the static/PWA editor | Yes, conditionally | Core may use it only as `same-origin-trusted`, outside the plugin's untrusted host boundary | +| Use OPFS for published WordPress content | No | Retain the existing authenticated/publication delivery path and opaque rendering baseline | + +The candidate-removal list above is therefore counterfactual for WordPress. No preview endpoint, controller, private-store component, cleanup mechanism, security header, sandbox rule, or bridge should be removed on the evidence currently available. + +## Baseline decision + +The plugin decision is **NO-GO** for replacing the opaque HTTP preview with OPFS. This repository will not implement OPFS endpoints or inject same-origin `opfsViewer` configuration because that would knowingly select the profile whose host-isolation test failed. + +- static/PWA preview: conditionally viable in trusted-content mode on Chromium and Firefox; +- WordPress untrusted preview: retain the opaque HTTP capability transport; +- same-origin OPFS inside WordPress: rejected for untrusted content because host isolation failed; +- opaque CSP OPFS: rejected because multipage serving failed; +- dedicated-origin OPFS: unavailable and `NOT EXECUTED`; +- published content: retain the current path; no OPFS substitution. diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md new file mode 100644 index 0000000..0d6e595 --- /dev/null +++ b/docs/preview-serving-contract.md @@ -0,0 +1,93 @@ +# Opaque editor preview — WordPress adapter + +The embedded editor renders its preview **filtered** by default: sanitised, with +no author JavaScript running. When the author opts in to running their own code, +the editor needs somewhere to put the real project bytes that is **not** the +WordPress page — a browser-enforced **opaque origin** the content cannot reach +out of. + +This plugin is that somewhere. The editor POSTs the whole project as one ZIP and +gets back an unguessable capability id; the plugin serves that tree from an +authless REST route under a sandbox CSP. There is no `srcdoc` transport and no +Service Worker fallback for authored content: missing or invalid configuration +**fails closed** and the filtered preview stays. + +## The two routes + +| | Request | Result | +|---|---|---| +| Management | `POST {REST}/exelearning/v1/preview-session/{attachmentId}` | multipart `snapshot=`, optional `previewId` → `{previewId}` | +| Management | `DELETE {REST}/exelearning/v1/preview-session/{attachmentId}/{previewId}` | drops the snapshot | +| Serving | `GET {REST}/exelearning/v1/preview/{previewId}/{file}` | the snapshot, authless | + +Management runs on the WordPress cookie/REST path: `X-WP-Nonce`, the +`upload_files` capability, and ownership of the snapshot (user + attachment). +Serving is authless and cookieless — the unguessable id plus the idle TTL is the +whole credential, which is what makes the origin opaque. + +**Pretty permalinks are required in practice.** The editor builds the preview URL +by resolving `{previewId}/index.html` against `servingBaseUrl`, and under plain +`?rest_route=` permalinks that resolution drops the query string and lands on the +site root. `ExeLearning_Editor::maybe_warn_preview_permalinks()` raises an +administration notice; the configuration itself is still emitted. + +## Why one whole snapshot + +An earlier revision implemented a layered protocol (contract v2): immutable asset +keys uploaded once, incremental document revisions and a fixed-resource manifest, +all to avoid re-uploading unchanged bytes. The editor stopped speaking it — it +sends one whole snapshot per refresh — so the layered store, its controllers and +the `previewHttp` bootstrap block were removed. This document describes what the +plugin does now. + +## Storage + +Snapshots live outside the web root, under the system temp directory, so no +direct web-server path can bypass the serving route and its sandbox CSP. + +Content sits in its own subtree; a write is staged and swapped in, so a reader +sees the previous snapshot or the new one, never a half-written one. + +## What an archive must survive before extraction + +`ExeLearning_Preview_Zip_Inspector` vets every entry *before* a byte is written, +because extraction is all or nothing and a limit noticed halfway would leave a +partial tree: + +- entry count and total **declared uncompressed** size — a zip bomb inflates past + the second, not the first; +- path traversal, absolute paths and backslashes; +- reserved names the store owns; +- Unix symlinks, stored as a tiny entry whose contents are a path. + +Limits default to 1 GB / 10 000 entries, overridable with the +`EXELEARNING_PREVIEW_MAX_BYTES` / `EXELEARNING_PREVIEW_MAX_FILES` constants or the +matching filters. A non-positive value falls back to the default: the guard +cannot be switched off. Over the limit the grant **fails closed** — the filtered +preview stays and the author sees an error, rather than the isolation boundary +being downgraded. + +## Response security policy + +Every response carries `X-Content-Type-Options: nosniff`, `Referrer-Policy: +no-referrer` and a restrictive `Permissions-Policy`. Every **scriptable** type — +`text/html`, `image/svg+xml`, XML, XHTML — additionally carries the sandbox-first +CSP, so a capability URL stays opaque even when opened directly. Not just HTML: an +author-supplied SVG runs its inline `'; + + if ( false !== stripos( $html, '' ) ) { + return preg_replace( '/<\/body>/i', $script . '', $html, 1 ); + } + return $html . $script; + } + + /** + * Whether the current request is a genuine top-level navigation. + * + * Embedded requests report Sec-Fetch-Dest: iframe; a real address-bar / new-tab + * navigation reports 'document'. When the header is absent (older browsers) we + * treat it as embedded and serve the content, since the response is sandboxed. + * + * @return bool True for a top-level document navigation. + */ + private function is_toplevel_navigation() { + $dest = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) + ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ) + : ''; + return 'document' === $dest; + } + + /** + * Build the "open it embedded" notice served on a top-level navigation. + * + * The package content is untrusted and only meant to run inside the embedding + * iframe (where the host page's relay renders external players and the parent + * origin isolates it). Opened top-level there is no host, so instead of running + * the content we return this short, script-free notice. + * + * @return string Notice HTML document. + */ + private function build_toplevel_notice() { + $title = esc_html__( 'This content lives inside a page', 'exelearning' ); + $message = esc_html__( 'This eXeLearning activity is meant to be viewed embedded in a WordPress page (through a shortcode or block), not on its own. Open the page that includes it to enjoy the interactive content.', 'exelearning' ); + $lang = esc_attr( get_bloginfo( 'language' ) ); + // eXeLearning icon (assets/images/exelearning-icon.svg), inlined so it renders + // under the opaque-origin sandbox without a cross-origin image request. + $icon = ''; + return '' + . '' . $title . '' + . '' + . '
' + . $icon + . '

' . $title . '

' + . '

' . $message . '

'; + } + + /** + * Serve the top-level "open it embedded" notice with the standard headers. + * + * @param string $mime_type Response MIME type (text/html). + */ + private function serve_toplevel_notice( $mime_type ) { + $html = $this->build_toplevel_notice(); + $this->send_headers( $mime_type, strlen( $html ) ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static markup, values escaped in build_toplevel_notice(). + echo $html; + } + + /** + * Read and cache the embed shim JavaScript source. + * + * @return string Shim source, or '' if the asset is unreadable. + */ + private static function embed_shim_source() { + static $cache = null; + if ( null !== $cache ) { + return $cache; + } + $path = EXELEARNING_PLUGIN_DIR . 'assets/js/exe-embed-shim.js'; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading bundled plugin asset. + $source = is_readable( $path ) ? file_get_contents( $path ) : false; + $cache = ( false === $source ) ? '' : $source; + return $cache; + } + /** * Serve CSS content with url() references rewritten to absolute proxy URLs. * This is needed when pretty permalinks are disabled. @@ -599,33 +718,14 @@ private function send_headers( $mime_type, $file_size ) { header( 'X-Frame-Options: SAMEORIGIN' ); } header( 'X-Content-Type-Options: nosniff' ); - header( 'Referrer-Policy: same-origin' ); + header( 'Referrer-Policy: no-referrer' ); header( 'Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()' ); - // CSP. HTML is the eXeLearning package's own (interactive) document, so it - // keeps a functional policy. SVG/XML are served as images/data and must - // NEVER execute script: a malicious uploaded .elpx could otherwise carry - // an
', esc_attr( $data['container_id'] ), @@ -300,6 +317,10 @@ private function render_block_preview( $data, $download_html ) { $html .= '
' . $download_html . $fullscreen_html . '
'; } + // The package is downloaded, parsed and laid out inside the iframe before + // anything paints, which reads as a blank frame for a noticeable moment. + // Wrap it so the enqueued loader script can cover that gap with a spinner. + $html .= '
'; $html .= sprintf( '', $this->build_preview_url( $data ), $data['height'], - esc_attr( get_the_title( $data['attachment_id'] ) ) + esc_attr( get_the_title( $data['attachment_id'] ) ), + esc_attr( ExeLearning_Iframe_Sandbox::sandbox_tokens() ) + ); + $html .= sprintf( + '
%s
', + esc_html__( 'Loading content…', 'exelearning' ) ); + $html .= '
'; $html .= '
'; diff --git a/includes/class-exelearning-editor.php b/includes/class-exelearning-editor.php index e05686c..6dc76e7 100644 --- a/includes/class-exelearning-editor.php +++ b/includes/class-exelearning-editor.php @@ -26,6 +26,7 @@ public function __construct() { add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_editor_scripts' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_scripts_for_blocks' ) ); add_action( 'admin_footer', array( $this, 'render_editor_modal_container' ) ); + add_action( 'admin_notices', array( $this, 'maybe_warn_preview_permalinks' ) ); add_filter( 'wp_prepare_attachment_for_js', array( $this, 'add_edit_capability' ), 10, 3 ); // Start output buffering early if we're on the editor page. @@ -312,4 +313,83 @@ public function get_editor_url( $attachment_id ) { public function get_project_id( $attachment_id ) { return 'wp-attachment-' . $attachment_id; } + + /** + * Whether WordPress runs with pretty permalinks. + * + * The HTTP preview serving base is appended with `/{previewId}/{path}`; under + * plain permalinks `rest_url()` yields the `?rest_route=` form, which cannot + * carry that suffix, so the transport requires pretty permalinks. + * + * @return bool + */ + public static function pretty_permalinks_enabled() { + return '' !== (string) get_option( 'permalink_structure' ); + } + + /** + * Purge caches left by an earlier editor build. + * + * A stale Service Worker from a previous build can serve outdated editor + * assets and drive the preview into a refresh loop. This purges the caches + * on load; it does NOT block registration, because the current editor build + * needs its own preview worker (see the inline comment for why that is safe + * and why the opaque snapshot never travels over it). + * + * Returned WITHOUT ` + + + + + + diff --git a/tests/e2e/embed/local.pdf b/tests/e2e/embed/local.pdf new file mode 100644 index 0000000..314e46d Binary files /dev/null and b/tests/e2e/embed/local.pdf differ diff --git a/tests/e2e/embed/parent.html b/tests/e2e/embed/parent.html new file mode 100644 index 0000000..0a7ac5d --- /dev/null +++ b/tests/e2e/embed/parent.html @@ -0,0 +1,8 @@ + + + + + +
+ + diff --git a/tests/e2e/embed/testing-the-sandbox.elpx b/tests/e2e/embed/testing-the-sandbox.elpx new file mode 100644 index 0000000..bfb5453 Binary files /dev/null and b/tests/e2e/embed/testing-the-sandbox.elpx differ diff --git a/tests/e2e/helpers/seed-elpx-attachment.php b/tests/e2e/helpers/seed-elpx-attachment.php new file mode 100644 index 0000000..eb36031 --- /dev/null +++ b/tests/e2e/helpers/seed-elpx-attachment.php @@ -0,0 +1,35 @@ +`), so a Playwright test can look up + * the editor URL from the browser. The `exelearning_editor` nonce is NOT minted + * here on purpose — WordPress binds nonces to the browser session token, so a + * wp-cli nonce would never validate in the browser. The test reads a + * session-valid edit URL via `wp.media` instead. + * + * Run inside wp-env against the TESTS CLI container, as the admin user: + * + * npx wp-env run tests-cli --env-cwd=wp-content/plugins/exelearning \ + * wp eval-file tests/e2e/helpers/seed-elpx-attachment.php --user=admin + * + * @package Exelearning + */ + +$exe_e2e_upload = wp_upload_dir(); +$exe_e2e_path = trailingslashit( $exe_e2e_upload['basedir'] ) . 'e2e-preview-' . time() . '.elpx'; + +// The editor page only checks the .elpx extension; the bytes are never parsed. +file_put_contents( $exe_e2e_path, "PK\x03\x04" ); // phpcs:ignore + +$exe_e2e_attachment_id = wp_insert_attachment( + array( + 'post_mime_type' => 'application/zip', + 'post_title' => 'e2e-preview', + 'post_status' => 'inherit', + ), + $exe_e2e_path +); + +update_attached_file( $exe_e2e_attachment_id, $exe_e2e_path ); + +echo 'attachment_id=' . (int) $exe_e2e_attachment_id; diff --git a/tests/js/exe_embed.test.js b/tests/js/exe_embed.test.js new file mode 100644 index 0000000..02b9857 --- /dev/null +++ b/tests/js/exe_embed.test.js @@ -0,0 +1,292 @@ +// Unit tests for the secure-mode external-embed relay (DEC-0061). The relay (parent) is +// the authoritative gate: in 'open' mode the structural invariant (https + cross-origin +// to the host), in 'strict' mode the maintained host allowlist. This file MIRRORS the +// RELAY describe-blocks of the canonical mod_exelearning suite (tests/js/exe_embed.test.js) +// so drift in the validate()/makePlayer()/sync() logic is caught here too. The relay is a +// require()-able dual-export module; globals come from vitest.config.mts. +const relay = require( '../../assets/js/exe-embed-relay.js' ); + +const HOSTS = [ + 'www.youtube.com', 'youtube.com', 'www.youtube-nocookie.com', + 'youtube-nocookie.com', 'player.vimeo.com', 'vimeo.com', + 'www.dailymotion.com', 'dailymotion.com', 'geo.dailymotion.com', + 'mediateca.educa.madrid.org', +]; +const STRICT = { strict: true, whitelist: relay.buildWhitelist( HOSTS ) }; +const ORIGIN = window.location.origin; // happy-dom default (the "host" origin here). +const CONTENT_SRC = ORIGIN + '/wp-json/exelearning/v1/content/' + 'a'.repeat( 40 ) + '/index.html'; + +describe( 'exe_embed_relay validate() — open mode (default): structural invariant', () => { + it( 'accepts any cross-origin https video iframe verbatim (no host list, no reconstruction)', () => { + expect( relay.validate( 'https://www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC ) ) + .toEqual( { url: 'https://www.youtube.com/embed/aqz-KE-bpKQ', kind: 'video' } ); + expect( relay.validate( 'https://some-new-provider.example/player/42', CONTENT_SRC ) ) + .toEqual( { url: 'https://some-new-provider.example/player/42', kind: 'video' } ); + } ); + + it( 'rejects same-origin (the host page itself)', () => { + expect( relay.validate( ORIGIN + '/wp-admin/index.php', CONTENT_SRC ) ).toBeNull(); + } ); + + it( 'rejects non-https', () => { + expect( relay.validate( 'http://www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC ) ).toBeNull(); + } ); + + it( 'rejects userinfo (https://evil.com@youtube.com/...)', () => { + expect( relay.validate( 'https://evil.com@www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC ) ).toBeNull(); + } ); + + it( 'rejects IP-literal and loopback/local hosts', () => { + expect( relay.validate( 'https://1.2.3.4/player', CONTENT_SRC ) ).toBeNull(); + expect( relay.validate( 'https://[2001:db8::1]/player', CONTENT_SRC ) ).toBeNull(); + expect( relay.validate( 'https://localhost/player', CONTENT_SRC ) ).toBeNull(); + expect( relay.validate( 'https://intranet.local/player', CONTENT_SRC ) ).toBeNull(); + } ); + + it( 'rejects non-http(s) schemes (data:/javascript:/blob:)', () => { + expect( relay.validate( 'data:text/html,

x

', CONTENT_SRC ) ).toBeNull(); + expect( relay.validate( 'javascript:alert(1)', CONTENT_SRC ) ).toBeNull(); + expect( relay.validate( 'blob:https://x.test/uuid', CONTENT_SRC ) ).toBeNull(); + } ); + + it( 'rejects a relative URL (the shim must report absolute)', () => { + expect( relay.validate( 'files/local.pdf', CONTENT_SRC ) ).toBeNull(); + expect( relay.validate( '/admin/secret', CONTENT_SRC ) ).toBeNull(); + } ); +} ); + +describe( 'exe_embed_relay validate() — PDFs (always allowed by structure)', () => { + it( 'accepts any cross-origin https PDF (no sameorigin flag)', () => { + expect( relay.validate( 'https://example.com/docs/report.pdf', CONTENT_SRC ) ) + .toEqual( { url: 'https://example.com/docs/report.pdf', kind: 'pdf' } ); + } ); + + it( 'accepts a same-origin PDF under the content directory (flagged sameorigin)', () => { + const pdf = ORIGIN + '/wp-json/exelearning/v1/content/' + 'a'.repeat( 40 ) + '/files/local.pdf'; + expect( relay.validate( pdf, CONTENT_SRC ) ).toEqual( { url: pdf, kind: 'pdf', sameorigin: true } ); + } ); + + it( 'rejects a same-origin PDF outside the package (e.g. an admin route)', () => { + expect( relay.validate( ORIGIN + '/wp-admin/secret.pdf', CONTENT_SRC ) ).toBeNull(); + } ); + + it( 'rejects an http PDF', () => { + expect( relay.validate( 'http://example.com/x.pdf', CONTENT_SRC ) ).toBeNull(); + } ); +} ); + +describe( 'exe_embed_relay validate() — strict mode (opt-in allowlist)', () => { + it( 'rebuilds the canonical youtube-nocookie URL from a youtube.com embed', () => { + expect( relay.validate( 'https://www.youtube.com/embed/aqz-KE-bpKQ', CONTENT_SRC, STRICT ) ) + .toEqual( { url: 'https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ', kind: 'video' } ); + } ); + + it( 'rebuilds the canonical Vimeo / Dailymotion / EducaMadrid URLs', () => { + expect( relay.validate( 'https://player.vimeo.com/video/76979871', CONTENT_SRC, STRICT ).url ) + .toBe( 'https://player.vimeo.com/video/76979871' ); + expect( relay.validate( 'https://www.dailymotion.com/embed/video/x8abc12', CONTENT_SRC, STRICT ).url ) + .toBe( 'https://www.dailymotion.com/embed/video/x8abc12' ); + expect( relay.validate( 'https://mediateca.educa.madrid.org/video/u555bvi3bk5wsabh', CONTENT_SRC, STRICT ).url ) + .toBe( 'https://mediateca.educa.madrid.org/video/u555bvi3bk5wsabh/fs' ); + } ); + + it( 'rejects a non-whitelisted cross-origin https host (unlike open mode)', () => { + expect( relay.validate( 'https://some-new-provider.example/player/42', CONTENT_SRC, STRICT ) ).toBeNull(); + expect( relay.validate( 'https://example.com/', CONTENT_SRC, STRICT ) ).toBeNull(); + } ); + + it( 'rejects look-alike hosts and malformed ids', () => { + expect( relay.validate( 'https://www.youtube.com.evil.com/embed/aqz-KE-bpKQ', CONTENT_SRC, STRICT ) ).toBeNull(); + expect( relay.validate( 'https://www.youtube.com/embed/', CONTENT_SRC, STRICT ) ).toBeNull(); + expect( relay.validate( 'https://player.vimeo.com/video/not-a-number', CONTENT_SRC, STRICT ) ).toBeNull(); + } ); + + it( 'still accepts cross-origin PDFs in strict mode', () => { + expect( relay.validate( 'https://example.com/x.pdf', CONTENT_SRC, STRICT ) ) + .toEqual( { url: 'https://example.com/x.pdf', kind: 'pdf' } ); + } ); +} ); + +describe( 'exe_embed_relay structural helpers', () => { + it( 'isIpOrLocalHost flags IP literals and loopback/local names', () => { + [ '1.2.3.4', '255.0.0.1', '[::1]', '[2001:db8::1]', 'localhost', 'x.localhost', 'host.local', '' ].forEach( + ( h ) => expect( relay.isIpOrLocalHost( h ) ).toBe( true ) + ); + [ 'youtube.com', 'player.vimeo.com', 'example.org' ].forEach( + ( h ) => expect( relay.isIpOrLocalHost( h ) ).toBe( false ) + ); + } ); + + it( 'isRelatedToLms flags the host, its subdomains and superdomains (dotted boundary)', () => { + expect( relay.isRelatedToLms( 'host.example.org', 'host.example.org' ) ).toBe( true ); // equal + expect( relay.isRelatedToLms( 'cdn.host.example.org', 'host.example.org' ) ).toBe( true ); // subdomain + expect( relay.isRelatedToLms( 'example.org', 'host.example.org' ) ).toBe( true ); // superdomain + expect( relay.isRelatedToLms( 'evil-host.example.org', 'host.example.org' ) ).toBe( false ); // look-alike + expect( relay.isRelatedToLms( 'youtube.com', 'host.example.org' ) ).toBe( false ); + } ); + + it( 'isRelatedToLms normalises the trailing-dot FQDN-root form (no host. bypass)', () => { + // 'host.example.org.' resolves to the same vhost but compares unequal as a raw + // string; without normalisation it would slip past the related-to-host gate and + // be promoted as a cross-origin player with allow-same-origin. + expect( relay.isRelatedToLms( 'host.example.org.', 'host.example.org' ) ).toBe( true ); // dotted host + expect( relay.isRelatedToLms( 'host.example.org', 'host.example.org.' ) ).toBe( true ); // dotted lmsHost + expect( relay.isRelatedToLms( 'cdn.host.example.org.', 'host.example.org' ) ).toBe( true ); // dotted subdomain + expect( relay.normalizeHost( 'Host.Example.ORG.' ) ).toBe( 'host.example.org' ); + } ); +} ); + +describe( 'exe_embed_relay makePlayer() — sandboxed players', () => { + it( 'video player is sandboxed with allow-same-origin but NOT top-navigation/modals', () => { + const frame = relay.makePlayer( { url: 'https://www.youtube.com/embed/abc123', kind: 'video' } ); + const sb = frame.getAttribute( 'sandbox' ); + expect( sb ).toContain( 'allow-scripts' ); + expect( sb ).toContain( 'allow-same-origin' ); // cross-origin src keeps its own origin; renders. + expect( sb ).not.toContain( 'allow-top-navigation' ); + expect( sb ).not.toContain( 'allow-modals' ); + expect( frame.getAttribute( 'data-exe-embed-player' ) ).toBe( '1' ); // excluded from message auth + expect( frame.getAttribute( 'allow' ) ).toContain( 'autoplay' ); + expect( frame.getAttribute( 'referrerpolicy' ) ).toBe( 'strict-origin-when-cross-origin' ); + } ); + + it( 'cross-origin PDF player is sandboxed allow-same-origin (no scripts/top-nav)', () => { + const frame = relay.makePlayer( { url: 'https://files.test/manual.pdf', kind: 'pdf' } ); + const sb = frame.getAttribute( 'sandbox' ); + expect( sb ).toBe( 'allow-same-origin' ); // cannot top-navigate the host tab to phishing + expect( sb ).not.toContain( 'allow-scripts' ); + expect( sb ).not.toContain( 'allow-top-navigation' ); + expect( frame.getAttribute( 'referrerpolicy' ) ).toBe( 'no-referrer' ); + } ); + + it( 'same-origin package PDF player is unsandboxed (the browser PDF viewer needs it)', () => { + const frame = relay.makePlayer( { url: 'https://files.test/manual.pdf', kind: 'pdf', sameorigin: true } ); + expect( frame.hasAttribute( 'sandbox' ) ).toBe( false ); + expect( frame.getAttribute( 'referrerpolicy' ) ).toBe( 'no-referrer' ); + } ); +} ); + +describe( 'exe_embed_relay createRelay() overlays players from messages', () => { + let iframe; + beforeEach( () => { + document.body.innerHTML = ''; + iframe = document.createElement( 'iframe' ); + document.body.appendChild( iframe ); + } ); + + it( 'creates an inline overlay player for a valid embed and removes it when no longer reported', () => { + const r = relay.createRelay( { mode: 'open' } ); + r.onMessage( { + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [ { id: 'e1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 } ], + }, + } ); + const players = document.querySelectorAll( '.exe-embed-overlay iframe' ); + expect( players.length ).toBe( 1 ); + expect( players[ 0 ].src ).toMatch( /www\.youtube\.com\/embed\/abc123$/ ); // verbatim in open mode + + r.onMessage( { source: iframe.contentWindow, data: { type: 'exe-embed', action: 'sync', embeds: [] } } ); + expect( document.querySelectorAll( '.exe-embed-overlay iframe' ).length ).toBe( 0 ); + } ); + + it( 'replaces the player when a reused embed id navigates to a different URL (no lingering video)', () => { + const r = relay.createRelay( { mode: 'open' } ); + r.onMessage( { + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [ { id: 'exe-embed-1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 } ], + }, + } ); + expect( document.querySelector( '.exe-embed-overlay iframe' ).src ).toMatch( /www\.youtube\.com\/embed\/abc123$/ ); + + r.onMessage( { + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [ { id: 'exe-embed-1', url: 'https://player.vimeo.com/video/12345', x: 0, y: 0, w: 425, h: 350 } ], + }, + } ); + const players = document.querySelectorAll( '.exe-embed-overlay iframe' ); + expect( players.length ).toBe( 1 ); + expect( players[ 0 ].src ).toMatch( /player\.vimeo\.com\/video\/12345$/ ); + expect( players[ 0 ].src ).not.toMatch( /youtube/ ); + } ); + + it( 'never treats a promoted player as a content source (forged-message defence)', () => { + const r = relay.createRelay( { mode: 'open' } ); + // A sandboxed player with allow-same-origin must not be able to impersonate the + // content iframe and inject embeds: tag an iframe like a player and verify a + // message from it is ignored. + const player = document.createElement( 'iframe' ); + player.setAttribute( 'data-exe-embed-player', '1' ); + document.body.appendChild( player ); + r.onMessage( { + source: player.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [ { id: 'x', url: 'https://evil.example/phish', x: 0, y: 0, w: 100, h: 100 } ], + }, + } ); + expect( document.querySelectorAll( '.exe-embed-overlay iframe' ).length ).toBe( 0 ); + } ); + + it( 'ignores a message whose source is not a known content iframe', () => { + const r = relay.createRelay( { mode: 'open' } ); + r.onMessage( { + source: {}, + data: { + type: 'exe-embed', action: 'sync', + embeds: [ { id: 'x', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 1, h: 1 } ], + }, + } ); + expect( document.querySelectorAll( '.exe-embed-overlay iframe' ).length ).toBe( 0 ); + } ); + + it( 'ignores non-embed messages', () => { + const r = relay.createRelay( { mode: 'open' } ); + r.onMessage( { source: iframe.contentWindow, data: { type: 'scorm', action: 'track', cmi: {} } } ); + expect( document.querySelectorAll( '.exe-embed-overlay iframe' ).length ).toBe( 0 ); + } ); + + it( 'checkDrift() re-pins an overlay whose content iframe moved without any event', () => { + const r = relay.createRelay( { mode: 'open' } ); + r.onMessage( { + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [ { id: 'e1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 } ], + }, + } ); + const overlay = document.querySelector( '.exe-embed-overlay' ); + // Nothing moved yet: the drift check must be a no-op. + expect( r.checkDrift() ).toBe( 0 ); + // The host toggles a sidebar: the iframe box shifts with no scroll/resize. + iframe.getBoundingClientRect = () => ( { left: 120, top: 30, width: 500, height: 320, right: 620, bottom: 350 } ); + expect( r.checkDrift() ).toBe( 1 ); + expect( overlay.style.left ).toBe( '120px' ); + expect( overlay.style.top ).toBe( '30px' ); + expect( overlay.style.width ).toBe( '500px' ); + // Settled: a second pass changes nothing. + expect( r.checkDrift() ).toBe( 0 ); + } ); + + it( 'dispose() tears down overlays like clear() and is safe before init / twice', () => { + const r = relay.createRelay( { mode: 'open' } ); + r.onMessage( { + source: iframe.contentWindow, + data: { + type: 'exe-embed', action: 'sync', + embeds: [ { id: 'e1', url: 'https://www.youtube.com/embed/abc123', x: 0, y: 0, w: 480, h: 270 } ], + }, + } ); + expect( document.querySelectorAll( '.exe-embed-overlay iframe' ).length ).toBe( 1 ); + + // dispose() before init() removes the overlay layer and never throws. + r.dispose(); + expect( document.querySelectorAll( '.exe-embed-overlay' ).length ).toBe( 0 ); + // Idempotent: a second dispose() must not throw. + expect( () => r.dispose() ).not.toThrow(); + } ); +} ); diff --git a/tests/js/exe_media_host.test.js b/tests/js/exe_media_host.test.js new file mode 100644 index 0000000..dd562c0 --- /dev/null +++ b/tests/js/exe_media_host.test.js @@ -0,0 +1,65 @@ +// Unit test for the parent-side modal media host (exe-media-host.js). Mirrors the +// canonical core test: it loads the policy first (sets window.exeMediaPolicy) then the +// host (sets window.exeMediaHost), completes the window-identity handshake, and drives +// commands over the transferred MessageChannel port. Globals come from vitest.config.mts. +require( '../../assets/js/exe-media-policy.js' ); +require( '../../assets/js/exe-media-host.js' ); +const host = window.exeMediaHost; + +const TYPE = 'exe-media'; +const V = 1; + +function makeWin() { + const handlers = []; + return { + addEventListener( t, cb ) { if ( t === 'message' ) handlers.push( cb ); }, + removeEventListener( t, cb ) { const i = handlers.indexOf( cb ); if ( i >= 0 ) handlers.splice( i, 1 ); }, + _emit( evt ) { handlers.slice().forEach( ( h ) => h( evt ) ); }, + }; +} + +function makeIframe() { + return { contentWindow: { postMessage() {} } }; +} + +// Two linked fake MessagePorts: posting on one delivers to the other's onmessage. +function makeFakeChannel() { + const port1 = { onmessage: null, start() {}, close() {} }; + const port2 = { onmessage: null, start() {}, close() {} }; + port1.postMessage = ( m ) => { if ( port2.onmessage ) port2.onmessage( { data: m } ); }; + port2.postMessage = ( m ) => { if ( port1.onmessage ) port1.onmessage( { data: m } ); }; + return { port1, port2 }; +} + +describe( 'exe-media-host openMedia() — single active media (audit L-2)', () => { + afterEach( () => { + document.querySelectorAll( 'dialog.exe-media-modal' ).forEach( ( d ) => d.remove() ); + if ( host._resetForTests ) host._resetForTests(); + } ); + + it( 'on a second open, tears down the previous adapter and modal (no stacking/leak)', () => { + const win = makeWin(); + const iframe = makeIframe(); + const ch = makeFakeChannel(); + const adapters = []; + const factory = () => { + const a = { + destroyed: false, + play() {}, pause() {}, seek() {}, + getCurrentTime() { return 0; }, getDuration() { return 0; }, + destroy() { this.destroyed = true; }, + }; + adapters.push( a ); + return a; + }; + host.attach( iframe, { win, genId: () => 'N1', channelFactory: () => ch, youtubeFactory: factory, document } ); + win._emit( { source: iframe.contentWindow, data: { type: TYPE, v: V, action: 'hello', helloId: 'H1' } } ); + const send = ( cmd ) => ch.port2.postMessage( Object.assign( { type: TYPE, v: V, exelearningBridge: 'N1' }, cmd ) ); + send( { action: 'open', reqId: 1, provider: 'youtube', videoId: 'dQw4w9WgXcQ' } ); + send( { action: 'open', reqId: 2, provider: 'youtube', videoId: 'oHg5SJYRHA0' } ); + expect( adapters.length ).toBe( 2 ); + expect( adapters[ 0 ].destroyed ).toBe( true ); // previous torn down + expect( adapters[ 1 ].destroyed ).toBe( false ); // current is live + expect( document.querySelectorAll( 'dialog.exe-media-modal' ).length ).toBe( 1 ); + } ); +} ); diff --git a/tests/unit/ContentProxyTest.php b/tests/unit/ContentProxyTest.php index d714de4..8a00f63 100644 --- a/tests/unit/ContentProxyTest.php +++ b/tests/unit/ContentProxyTest.php @@ -1041,6 +1041,93 @@ public function test_content_origin_rejects_non_bare_origin() { remove_filter( 'exelearning_content_origin', $cb ); } + /** + * Secure mode appends a `sandbox` directive so the document stays opaque even when + * opened outside the iframe (new tab / raw URL navigation). + */ + public function test_build_html_csp_secure_adds_sandbox() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'build_html_csp' ); + $method->setAccessible( true ); + + $csp = $method->invoke( $this->proxy, "'self'", true ); + + // The CSP sandbox must mirror the secure iframe tokens, incl. allow-forms, or + // form-based iDevices are blocked by the CSP even though the iframe allows them. + $this->assertStringContainsString( 'sandbox allow-scripts allow-popups allow-forms', $csp ); + $this->assertStringContainsString( "default-src 'self'", $csp ); + // Strict (default): no bare https: channels, so the served document cannot exfiltrate + // the content URL; frame-src is limited to the maintained providers. + $this->assertDoesNotMatchRegularExpression( '~\bhttps:(?!//)~', $csp ); + $this->assertStringContainsString( 'https://www.youtube-nocookie.com', $csp ); + } + + /** + * The compatible CSP profile re-opens img/media to https: (documented weaker), while the + * sandbox directive is unchanged. + */ + public function test_build_html_csp_compatible_profile_allows_https() { + $callback = function () { + return ExeLearning_Iframe_Sandbox::CSP_COMPATIBLE; + }; + add_filter( 'exelearning_csp_profile', $callback ); + + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'build_html_csp' ); + $method->setAccessible( true ); + $csp = $method->invoke( $this->proxy, "'self'", true ); + + remove_filter( 'exelearning_csp_profile', $callback ); + + $this->assertMatchesRegularExpression( '~img-src[^;]*\bhttps:(?!//)~', $csp ); + $this->assertStringContainsString( 'sandbox allow-scripts allow-popups allow-forms', $csp ); + } + + /** + * Without the sandbox flag (the dev-only legacy escape hatch) the CSP omits the sandbox + * directive. + */ + public function test_build_html_csp_without_sandbox_flag() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'build_html_csp' ); + $method->setAccessible( true ); + + $csp = $method->invoke( $this->proxy, "'self'", false ); + + $this->assertStringNotContainsString( 'sandbox', $csp ); + $this->assertStringContainsString( "default-src 'self'", $csp ); + } + + /** + * In secure mode the served HTML gets the external-embed shim inlined. + */ + public function test_inject_embed_shim_adds_shim_in_secure_mode() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'inject_embed_shim' ); + $method->setAccessible( true ); + + $html = '

content

'; + $out = $method->invoke( $this->proxy, $html ); + + $this->assertStringContainsString( 'id="exelearning-embed-shim"', $out ); + // The shim source itself is inlined. + $this->assertStringContainsString( 'data-exe-embed-id', $out ); + // Injected before the closing body tag. + $this->assertStringContainsString( '', $out ); + } + + /** + * The same-origin legacy admin mode was removed: a leftover option=legacy is ignored and + * the shim is still injected (the content always renders secure). + */ + public function test_inject_embed_shim_ignores_legacy_option() { + update_option( ExeLearning_Iframe_Sandbox::OPTION, 'legacy' ); + + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'inject_embed_shim' ); + $method->setAccessible( true ); + + $html = '

content

'; + $out = $method->invoke( $this->proxy, $html ); + + $this->assertStringContainsString( 'id="exelearning-embed-shim"', $out ); + } + /** * The MIME map serves ES modules (.mjs) as JavaScript so module scripts * execute under strict MIME checking (issue #53). @@ -1179,4 +1266,124 @@ public function test_rewrite_relative_urls_external_not_proxied_when_enabled() { $this->assertEquals( $html, $method->invoke( $this->proxy, $html, $hash, '' ) ); } + + /** + * PDF documents get the opaque-origin sandbox CSP in secure mode. + */ + public function test_select_csp_pdf_secure_gets_opaque_sandbox() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'select_csp' ); + $method->setAccessible( true ); + $this->assertEquals( + 'sandbox allow-scripts allow-popups allow-forms', + $method->invoke( $this->proxy, 'application/pdf', "'self'", true ) + ); + } + + /** + * In legacy (same-origin) mode a PDF gets no CSP, matching the HTML legacy policy. + */ + public function test_select_csp_pdf_legacy_gets_no_policy() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'select_csp' ); + $method->setAccessible( true ); + $this->assertSame( '', $method->invoke( $this->proxy, 'application/pdf', "'self'", false ) ); + } + + /** + * Media types are treated like the PDF: opaque sandbox in secure mode. + */ + public function test_select_csp_media_secure_gets_opaque_sandbox() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'select_csp' ); + $method->setAccessible( true ); + $this->assertEquals( + 'sandbox allow-scripts allow-popups allow-forms', + $method->invoke( $this->proxy, 'video/mp4', "'self'", true ) + ); + } + + /** + * SVG/XML keep the script-free lockdown. + */ + public function test_select_csp_svg_is_script_free() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'select_csp' ); + $method->setAccessible( true ); + $csp = $method->invoke( $this->proxy, 'image/svg+xml', "'self'", true ); + $this->assertStringContainsString( "script-src 'none'", $csp ); + $this->assertStringContainsString( 'sandbox', $csp ); + } + + /** + * HTML gets a sandbox directive in secure mode and none in legacy mode. + */ + public function test_select_csp_html_sandbox_follows_mode() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'select_csp' ); + $method->setAccessible( true ); + $secure = $method->invoke( $this->proxy, 'text/html', "'self'", true ); + $legacy = $method->invoke( $this->proxy, 'text/html', "'self'", false ); + $this->assertStringContainsString( 'sandbox allow-scripts allow-popups allow-forms', $secure ); + $this->assertStringNotContainsString( 'sandbox', $legacy ); + } + + /** + * A Sec-Fetch-Dest: document request is recognised as a top-level navigation. + */ + public function test_is_toplevel_navigation_true_for_document() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'is_toplevel_navigation' ); + $method->setAccessible( true ); + $saved = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ? $_SERVER['HTTP_SEC_FETCH_DEST'] : null; + + $_SERVER['HTTP_SEC_FETCH_DEST'] = 'document'; + $this->assertTrue( $method->invoke( $this->proxy ) ); + + if ( null === $saved ) { + unset( $_SERVER['HTTP_SEC_FETCH_DEST'] ); + } else { + $_SERVER['HTTP_SEC_FETCH_DEST'] = $saved; + } + } + + /** + * An embedded request (Sec-Fetch-Dest: iframe) is not a top-level navigation. + */ + public function test_is_toplevel_navigation_false_for_iframe() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'is_toplevel_navigation' ); + $method->setAccessible( true ); + $saved = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ? $_SERVER['HTTP_SEC_FETCH_DEST'] : null; + + $_SERVER['HTTP_SEC_FETCH_DEST'] = 'iframe'; + $this->assertFalse( $method->invoke( $this->proxy ) ); + + if ( null === $saved ) { + unset( $_SERVER['HTTP_SEC_FETCH_DEST'] ); + } else { + $_SERVER['HTTP_SEC_FETCH_DEST'] = $saved; + } + } + + /** + * With no Sec-Fetch-Dest header we treat the request as embedded (serve content). + */ + public function test_is_toplevel_navigation_false_without_header() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'is_toplevel_navigation' ); + $method->setAccessible( true ); + $saved = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ? $_SERVER['HTTP_SEC_FETCH_DEST'] : null; + + unset( $_SERVER['HTTP_SEC_FETCH_DEST'] ); + $this->assertFalse( $method->invoke( $this->proxy ) ); + + if ( null !== $saved ) { + $_SERVER['HTTP_SEC_FETCH_DEST'] = $saved; + } + } + + /** + * The top-level notice is a self-contained, script-free HTML document. + */ + public function test_toplevel_notice_is_standalone_document() { + $method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'build_toplevel_notice' ); + $method->setAccessible( true ); + $html = $method->invoke( $this->proxy ); + $this->assertStringContainsString( '', $html ); + $this->assertStringContainsString( 'assertStringNotContainsString( 'assertArrayNotHasKey( 'exelearningCanEdit', $result ); } + + // ---- opaque preview wiring -------------------------------------------- + + /** + * Test pretty_permalinks_enabled reflects the permalink_structure option. + */ + public function test_pretty_permalinks_enabled_reflects_option() { + update_option( 'permalink_structure', '' ); + $this->assertFalse( ExeLearning_Editor::pretty_permalinks_enabled() ); + update_option( 'permalink_structure', '/%postname%/' ); + $this->assertTrue( ExeLearning_Editor::pretty_permalinks_enabled() ); + } + + /** + * Test the constructor registers the permalink admin notice. + */ + public function test_constructor_registers_admin_notice() { + $editor = new ExeLearning_Editor(); + $this->assertNotFalse( has_action( 'admin_notices', array( $editor, 'maybe_warn_preview_permalinks' ) ) ); + } + + /** + * Test the admin notice warns on a host screen under plain permalinks. + */ + public function test_admin_notice_warns_under_plain_permalinks() { + update_option( 'permalink_structure', '' ); + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + set_current_screen( 'upload' ); + + ob_start(); + $this->editor->maybe_warn_preview_permalinks(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'notice-warning', $output ); + $this->assertStringContainsString( 'options-permalink.php', $output ); + } + + /** + * Test the admin notice stays silent when pretty permalinks are enabled. + */ + public function test_admin_notice_silent_under_pretty_permalinks() { + update_option( 'permalink_structure', '/%postname%/' ); + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + set_current_screen( 'upload' ); + + ob_start(); + $this->editor->maybe_warn_preview_permalinks(); + $this->assertEmpty( ob_get_clean() ); + } + + /** + * Test the admin notice stays silent on an unrelated screen. + */ + public function test_admin_notice_silent_on_unrelated_screen() { + update_option( 'permalink_structure', '' ); + wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + set_current_screen( 'dashboard' ); + + ob_start(); + $this->editor->maybe_warn_preview_permalinks(); + $this->assertEmpty( ob_get_clean() ); + } + + /** + * Test the admin notice stays silent for a user without upload_files. + */ + public function test_admin_notice_silent_without_upload_capability() { + update_option( 'permalink_structure', '' ); + wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) ); + set_current_screen( 'upload' ); + + ob_start(); + $this->editor->maybe_warn_preview_permalinks(); + $this->assertEmpty( ob_get_clean() ); + } + + /** + * Test the Service Worker guard purges stale caches WITHOUT blocking new + * registrations, and is a bare IIFE. + * + * The trust-boundary editor registers its own preview Service Worker, which + * serves ONLY DOMPurify-sanitized ("filtered") content from a real + * same-origin URL — required so whitelisted external videos get a valid + * referrer and play inline (a blob: transport is rejected with Error 153). + * The guard must therefore NOT neutralize `serviceWorker.register`; it only + * clears caches left by an earlier build. + */ + public function test_purge_stale_editor_caches_script_purges_without_blocking_registration() { + $js = ExeLearning_Editor::purge_stale_editor_caches_script(); + // Purges caches left by an earlier editor build. + $this->assertStringContainsString( 'caches', $js ); + $this->assertStringContainsString( 'caches.delete', $js ); + // Does NOT stub registration to a no-op: the editor must be able to + // register its own filtered-preview worker. + $this->assertStringNotContainsString( 'serviceWorker.register = function', $js ); + $this->assertStringNotContainsString( 'Promise.resolve({', $js ); + // Self-contained IIFE — the caller wraps it in