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 ab2264c..d4e8c6b 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -9,7 +9,10 @@ /tests/* /includes/vendor/* /dist/* - /exelearning/* + + */wp-content/uploads/exelearning/* diff --git a/README.md b/README.md index 93f2b09..190edd1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ EXELEARNING_EDITOR_REF=my-feature EXELEARNING_EDITOR_REF_TYPE=branch make build- > **Important:** It is recommended to download from [Releases](https://github.com/exelearning/wp-exelearning/releases) for production use, which includes the embedded editor pre-built. If you clone the repository without building the editor, you can install it from the WordPress admin panel at **Settings > eXeLearning** using the "Download & Install Editor" button, which fetches the latest static editor package from GitHub Releases automatically. No remote loading or proxy is used at runtime. +### 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 29f2428..0d195cf 100644 --- a/admin/views/editor-bootstrap.php +++ b/admin/views/editor-bootstrap.php @@ -111,6 +111,15 @@ 'fallbackTheme' => 'base', ); +// Opaque HTTP preview transport (serving contract v2). Emitted only under +// pretty permalinks; otherwise `previewHttp` is omitted so the editor fails +// closed (no silent same-origin fallback) and ExeLearning_Editor's admin notice +// explains the requirement. wp_json_encode() output is valid JS object syntax. +$exelearning_preview_http = ExeLearning_Editor::build_preview_http_config( $exelearning_nonce ); +$exelearning_preview_http_js = null === $exelearning_preview_http + ? '' + : "\n previewHttp: " . wp_json_encode( $exelearning_preview_http ) . ','; + // 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 +209,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 over HTTP (see previewHttp 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 +230,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 +249,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 +414,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_http_js, esc_url( $exelearning_plugin_assets_url ) ); // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript @@ -538,10 +471,20 @@ 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 guard so the bundled pre-HTTP-v2 editor can never register a +// same-origin /viewer/ preview worker on the WordPress origin (interim +// protection; see ExeLearning_Editor::service_worker_guard_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_sw_guard_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_sw_guard_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/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 ', $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() ) ); return $html; diff --git a/includes/class-exelearning-editor.php b/includes/class-exelearning-editor.php index e05686c..51959de 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,104 @@ 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' ); + } + + /** + * The `previewHttp` embedding config that opts the editor into the opaque + * HTTP preview transport (serving contract v2), or null when pretty + * permalinks are disabled. + * + * When null, the bootstrap omits `previewHttp` entirely and the editor fails + * closed with a clear error (per contract, no silent same-origin fallback); + * {@see maybe_warn_preview_permalinks} surfaces the reason to the admin. + * + * @param string $nonce The `wp_rest` nonce (sent as `X-WP-Nonce` on every + * management request). + * @return array|null + */ + public static function build_preview_http_config( $nonce ) { + if ( ! self::pretty_permalinks_enabled() ) { + return null; + } + return array( + 'protocolVersion' => 2, + 'managementBaseUrl' => rest_url( 'exelearning/v1/preview-session' ), + 'servingBaseUrl' => rest_url( 'exelearning/v1/preview' ), + 'managementHeaders' => array( 'X-WP-Nonce' => (string) $nonce ), + ); + } + + /** + * Interim Service Worker neutralization for the editor bootstrap. + * + * The opaque preview travels over HTTP; a Service Worker must NEVER serve it + * on the WordPress origin. But the bundled static editor (`.editor-version`, + * v4.0.2) predates the HTTP transport and can still call + * `navigator.serviceWorker.register('preview-sw.js', { scope: '…/viewer/' })` + * for a same-origin `/viewer/` preview — which would put untrusted author + * content SAME-ORIGIN and WITHOUT the sandbox CSP. This stub makes + * registration a resolved no-op so no such worker can ever activate, until an + * HTTP-v2 editor build ships (mirrors the Nextcloud/Moodle resilience shim). + * It is NOT a path rewrite (the removed `/viewer/` monkey-patch was). + * + * 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/fixtures/preview-contract/README.md b/tests/fixtures/preview-contract/README.md new file mode 100644 index 0000000..59495f5 --- /dev/null +++ b/tests/fixtures/preview-contract/README.md @@ -0,0 +1,72 @@ +# Preview serving contract v2 — conformance vectors + +`vectors.json` is a machine-readable sequence of requests and expected +responses that every host implementing the preview serving contract v2 +(Moodle, WordPress, Omeka S, Nextcloud, Procomún, Electron, eXe core) should +replay against its own endpoints. In **this** (WordPress) repository the +contract mirror lives at [`docs/preview-serving-contract.md`](../../../docs/preview-serving-contract.md); +`vectors.json` and the reference consumer +(`src/routes/preview-session.conformance.spec.ts`) are **vendored verbatim from +eXe core**, so the relative paths inside the JSON's `description` refer to the +core tree, not this one. The WordPress replay harness is +[`tests/unit/PreviewContractVectorsTest.php`](../../unit/PreviewContractVectorsTest.php) — +port the reference interpretation, not just its assertions. + +> **Re-vendor pending.** The contract v2.1 Range/bare-root deltas (malformed / +> multi-range / non-`bytes` ranges → `200` full body; bare `.../{previewId}` → +> `302` to `index.html`) are covered by the WordPress unit tests but are **not +> yet** in this vendored `vectors.json`. Do **not** fork the JSON: it will be +> re-vendored from core once core adds those steps. Adapt the harness minimally +> (with a `TODO(re-vendor)` note) if a future step conflicts. + +## Harness semantics + +1. **Fixed resources.** Before replaying, materialize every entry of + `fixedResources` under a throwaway distribution root: write `content` to + `path` (relative to the root) and register the id → `{ path, size }` in the + host's copy of the fixed-resource manifest. The host must resolve fixed refs + through that manifest only. +2. **Authentication.** Management requests (`/api/preview-session…`) run as an + authenticated session owner (however the host authenticates). Serving + requests (`/preview/…`) run with **no credentials**. +3. **Steps run in order** against ONE session. After `create-session`, capture + `previewId` from the response body and substitute it for `{previewId}` in + every later path. `constants` exists only for human reference — asset keys + appear verbatim inside the steps. +4. **Request bodies.** + - `body.kind === "assets"` → multipart form: field `assets` = + `JSON.stringify(entries.map(e => ({ key: e.key, size: byteLength(e.content) })))`, + plus one `files[]` part per entry (the UTF-8 bytes of `content`), + index-aligned. + - `body.kind === "revision"` → multipart form: field `revision` = + `JSON.stringify({ ...meta, writes: writes.map(w => w.path) })`, plus one + `files[]` part per `writes` entry (the UTF-8 bytes of `content`), + index-aligned. + - `request.headers` (e.g. `Range`, `If-None-Match`) are sent verbatim. +5. **Expectations.** + - `expect.status` — exact match. + - `expect.body` — recursive **subset** match against the parsed JSON + response (arrays must match exactly, objects may carry extra keys). + - `expect.bodyText` — exact match of the response body text. + - `expect.headers` — per header (names case-insensitive): a string means + exact value match; `{ "startsWith": s }` / `{ "contains": s }` match a + prefix / substring; `{ "absent": true }` asserts the header is not set. + +## What the sequence proves + +- protocol v2 negotiation (`protocolVersion: 2`, `revision: 0` at creation); +- asset upload + **immutability** (re-uploading a key with different bytes is + `alreadyStored` and the original bytes keep serving); +- atomic revision publication and the three-layer resolution order; +- tiered `Cache-Control` (document `no-store` / asset `no-cache` + `ETag` / + fixed `private, max-age=31536000` / 404 `no-store`); +- conditional (`304`) and single-range (`206`/`416`) asset requests; +- the sandbox-first CSP on every scriptable type from the session **and** the + fixed layer (the "SVG opened in a new tab" hole); +- hardening headers on every response, 404s included; +- traversal rejection (raw and percent-encoded); +- `409 revision-conflict` with `currentRevision`, `422 missing-assets`; +- session deletion kills the capability URL. + +Keep this fixture in sync with the contract document; hosts vendor it verbatim +(do not fork the JSON). diff --git a/tests/fixtures/preview-contract/vectors.json b/tests/fixtures/preview-contract/vectors.json new file mode 100644 index 0000000..1356f07 --- /dev/null +++ b/tests/fixtures/preview-contract/vectors.json @@ -0,0 +1,396 @@ +{ + "description": "Machine-readable conformance vectors for eXeLearning preview serving contract v2 (doc/development/preview-serving-contract.md). Replay every step, in order, against a host implementation. See README.md for harness semantics.", + "protocolVersion": 2, + "fixedResources": { + "libs/jquery/jquery.min.js": { + "path": "libs/jquery/jquery.min.js", + "content": "window.jQuery=function(){};" + }, + "theme:base/icon.svg": { + "path": "files/perm/themes/base/base/icon.svg", + "content": "" + } + }, + "constants": { + "photoKey": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "clipKey": "12345678-90ab-4cde-8f01-234567890abc@00112233" + }, + "steps": [ + { + "id": "create-session", + "request": { "method": "POST", "path": "/api/preview-session" }, + "expect": { + "status": 201, + "body": { "protocolVersion": 2, "revision": 0 } + } + }, + { + "id": "upload-assets", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/assets", + "body": { + "kind": "assets", + "entries": [ + { "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", "content": "PHOTO-BYTES-v1" }, + { "key": "12345678-90ab-4cde-8f01-234567890abc@00112233", "content": "0123456789" } + ] + } + }, + "expect": { + "status": 200, + "body": { + "stored": [ + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "12345678-90ab-4cde-8f01-234567890abc@00112233" + ], + "alreadyStored": [], + "rejected": [] + } + } + }, + { + "id": "reupload-asset-immutability", + "comment": "Same key, DIFFERENT bytes: reported alreadyStored, bytes NOT replaced (asserted later by serve-asset).", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/assets", + "body": { + "kind": "assets", + "entries": [ + { + "key": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "content": "PHOTO-BYTES-v2-DIFFERENT" + } + ] + } + }, + "expect": { + "status": 200, + "body": { + "stored": [], + "alreadyStored": ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57"], + "rejected": [] + } + } + }, + { + "id": "publish-revision-1", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 0, + "nextRevision": 1, + "deletes": [], + "assetRefs": { + "content/resources/photo.png": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57", + "media/clip.mp4": "12345678-90ab-4cde-8f01-234567890abc@00112233" + }, + "fixedRefs": { + "libs/jquery/jquery.min.js": "libs/jquery/jquery.min.js", + "theme/icon.svg": "theme:base/icon.svg" + } + }, + "writes": [ + { "path": "index.html", "content": "conformance" }, + { + "path": "img/inline.svg", + "content": "" + } + ] + } + }, + "expect": { "status": 200, "body": { "revision": 1, "active": true } } + }, + { + "id": "serve-document", + "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, + "expect": { + "status": 200, + "bodyText": "conformance", + "headers": { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Access-Control-Allow-Origin": "*", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-bare-root-redirect", + "comment": "The bare capability root never serves index.html bytes; it 302-redirects to the entry document with a RELATIVE Location (BASE_PATH / app:// safe). No trailing slash → Location resolves against /preview/{id}.", + "request": { "method": "GET", "path": "/preview/{previewId}" }, + "expect": { + "status": 302, + "headers": { + "Location": "{previewId}/index.html", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff" + } + } + }, + { + "id": "serve-asset", + "comment": "Bytes are the ORIGINAL upload — proves reupload-asset-immutability did not replace them.", + "request": { "method": "GET", "path": "/preview/{previewId}/content/resources/photo.png" }, + "expect": { + "status": 200, + "bodyText": "PHOTO-BYTES-v1", + "headers": { + "Content-Type": "image/png", + "Cache-Control": "no-cache", + "ETag": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"", + "Accept-Ranges": "bytes", + "Content-Security-Policy": { "absent": true } + } + } + }, + { + "id": "serve-asset-304", + "request": { + "method": "GET", + "path": "/preview/{previewId}/content/resources/photo.png", + "headers": { "If-None-Match": "\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000@9c41d2e8a1b03f57\"" } + }, + "expect": { + "status": 304, + "headers": { "X-Content-Type-Options": "nosniff" } + } + }, + { + "id": "serve-asset-range", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=2-4" } + }, + "expect": { + "status": 206, + "bodyText": "234", + "headers": { "Content-Range": "bytes 2-4/10", "Content-Length": "3" } + } + }, + { + "id": "serve-asset-range-unsatisfiable", + "comment": "Valid single range past EOF (first-byte-pos ≥ length) → 416.", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=99-" } + }, + "expect": { + "status": 416, + "headers": { "Content-Range": "bytes */10" } + } + }, + { + "id": "serve-asset-range-zero-suffix-unsatisfiable", + "comment": "A zero-length suffix (bytes=-0) is a VALID but unsatisfiable range → 416.", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=-0" } + }, + "expect": { + "status": 416, + "headers": { "Content-Range": "bytes */10" } + } + }, + { + "id": "serve-asset-range-inverted-ignored", + "comment": "last-byte-pos < first-byte-pos (bytes=5-2) is an INVALID spec and MUST be ignored → full 200 body, no Content-Range.", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=5-2" } + }, + "expect": { + "status": 200, + "bodyText": "0123456789", + "headers": { "Content-Range": { "absent": true } } + } + }, + { + "id": "serve-asset-range-inverted-oob-ignored", + "comment": "Inverted AND out of bounds (bytes=15-2 on a 10-byte body): structural invalidity is checked BEFORE satisfiability, so it is ignored (200), NEVER 416.", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=15-2" } + }, + "expect": { + "status": 200, + "bodyText": "0123456789", + "headers": { "Content-Range": { "absent": true } } + } + }, + { + "id": "serve-asset-range-multi-ignored", + "comment": "Multi-range is unsupported → ignored → full 200 body.", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=0-1,3-4" } + }, + "expect": { + "status": 200, + "bodyText": "0123456789", + "headers": { "Content-Range": { "absent": true } } + } + }, + { + "id": "serve-asset-range-non-bytes-ignored", + "comment": "A non-bytes range unit → ignored → full 200 body.", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "items=0-1" } + }, + "expect": { + "status": 200, + "bodyText": "0123456789", + "headers": { "Content-Range": { "absent": true } } + } + }, + { + "id": "serve-asset-range-garbage-ignored", + "comment": "Unparseable garbage (non-numeric positions) → ignored → full 200 body.", + "request": { + "method": "GET", + "path": "/preview/{previewId}/media/clip.mp4", + "headers": { "Range": "bytes=a-b" } + }, + "expect": { + "status": 200, + "bodyText": "0123456789", + "headers": { "Content-Range": { "absent": true } } + } + }, + { + "id": "serve-fixed", + "request": { "method": "GET", "path": "/preview/{previewId}/libs/jquery/jquery.min.js" }, + "expect": { + "status": 200, + "bodyText": "window.jQuery=function(){};", + "headers": { + "Cache-Control": "private, max-age=31536000", + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*" + } + } + }, + { + "id": "serve-fixed-scriptable-svg", + "comment": "The sandbox CSP must be emitted on scriptable types from EVERY layer, fixed included.", + "request": { "method": "GET", "path": "/preview/{previewId}/theme/icon.svg" }, + "expect": { + "status": 200, + "headers": { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "private, max-age=31536000", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-session-scriptable-svg", + "request": { "method": "GET", "path": "/preview/{previewId}/img/inline.svg" }, + "expect": { + "status": 200, + "headers": { + "Content-Type": "image/svg+xml; charset=utf-8", + "Content-Security-Policy": { "startsWith": "sandbox allow-scripts allow-popups allow-forms" } + } + } + }, + { + "id": "serve-unknown-404", + "request": { "method": "GET", "path": "/preview/{previewId}/nope.css" }, + "expect": { + "status": 404, + "headers": { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*" + } + } + }, + { + "id": "serve-traversal-raw", + "comment": "A literal ../ is normalized away by URL parsing before it reaches the server; whatever survives must still 404.", + "request": { "method": "GET", "path": "/preview/{previewId}/../secret" }, + "expect": { "status": 404 } + }, + { + "id": "serve-traversal-encoded", + "comment": "Percent-encoded traversal reaches the server verbatim and must be rejected by path normalization.", + "request": { "method": "GET", "path": "/preview/{previewId}/%2e%2e%2fsecret" }, + "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } + }, + { + "id": "conflicting-revision-409", + "comment": "Stale baseRevision (client believes 0, server is at 1).", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 0, + "nextRevision": 1, + "deletes": [], + "assetRefs": {}, + "fixedRefs": {} + }, + "writes": [{ "path": "index.html", "content": "stale" }] + } + }, + "expect": { + "status": 409, + "body": { "reason": "revision-conflict", "currentRevision": 1 } + } + }, + { + "id": "missing-asset-revision-422", + "request": { + "method": "POST", + "path": "/api/preview-session/{previewId}/revisions", + "body": { + "kind": "revision", + "meta": { + "baseRevision": 1, + "nextRevision": 2, + "deletes": [], + "assetRefs": { + "content/resources/ghost.png": "99999999-9999-4999-8999-999999999999@deadbeef" + }, + "fixedRefs": {} + }, + "writes": [] + } + }, + "expect": { + "status": 422, + "body": { + "reason": "missing-assets", + "missing": ["99999999-9999-4999-8999-999999999999@deadbeef"] + } + } + }, + { + "id": "delete-session", + "request": { "method": "DELETE", "path": "/api/preview-session/{previewId}" }, + "expect": { "status": 200, "body": { "success": true } } + }, + { + "id": "serve-after-delete-404", + "request": { "method": "GET", "path": "/preview/{previewId}/index.html" }, + "expect": { "status": 404, "headers": { "Cache-Control": "no-store" } } + } + ] +} 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..255194a 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). diff --git a/tests/unit/EditorTest.php b/tests/unit/EditorTest.php index 88afd16..9bf7977 100644 --- a/tests/unit/EditorTest.php +++ b/tests/unit/EditorTest.php @@ -575,4 +575,117 @@ public function test_add_edit_capability_non_string_file() { $this->assertArrayNotHasKey( 'exelearningCanEdit', $result ); } + + // ---- opaque HTTP preview wiring (serving contract v2) ----------------- + + /** + * 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 build_preview_http_config emits the two-URL config under pretty permalinks. + */ + public function test_build_preview_http_config_under_pretty_permalinks() { + update_option( 'permalink_structure', '/%postname%/' ); + $config = ExeLearning_Editor::build_preview_http_config( 'nonce-123' ); + $this->assertIsArray( $config ); + $this->assertSame( 2, $config['protocolVersion'] ); + $this->assertStringContainsString( 'exelearning/v1/preview-session', $config['managementBaseUrl'] ); + $this->assertStringContainsString( 'exelearning/v1/preview', $config['servingBaseUrl'] ); + $this->assertStringNotContainsString( 'preview-session', $config['servingBaseUrl'] ); + $this->assertSame( array( 'X-WP-Nonce' => 'nonce-123' ), $config['managementHeaders'] ); + } + + /** + * Test build_preview_http_config fails closed (null) under plain permalinks. + */ + public function test_build_preview_http_config_null_under_plain_permalinks() { + update_option( 'permalink_structure', '' ); + $this->assertNull( ExeLearning_Editor::build_preview_http_config( 'nonce-123' ) ); + } + + /** + * 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 neutralizes registration and is a bare IIFE. + */ + public function test_service_worker_guard_script_neutralizes_registration() { + $js = ExeLearning_Editor::service_worker_guard_script(); + $this->assertStringContainsString( 'navigator.serviceWorker.register', $js ); + $this->assertStringContainsString( 'Promise.resolve', $js ); + $this->assertStringContainsString( 'unregister', $js ); + // Self-contained IIFE — the caller wraps it in ' ); + file_put_contents( + $this->base . '/dist/bundles/preview-fixed-resources.json', + wp_json_encode( + array( + 'schemaVersion' => 1, + 'buildVersion' => 'test', + 'resources' => array( 'theme:icon' => array( 'path' => 'files/icon.svg', 'size' => 29 ) ), + ) + ), + LOCK_EX + ); + $fixed = new ExeLearning_Preview_Fixed_Resources( $this->base . '/dist' ); + $serving = new ExeLearning_Preview_Serving_Controller( $this->store, $fixed ); + + $id = $this->create_session_id(); + $this->store->apply_revision( + $id, + array( + 'baseRevision' => 0, + 'nextRevision' => 1, + 'writes' => array(), + 'deletes' => array(), + 'assetRefs' => array(), + 'fixedRefs' => array( 'theme/icon.svg' => 'theme:icon' ), + ), + $fixed + ); + + $resp = $serving->build_serve_response( $id, 'theme/icon.svg' ); + $this->assertSame( 200, $resp['status'] ); + $this->assertSame( 'private, max-age=31536000', $resp['headers']['Cache-Control'] ); + $this->assertSame( 'image/svg+xml; charset=utf-8', $resp['headers']['Content-Type'] ); + $this->assertSame( self::EXPECTED_CSP, $resp['headers']['Content-Security-Policy'] ); + } + + public function test_serve_after_delete_is_404() { + $id = $this->create_session_id(); + $this->publish_document( $id, 'index.html', 'x' ); + $this->store->delete_session( $id ); + $this->assertSame( 404, $this->serving->build_serve_response( $id, 'index.html' )['status'] ); + } + + public function test_uninjected_serving_builds_default_dependencies() { + $serving = new ExeLearning_Preview_Serving_Controller(); + // A bad capability id is rejected before any filesystem access, but it + // still exercises the lazy store()/fixed() getters. + $this->assertSame( 404, $serving->build_serve_response( 'not-a-valid-uuid', 'index.html' )['status'] ); + } +} diff --git a/tests/unit/PreviewSessionStoreTest.php b/tests/unit/PreviewSessionStoreTest.php new file mode 100644 index 0000000..8657a95 --- /dev/null +++ b/tests/unit/PreviewSessionStoreTest.php @@ -0,0 +1,505 @@ +store_dir = $base . '/store'; + $this->tmp_root = $base . '/tmp'; + $this->dist_root = $base . '/dist'; + wp_mkdir_p( $this->store_dir ); + wp_mkdir_p( $this->tmp_root ); + wp_mkdir_p( $this->dist_root ); + $this->store = new ExeLearning_Preview_Session_Store( $this->store_dir ); + $this->fixed = new ExeLearning_Preview_Fixed_Resources( $this->dist_root ); + } + + public function tear_down() { + $this->rrmdir( dirname( $this->store_dir ) ); + parent::tear_down(); + } + + private function rrmdir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $items as $item ) { + $item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); + } + rmdir( $dir ); + } + + private function tmp_with( $bytes ) { + $path = $this->tmp_root . '/' . wp_generate_password( 12, false ); + file_put_contents( $path, $bytes ); + return $path; + } + + private function asset_entry( $key, $bytes ) { + return array( + 'key' => $key, + 'declaredSize' => strlen( $bytes ), + 'tmp_path' => $this->tmp_with( $bytes ), + ); + } + + private function write( $path, $bytes ) { + return array( + 'path' => $path, + 'tmp_path' => $this->tmp_with( $bytes ), + ); + } + + private function revision_meta( $base, $next, $writes, $overrides = array() ) { + return array_merge( + array( + 'baseRevision' => $base, + 'nextRevision' => $next, + 'writes' => $writes, + 'deletes' => array(), + 'assetRefs' => array(), + 'fixedRefs' => array(), + ), + $overrides + ); + } + + // ---- normalize_content_path ------------------------------------------ + + public function test_normalize_content_path_valid() { + $this->assertSame( 'html/page.html', ExeLearning_Preview_Session_Store::normalize_content_path( 'html/page.html' ) ); + $this->assertSame( 'index.html', ExeLearning_Preview_Session_Store::normalize_content_path( '' ) ); + $this->assertSame( 'index.html', ExeLearning_Preview_Session_Store::normalize_content_path( '/' ) ); + $this->assertSame( 'a/b.css', ExeLearning_Preview_Session_Store::normalize_content_path( '/a/./b.css' ) ); + $this->assertSame( 'b', ExeLearning_Preview_Session_Store::normalize_content_path( 'a/../b' ) ); + } + + public function test_normalize_content_path_rejects_traversal() { + $this->assertNull( ExeLearning_Preview_Session_Store::normalize_content_path( '../secret' ) ); + $this->assertNull( ExeLearning_Preview_Session_Store::normalize_content_path( 'a/../../secret' ) ); + $this->assertNull( ExeLearning_Preview_Session_Store::normalize_content_path( '..' ) ); + } + + public function test_normalize_content_path_decodes_and_rejects_encoded_traversal() { + $this->assertSame( 'a/b.html', ExeLearning_Preview_Session_Store::normalize_content_path( 'a%2Fb.html' ) ); + $this->assertNull( ExeLearning_Preview_Session_Store::normalize_content_path( '%2e%2e%2fsecret' ) ); + } + + public function test_normalize_content_path_rejects_null_byte() { + $this->assertNull( ExeLearning_Preview_Session_Store::normalize_content_path( "a\0b" ) ); + } + + public function test_normalize_content_path_strips_query_and_fragment() { + $this->assertSame( 'p.html', ExeLearning_Preview_Session_Store::normalize_content_path( 'p.html?v=1#frag' ) ); + } + + // ---- session lifecycle ----------------------------------------------- + + public function test_create_session_returns_uuid_and_zero_revision() { + $session = $this->store->create_session( 7 ); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', + $session['previewId'] + ); + $this->assertSame( 0, $session['revision'] ); + $this->assertTrue( is_dir( $this->store_dir . '/' . $session['previewId'] ) ); + } + + public function test_get_limits_matches_constants() { + $limits = $this->store->get_limits(); + $this->assertSame( ExeLearning_Preview_Session_Store::MAX_FILES_PER_SESSION, $limits['maxFilesPerSession'] ); + $this->assertSame( ExeLearning_Preview_Session_Store::MAX_BYTES_PER_SESSION, $limits['maxBytesPerSession'] ); + $this->assertSame( ExeLearning_Preview_Session_Store::MAX_ASSET_BYTES, $limits['maxAssetBytes'] ); + $this->assertSame( ExeLearning_Preview_Session_Store::RECOMMENDED_BATCH_BYTES, $limits['recommendedBatchBytes'] ); + } + + public function test_get_owned_session_ownership() { + $id = $this->store->create_session( 7 )['previewId']; + $this->assertArrayHasKey( 'meta', $this->store->get_owned_session( $id, 7 ) ); + $this->assertSame( 403, $this->store->get_owned_session( $id, 9 )['status'] ); + $this->assertSame( 404, $this->store->get_owned_session( 'not-a-session', 7 )['status'] ); + $this->assertSame( + 404, + $this->store->get_owned_session( 'ffffffff-ffff-4fff-8fff-ffffffffffff', 7 )['status'] + ); + } + + public function test_per_user_cap_evicts_lru() { + $ids = array(); + for ( $i = 0; $i < ExeLearning_Preview_Session_Store::MAX_SESSIONS_PER_USER; $i++ ) { + $ids[] = $this->store->create_session( 42 )['previewId']; + // Stagger the access clock so the first session is the strict LRU. + touch( $this->store_dir . '/' . $ids[ $i ] . '/access', time() - ( 100 - $i ) ); + } + $extra = $this->store->create_session( 42 )['previewId']; + $this->assertFalse( is_dir( $this->store_dir . '/' . $ids[0] ), 'oldest session should be evicted' ); + $this->assertTrue( is_dir( $this->store_dir . '/' . $extra ) ); + } + + // ---- assets (immutability, validation) ------------------------------- + + public function test_store_assets_new_then_immutable() { + $id = $this->store->create_session( 1 )['previewId']; + $out = $this->store->store_assets( $id, array( $this->asset_entry( self::PHOTO_KEY, 'PHOTO-BYTES-v1' ) ) ); + $this->assertSame( array( self::PHOTO_KEY ), $out['stored'] ); + $this->assertSame( array(), $out['alreadyStored'] ); + + // Re-upload the same key with DIFFERENT bytes: reported alreadyStored, + // bytes NOT replaced. + $out2 = $this->store->store_assets( $id, array( $this->asset_entry( self::PHOTO_KEY, 'PHOTO-BYTES-v2-DIFFERENT' ) ) ); + $this->assertSame( array(), $out2['stored'] ); + $this->assertSame( array( self::PHOTO_KEY ), $out2['alreadyStored'] ); + + $this->assertSame( + 'PHOTO-BYTES-v1', + file_get_contents( $this->store_dir . '/' . $id . '/assets/' . self::PHOTO_KEY ) + ); + } + + public function test_store_assets_rejects_invalid_key_and_size_mismatch() { + $id = $this->store->create_session( 1 )['previewId']; + $out = $this->store->store_assets( + $id, + array( + array( + 'key' => 'not-a-valid-key', + 'declaredSize' => 3, + 'tmp_path' => $this->tmp_with( 'abc' ), + ), + array( + 'key' => self::CLIP_KEY, + 'declaredSize' => 999, + 'tmp_path' => $this->tmp_with( 'short' ), + ), + ) + ); + $this->assertSame( array(), $out['stored'] ); + $reasons = wp_list_pluck( $out['rejected'], 'reason' ); + $this->assertContains( 'invalid-key', $reasons ); + $this->assertContains( 'size-mismatch', $reasons ); + } + + // ---- revisions (atomic publication, validation order) ---------------- + + public function test_apply_revision_publishes_and_serves_document() { + $id = $this->store->create_session( 1 )['previewId']; + $res = $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array( $this->write( 'index.html', '

one

' ) ) ), + $this->fixed + ); + $this->assertTrue( $res['active'] ); + $this->assertSame( 1, $res['revision'] ); + + $lookup = $this->store->serve_lookup( $id, 'index.html', $this->fixed ); + $this->assertSame( 'document', $lookup['kind'] ); + $this->assertSame( '

one

', file_get_contents( $lookup['path'] ) ); + } + + public function test_apply_revision_conflict_on_stale_base() { + $id = $this->store->create_session( 1 )['previewId']; + $this->store->apply_revision( $id, $this->revision_meta( 0, 1, array( $this->write( 'index.html', 'a' ) ) ), $this->fixed ); + + $res = $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array( $this->write( 'index.html', 'stale' ) ) ), + $this->fixed + ); + $this->assertSame( 409, $res['status'] ); + $this->assertSame( 1, $res['currentRevision'] ); + } + + public function test_apply_revision_rejects_unsafe_path() { + $id = $this->store->create_session( 1 )['previewId']; + $res = $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array( $this->write( '../escape.html', 'x' ) ) ), + $this->fixed + ); + $this->assertSame( 400, $res['status'] ); + } + + public function test_apply_revision_missing_assets_422() { + $id = $this->store->create_session( 1 )['previewId']; + $res = $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array(), array( 'assetRefs' => array( 'r/ghost.png' => self::PHOTO_KEY ) ) ), + $this->fixed + ); + $this->assertSame( 422, $res['status'] ); + $this->assertSame( 'missing-assets', $res['reason'] ); + $this->assertSame( array( self::PHOTO_KEY ), $res['missing'] ); + } + + public function test_apply_revision_unknown_fixed_422() { + $id = $this->store->create_session( 1 )['previewId']; + $res = $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array(), array( 'fixedRefs' => array( 'libs/x.js' => 'libs/unknown' ) ) ), + $this->fixed + ); + $this->assertSame( 422, $res['status'] ); + $this->assertSame( 'unknown-fixed-resources', $res['reason'] ); + $this->assertSame( array( 'libs/unknown' ), $res['resources'] ); + } + + public function test_apply_revision_is_atomic_across_revisions() { + $id = $this->store->create_session( 1 )['previewId']; + $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array( $this->write( 'index.html', 'v1' ), $this->write( 'keep.html', 'keep' ) ) ), + $this->fixed + ); + $this->store->apply_revision( + $id, + $this->revision_meta( 1, 2, array( $this->write( 'index.html', 'v2' ) ) ), + $this->fixed + ); + + // index.html updated, keep.html carried forward into revision 2. + $this->assertSame( 'v2', file_get_contents( $this->store->serve_lookup( $id, 'index.html', $this->fixed )['path'] ) ); + $this->assertSame( 'keep', file_get_contents( $this->store->serve_lookup( $id, 'keep.html', $this->fixed )['path'] ) ); + } + + public function test_revision_delete_drops_document() { + $id = $this->store->create_session( 1 )['previewId']; + $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array( $this->write( 'gone.html', 'x' ), $this->write( 'index.html', 'i' ) ) ), + $this->fixed + ); + $this->store->apply_revision( + $id, + $this->revision_meta( 1, 2, array(), array( 'deletes' => array( 'gone.html' ) ) ), + $this->fixed + ); + $this->assertNull( $this->store->serve_lookup( $id, 'gone.html', $this->fixed ) ); + $this->assertNotNull( $this->store->serve_lookup( $id, 'index.html', $this->fixed ) ); + } + + // ---- serving resolution & TTL ---------------------------------------- + + public function test_serve_lookup_resolves_asset_with_etag() { + $id = $this->store->create_session( 1 )['previewId']; + $this->store->store_assets( $id, array( $this->asset_entry( self::PHOTO_KEY, 'PNGDATA' ) ) ); + $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array(), array( 'assetRefs' => array( 'img/p.png' => self::PHOTO_KEY ) ) ), + $this->fixed + ); + $lookup = $this->store->serve_lookup( $id, 'img/p.png', $this->fixed ); + $this->assertSame( 'asset', $lookup['kind'] ); + $this->assertSame( self::PHOTO_KEY, $lookup['etag'] ); + $this->assertSame( 'PNGDATA', file_get_contents( $lookup['path'] ) ); + } + + public function test_serve_lookup_unknown_and_bad_id() { + $id = $this->store->create_session( 1 )['previewId']; + $this->store->apply_revision( $id, $this->revision_meta( 0, 1, array( $this->write( 'index.html', 'x' ) ) ), $this->fixed ); + $this->assertNull( $this->store->serve_lookup( $id, 'nope.css', $this->fixed ) ); + $this->assertNull( $this->store->serve_lookup( 'not-a-uuid', 'index.html', $this->fixed ) ); + $this->assertNull( $this->store->serve_lookup( $id, '../secret', $this->fixed ) ); + } + + public function test_serve_lookup_before_first_revision_is_404() { + $id = $this->store->create_session( 1 )['previewId']; + $this->assertNull( $this->store->serve_lookup( $id, 'index.html', $this->fixed ) ); + } + + public function test_idle_ttl_expiry_deletes_on_serve() { + $id = $this->store->create_session( 1 )['previewId']; + $this->store->apply_revision( $id, $this->revision_meta( 0, 1, array( $this->write( 'index.html', 'x' ) ) ), $this->fixed ); + touch( $this->store_dir . '/' . $id . '/access', time() - ( ExeLearning_Preview_Session_Store::TTL_SECONDS + 60 ) ); + + $this->assertNull( $this->store->serve_lookup( $id, 'index.html', $this->fixed ) ); + $this->assertFalse( is_dir( $this->store_dir . '/' . $id ), 'expired session should be reclaimed' ); + } + + public function test_sweep_expired_removes_idle_sessions() { + $fresh = $this->store->create_session( 1 )['previewId']; + $expired = $this->store->create_session( 1 )['previewId']; + touch( $this->store_dir . '/' . $expired . '/access', time() - ( ExeLearning_Preview_Session_Store::TTL_SECONDS + 60 ) ); + + $this->assertSame( 1, $this->store->sweep_expired() ); + $this->assertTrue( is_dir( $this->store_dir . '/' . $fresh ) ); + $this->assertFalse( is_dir( $this->store_dir . '/' . $expired ) ); + } + + public function test_delete_session_removes_directory() { + $id = $this->store->create_session( 1 )['previewId']; + $this->assertTrue( $this->store->delete_session( $id ) ); + $this->assertFalse( is_dir( $this->store_dir . '/' . $id ) ); + $this->assertFalse( $this->store->delete_session( $id ) ); + } + + // ---- web-access guard (CSP bypass defence) --------------------------- + + public function test_base_dir_is_guarded_against_direct_web_access() { + // The store lives under wp-content/uploads, which is web-servable. Author + // HTML fetched directly (bypassing the REST route) would be served + // same-origin WITHOUT the sandbox CSP. The base dir must deny direct + // access as soon as it is used. + $this->store->create_session( 1 ); + + $htaccess = $this->store_dir . '/.htaccess'; + $index = $this->store_dir . '/index.php'; + $this->assertFileExists( $htaccess ); + $this->assertFileExists( $index ); + + $rules = file_get_contents( $htaccess ); + $this->assertStringContainsString( 'Require all denied', $rules ); + $this->assertStringContainsString( 'Deny from all', $rules ); + $this->assertStringContainsString( 'Silence is golden', file_get_contents( $index ) ); + } + + public function test_web_access_guard_is_idempotent_and_self_healing() { + $this->store->create_session( 1 ); + $htaccess = $this->store_dir . '/.htaccess'; + + // A tampered/removed guard is rewritten on the next store operation. + unlink( $htaccess ); + $this->assertFileDoesNotExist( $htaccess ); + $this->store->create_session( 1 ); + $this->assertFileExists( $htaccess ); + $this->assertStringContainsString( 'Require all denied', file_get_contents( $htaccess ) ); + } + + public function test_ships_nginx_deny_snippet_for_non_apache_servers() { + // nginx does not read the .htaccess guard, so the plugin must ship an + // includable deny snippet for the preview store — otherwise a direct GET + // serves untrusted author HTML same-origin without the sandbox CSP. + $snippet = EXELEARNING_PLUGIN_DIR . 'nginx-exelearning-preview.conf'; + $this->assertFileExists( $snippet ); + $conf = file_get_contents( $snippet ); + $this->assertStringContainsString( 'exelearning-preview/', $conf ); + $this->assertMatchesRegularExpression( '/return\s+403/', $conf ); + } + + // ---- failed-write integrity (contract §5) ---------------------------- + + public function test_asset_write_failure_is_rejected_and_never_indexed() { + $id = $this->store->create_session( 1 )['previewId']; + // Force the copy to fail: pre-create the asset's destination path as a + // DIRECTORY, so copy() to it returns false (cross-platform). + wp_mkdir_p( $this->store_dir . '/' . $id . '/assets/' . self::PHOTO_KEY ); + + $out = $this->store->store_assets( $id, array( $this->asset_entry( self::PHOTO_KEY, 'PHOTO-BYTES' ) ) ); + + // Reported write-failed, NOT stored — the discarded copy() return used to + // let the key be indexed and reported as stored despite no bytes written. + $this->assertSame( array(), $out['stored'] ); + $this->assertSame( + array( + array( + 'key' => self::PHOTO_KEY, + 'reason' => 'write-failed', + ), + ), + $out['rejected'] + ); + // The byte counter was NOT bumped. + $this->assertSame( 0, (int) $this->store->get_owned_session( $id, 1 )['meta']['assetBytes'] ); + } + + public function test_revision_write_failure_aborts_before_pointer_swap() { + $id = $this->store->create_session( 1 )['previewId']; + // Force the publish rename to fail: pre-create the target revision dir as + // a NON-EMPTY directory, so rename(staging, revisions/1) returns false. + $target = $this->store_dir . '/' . $id . '/revisions/1'; + wp_mkdir_p( $target ); + file_put_contents( $target . '/blocker', 'x' ); + + $res = $this->store->apply_revision( + $id, + $this->revision_meta( 0, 1, array( $this->write( 'index.html', '

hi

' ) ) ), + $this->fixed + ); + + // 500, and the pointer was NOT swapped: the active revision is still 0 + // and nothing is servable — no partial/empty revision was published. + $this->assertSame( 500, $res['status'] ); + $this->assertSame( 0, (int) $this->store->get_owned_session( $id, 1 )['meta']['revision'] ); + $this->assertNull( $this->store->serve_lookup( $id, 'index.html', $this->fixed ) ); + // No stray staging directory is left behind. + $stray = glob( $this->store_dir . '/' . $id . '/revisions/.stage-*' ); + $this->assertSame( array(), false === $stray ? array() : $stray ); + } + + public function test_revision_doc_write_failure_keeps_prior_revision_served() { + $id = $this->store->create_session( 1 )['previewId']; + // Publish a good revision 1. + $this->store->apply_revision( $id, $this->revision_meta( 0, 1, array( $this->write( 'index.html', 'REV-ONE' ) ) ), $this->fixed ); + + // Revision 2 whose document copy is forced to fail: point the write at a + // DIRECTORY, so copy() of the staged document returns false. + $bad_src = $this->tmp_root . '/a-directory-not-a-file'; + wp_mkdir_p( $bad_src ); + $res = $this->store->apply_revision( + $id, + $this->revision_meta( 1, 2, array( array( 'path' => 'index.html', 'tmp_path' => $bad_src ) ) ), + $this->fixed + ); + + // The publish aborted before the pointer swap: 500, still on revision 1, + // and revision 1's document is still served intact. + $this->assertSame( 500, $res['status'] ); + $this->assertSame( 1, (int) $this->store->get_owned_session( $id, 1 )['meta']['revision'] ); + $lookup = $this->store->serve_lookup( $id, 'index.html', $this->fixed ); + $this->assertNotNull( $lookup ); + $this->assertSame( 'REV-ONE', file_get_contents( $lookup['path'] ) ); + $this->assertFalse( is_dir( $this->store_dir . '/' . $id . '/revisions/2' ) ); + } +} diff --git a/tests/unit/ShortcodesTest.php b/tests/unit/ShortcodesTest.php index 4f1db95..cfe6bda 100644 --- a/tests/unit/ShortcodesTest.php +++ b/tests/unit/ShortcodesTest.php @@ -101,7 +101,7 @@ public function test_display_exelearning_renders_iframe() { } /** - * Test iframe has sandbox attribute for security. + * Test the iframe is sandboxed and, by default (secure mode), opaque-origin. */ public function test_iframe_has_sandbox_attribute() { $attachment_id = $this->factory->attachment->create(); @@ -113,14 +113,81 @@ public function test_iframe_has_sandbox_attribute() { $this->assertStringContainsString( 'sandbox=', $result ); $this->assertStringContainsString( 'allow-scripts', $result ); - // allow-same-origin is required for the eXeLearning viewer (a same-origin - // app) to render inside the iframe. - $this->assertStringContainsString( 'allow-same-origin', $result ); + // Secure is the default: no allow-same-origin, so the content runs in an + // opaque origin and cannot reach this page. + $this->assertStringNotContainsString( 'allow-same-origin', $result ); // allow-modals is intentionally NOT granted so the preview cannot raise // "Leave site?" dialogs. $this->assertStringNotContainsString( 'allow-modals', $result ); } + /** + * The same-origin admin mode was removed: a leftover option=legacy is ignored, so the + * content iframe stays opaque (no allow-same-origin). Same-origin is reachable only via + * the dev-only EXELEARNING_UNSAFE_LEGACY_IFRAME constant (see IframeSandboxTest). + */ + public function test_iframe_sandbox_ignores_legacy_option_and_stays_opaque() { + update_option( ExeLearning_Iframe_Sandbox::OPTION, ExeLearning_Iframe_Sandbox::MODE_LEGACY ); + + $attachment_id = $this->factory->attachment->create(); + $hash = str_repeat( 'c', 40 ); + update_post_meta( $attachment_id, '_exelearning_extracted', $hash ); + update_post_meta( $attachment_id, '_exelearning_has_preview', '1' ); + + $result = $this->shortcodes->display_exelearning( array( 'id' => $attachment_id ) ); + + $this->assertStringNotContainsString( 'allow-same-origin', $result ); + } + + /** + * In secure mode the teacher selector is offered on the iframe src via ?exe-teacher=1 + * (read by the package from its own URL), with no contentDocument injection and no + * legacy exe-teacher-toggler parameter. + */ + public function test_secure_mode_carries_teacher_params_without_contentdocument() { + $attachment_id = $this->factory->attachment->create(); + $hash = str_repeat( 'd', 40 ); + update_post_meta( $attachment_id, '_exelearning_extracted', $hash ); + update_post_meta( $attachment_id, '_exelearning_has_preview', '1' ); + + $result = $this->shortcodes->display_exelearning( + array( + 'id' => $attachment_id, + 'teacher_mode' => '1', + 'teacher_mode_visible' => '0', + ) + ); + + $this->assertStringContainsString( 'exe-teacher=1', $result ); + $this->assertStringNotContainsString( 'exe-teacher-toggler', $result ); + $this->assertStringNotContainsString( 'contentDocument', $result ); + } + + /** + * In legacy mode the teacher selector is offered the same way as secure mode — + * through ?exe-teacher=1 on the iframe src — and the former same-origin + * contentDocument injection has been retired (core owns teacher mode now). + */ + public function test_legacy_mode_carries_teacher_param_without_contentdocument() { + update_option( ExeLearning_Iframe_Sandbox::OPTION, ExeLearning_Iframe_Sandbox::MODE_LEGACY ); + + $attachment_id = $this->factory->attachment->create(); + $hash = str_repeat( 'e', 40 ); + update_post_meta( $attachment_id, '_exelearning_extracted', $hash ); + update_post_meta( $attachment_id, '_exelearning_has_preview', '1' ); + + $result = $this->shortcodes->display_exelearning( + array( + 'id' => $attachment_id, + 'teacher_mode' => '1', + 'teacher_mode_visible' => '0', + ) + ); + + $this->assertStringContainsString( 'exe-teacher=1', $result ); + $this->assertStringNotContainsString( 'contentDocument', $result ); + } + /** * Test iframe has referrerpolicy attribute for security. */ diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..dfd3e4d --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +// Vitest config for the plugin's JavaScript unit tests. Scope: the secure-mode +// external-embed relay (assets/js/exe-embed-relay.js). The relay is a MIRROR of the +// canonical mod_exelearning source; these tests mirror its RELAY describe-blocks so +// drift in the validate()/makePlayer()/sync() logic is caught here too. The auto-running +// shim (assets/js/exe-embed-shim.js) is not unit-tested here. +export default defineConfig( { + test: { + globals: true, + // happy-dom gives the relay a window/document for createRelay() and the overlay + // DOM work, matching the canonical embedder's own setup. + environment: 'happy-dom', + include: [ 'tests/js/**/*.test.js' ], + }, +} );