diff --git a/.gitignore b/.gitignore index a42a178..88e6968 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,8 @@ node_modules/ # JS test artifacts (Vitest) coverage/ junit-js.xml + +# E2E artifacts (Playwright) +test-results/ +playwright-report/ .env diff --git a/README.md b/README.md index c155f0f..4f3caf8 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,39 @@ editor remains): editor's built-in styles, and optionally block users from importing styles bundled inside an `.elpx`. A dedicated _Styles_ admin page lists and manages them. +* **Package iframe security mode** (`iframemode`, default **Secure**): in _Secure_ + mode the eXeLearning package runs in a sandboxed, **opaque-origin** iframe so its + JavaScript cannot read or modify the surrounding Moodle page, its cookies or the + session; SCORM scoring is relayed to Moodle over a validated `postMessage` bridge, + and the package is served via `tokenpluginfile.php` so its assets load without the + session cookie (which an opaque iframe never sends). The package only receives a + read-only file token — never the `sesskey` — so Secure is strictly safer than + Legacy. _Legacy_ keeps the previous same-origin behaviour as an opt-in fallback. + Secure mode is **never silently downgraded**: where it cannot render (e.g. a host + whose service worker can't serve an opaque iframe, such as a PHP-WASM playground), a + "blocked by security configuration" notice is shown instead of falling back to + Legacy. See + [DEC-0059](./research/decisiones/adr/DEC-0059-bridge-scorm-postmessage-origen-opaco.md) + and [DEC-0060](./research/decisiones/adr/DEC-0060-iframe-seguro-tokenpluginfile.md). + +### External embeds (YouTube/Vimeo/PDF) in Secure mode + +The opaque-origin sandbox also blanks **embedded YouTube/Vimeo players and PDFs** (the +sandbox flag propagates to the nested player iframe, and browsers block their PDF viewer +without `allow-same-origin`). So that authors can still use external media in Secure mode +**without a separate subdomain**, those embeds are **promoted to the Moodle page and +rendered inline**: a shim baked into the package (`js/exe_embed_shim.js`, self-activating +only in the opaque origin) replaces each whitelisted-video / `.pdf` iframe with a +placeholder and reports its geometry to the parent; a relay on the activity page +(`js/exe_embed_relay.js`) validates + rebuilds the canonical URL and overlays the real +player exactly over the placeholder. Local package PDFs always render; any `https` `.pdf` +renders; a same-origin `.pdf` must belong to the package (served as `application/pdf`). +This is independent of, and does not affect, the SCORM bridge. See +[DEC-0061](./research/decisiones/adr/DEC-0061-embeds-externos-promote-to-parent.md). + +The mechanism has unit tests (`npm run test:js` — Vitest, incl. a SCORM-coexistence +guard) and a cross-browser end-to-end test in Firefox (`npm run test:e2e:embed` — +Playwright, loads the real shim/relay against an opaque-origin harness). ## Embedded editor management @@ -191,10 +224,18 @@ attempts, and delete attempts from the teacher report (the grade is recalculated). Completion can require a passing grade (SCORM-style, see [DEC-0010](./research/decisiones/adr/DEC-0010-finalizacion-estilo-scorm.md)). -Grading runtime uses a SCORM 1.2 bridge: a small `window.API` shim installed by -`view.php` accepts `LMSSetValue` calls from the iDevice's bundled pipwerks -wrapper and forwards them to `track.php`, which calls Moodle's `grade_update()`. -xAPI support via `core_xapi` is on the roadmap. +Grading runtime uses a SCORM 1.2 bridge whose isolation depends on the **package +iframe security mode** +([DEC-0059](./research/decisiones/adr/DEC-0059-bridge-scorm-postmessage-origen-opaco.md)). +In the default **Secure** mode the package runs in an opaque-origin sandboxed iframe +served via `tokenpluginfile.php` (so its assets load without the session cookie): a +`window.API` shim lives _inside_ the iframe and posts buffered scores to the Moodle page +over a validated `postMessage` channel; the page (which holds the `sesskey`) forwards +them to `track.php`, which calls Moodle's `grade_update()`. In **Legacy** mode the shim +is installed by `view.php` in the same-origin parent and the iDevice's bundled pipwerks +wrapper reaches it directly. See +[DEC-0060](./research/decisiones/adr/DEC-0060-iframe-seguro-tokenpluginfile.md) for the +secure serving + CSP hardening. xAPI support via `core_xapi` is on the roadmap. ## Web services (Mobile API) diff --git a/assets/scorm/SCORM_API_wrapper.js b/assets/scorm/SCORM_API_wrapper.js index 6ea6c37..34134b0 100644 --- a/assets/scorm/SCORM_API_wrapper.js +++ b/assets/scorm/SCORM_API_wrapper.js @@ -133,12 +133,35 @@ pipwerks.SCORM.API.get = function () { find = pipwerks.SCORM.API.find, trace = pipwerks.UTILS.trace; - if (win.parent && win.parent != win) { - API = find(win.parent); + // Check the CURRENT window's frame hierarchy first (standard pipwerks order). In + // the secure (opaque-origin) package mode the SCORM API is provided locally by the + // in-iframe bridge shim (js/scorm_bridge_shim.js, DEC-0059) as window.API, and the + // Moodle parent is a cross-origin/opaque frame that throws SecurityError on access. + // Starting at win.parent (as the prior build did) made init() throw there and the + // connection never went active, so no score was ever saved in secure mode. find(win) + // returns the local API when present and otherwise walks up same-origin ancestors, + // which keeps the legacy same-origin mode (API hosted by the Moodle parent) working. + // Every cross-origin hop is wrapped so an opaque ancestor can never abort lookup. + try { + API = find(win); + } catch (e) { + trace("API.get: find(window) threw: " + e); + } + + if (!API && win.parent && win.parent != win) { + try { + API = find(win.parent); + } catch (e) { + trace("API.get: find(parent) blocked (cross-origin): " + e); + } } - if (!API && win.top.opener) { - API = find(win.top.opener); + try { + if (!API && win.top && win.top.opener) { + API = find(win.top.opener); + } + } catch (e) { + trace("API.get: find(opener) blocked: " + e); } if (API) { diff --git a/blueprint.json b/blueprint.json index 14623f7..a4e0750 100644 --- a/blueprint.json +++ b/blueprint.json @@ -1,6 +1,9 @@ { "$schema": "https://raw.githubusercontent.com/ateeducacion/moodle-playground/refs/heads/main/assets/blueprints/blueprint-schema.json", "preferredVersions": { "php": "8.3", "moodle": "5.0" }, + "phpConstants": { + "EXELEARNING_UNSAFE_LEGACY_IFRAME": true + }, "landingPage": "/course/view.php?id=2", "constants": { "REPO": "exelearning/mod_exelearning", @@ -39,6 +42,7 @@ }, { "step": "setConfigs", + "comment": "Playground-only display/debug config. The opaque-iframe escape hatch is enabled separately via the top-level phpConstants (EXELEARNING_UNSAFE_LEGACY_IFRAME): the Playground's php-wasm service worker only serves same-origin documents, so secure mode would only show the 'blocked by security configuration' notice. Real Moodle keeps the secure default and never defines that constant.", "configs": [ { "name": "editormode", "value": "embedded", "plugin": "exelearning" }, { "name": "display", "value": "1", "plugin": "exelearning" }, diff --git a/classes/admin/admin_setting_stylesbuiltins.php b/classes/admin/admin_setting_stylesbuiltins.php index 70584f7..ecb5f70 100644 --- a/classes/admin/admin_setting_stylesbuiltins.php +++ b/classes/admin/admin_setting_stylesbuiltins.php @@ -111,9 +111,10 @@ public function output_html($data, $query = '') { : get_string('stylesenable', 'mod_exelearning'); $toggleaction = $isenabled ? 'disablebuiltin' : 'enablebuiltin'; - $togglelink = $this->action_link( + $togglelink = styles_action_button::link( $baseurl, $toggleaction, + 'id', $id, $togglelabel, $isenabled ? 'btn-secondary' : 'btn-success' @@ -145,33 +146,4 @@ public function output_html($data, $query = '') { $query ); } - - /** - * Build a single toggle action as a sesskey-protected link styled as a button. - * - * Rendered as a link rather than an inline
: this setting is shown inside the - * admin settings page, which already wraps every setting in one . A nested - * is invalid HTML and leaks its own action/sesskey hidden fields into the - * outer form's submission, so the page's "Save changes" posts action=disablebuiltin - * instead of action=save-settings and silently saves nothing. styles.php accepts the - * toggle over GET (optional_param + confirm_sesskey), the same pattern Moodle core - * uses to enable/disable plugins. - * - * @param \moodle_url $baseurl - * @param string $action - * @param string $id - * @param string $label - * @param string $btnclass - * @return string - */ - private function action_link( - \moodle_url $baseurl, - string $action, - string $id, - string $label, - string $btnclass - ): string { - $url = new \moodle_url($baseurl, ['action' => $action, 'id' => $id, 'sesskey' => sesskey()]); - return \html_writer::link($url, $label, ['class' => 'btn btn-sm ' . $btnclass, 'role' => 'button']); - } } diff --git a/classes/admin/admin_setting_stylesuploaded.php b/classes/admin/admin_setting_stylesuploaded.php index e98f495..12aa068 100644 --- a/classes/admin/admin_setting_stylesuploaded.php +++ b/classes/admin/admin_setting_stylesuploaded.php @@ -110,16 +110,18 @@ public function output_html($data, $query = '') { : get_string('stylesenable', 'mod_exelearning'); $toggleaction = $enabled ? 'disable' : 'enable'; - $togglelink = $this->action_link( + $togglelink = styles_action_button::link( $baseurl, $toggleaction, + 'slug', $slug, $togglelabel, $enabled ? 'btn-secondary' : 'btn-success' ); - $deletelink = $this->action_link( + $deletelink = styles_action_button::link( $baseurl, 'delete', + 'slug', $slug, get_string('stylesdelete', 'mod_exelearning'), 'btn-danger' @@ -152,33 +154,4 @@ public function output_html($data, $query = '') { $query ); } - - /** - * Build a single action as a sesskey-protected link styled as a button. - * - * Rendered as a link rather than an inline : this setting appears inside the - * admin settings page, which already wraps every setting in one . A nested - * is invalid HTML and leaks its action/sesskey hidden fields into the outer - * form's submission, so the page's "Save changes" posts the nested action (e.g. - * delete) instead of action=save-settings and silently saves nothing. styles.php - * accepts these actions over GET (optional_param + confirm_sesskey); the destructive - * delete is confirmed server-side there, so a prefetch cannot destroy data. - * - * @param \moodle_url $baseurl - * @param string $action - * @param string $slug - * @param string $label - * @param string $btnclass - * @return string - */ - private function action_link( - \moodle_url $baseurl, - string $action, - string $slug, - string $label, - string $btnclass - ): string { - $url = new \moodle_url($baseurl, ['action' => $action, 'slug' => $slug, 'sesskey' => sesskey()]); - return \html_writer::link($url, $label, ['class' => 'btn btn-sm ' . $btnclass, 'role' => 'button']); - } } diff --git a/classes/admin/styles_action_button.php b/classes/admin/styles_action_button.php new file mode 100644 index 0000000..5a6c5a3 --- /dev/null +++ b/classes/admin/styles_action_button.php @@ -0,0 +1,63 @@ +. + +/** + * Shared renderer for the styles-management admin action buttons. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exelearning\admin; + +/** + * Builds a styles-management action (enable/disable/delete) as a sesskey-protected link + * styled as a button. + * + * Shared by admin_setting_stylesbuiltins and admin_setting_stylesuploaded, which render + * the action as a link rather than an inline : these settings appear inside the + * admin settings page, which already wraps every setting in one . A nested + * is invalid HTML and leaks its action/sesskey hidden fields into the outer form's + * submission, so the page's "Save changes" posts the nested action (e.g. delete) instead + * of action=save-settings and silently saves nothing. styles.php accepts these actions + * over GET (optional_param + confirm_sesskey); the destructive delete is confirmed + * server-side there, so a prefetch cannot destroy data. + */ +final class styles_action_button { + /** + * Build a single action as a sesskey-protected link styled as a button. + * + * @param \moodle_url $baseurl The styles.php base URL. + * @param string $action The action (enable/disable/enablebuiltin/disablebuiltin/delete). + * @param string $idkey The query parameter name carrying the identifier ('id' or 'slug'). + * @param string $idval The identifier value. + * @param string $label The button label. + * @param string $btnclass The Bootstrap button modifier class (e.g. 'btn-danger'). + * @return string + */ + public static function link( + \moodle_url $baseurl, + string $action, + string $idkey, + string $idval, + string $label, + string $btnclass + ): string { + $url = new \moodle_url($baseurl, ['action' => $action, $idkey => $idval, 'sesskey' => sesskey()]); + return \html_writer::link($url, $label, ['class' => 'btn btn-sm ' . $btnclass, 'role' => 'button']); + } +} diff --git a/classes/local/package_manager.php b/classes/local/package_manager.php index c084dff..93fe75e 100644 --- a/classes/local/package_manager.php +++ b/classes/local/package_manager.php @@ -240,25 +240,42 @@ public static function extract_stored(int $contextid, int $revision): void { 1 ); - // 5) If the package (web export) does not include libs/SCORM_API_wrapper.js, - // inject it from the plugin's assets/ directory. eXeLearning v4 only bundles - // this wrapper in the SCORM export; without it, gradable iDevices display - // "this page is not part of a SCORM package". - foreach (['SCORM_API_wrapper.js', 'SCOFunctions.js'] as $shimname) { + // 5) Ensure the SCORM client assets live under libs/ of the extracted package. + // The vendored pipwerks wrapper + SCOFunctions are copied only when the export + // does not already bundle them: eXeLearning v4 bundles them in the SCORM export + // but not in the web/elpx export, and without them gradable iDevices show "this + // page is not part of a SCORM package". A bundled copy is never overwritten + // ($refresh = false). The bridge client (scorm_tracker + exe_scorm_bridge) powers + // the secure opaque-origin iframe mode (DEC-0059); it runs INSIDE the iframe so it + // must be served from the package, and being plugin-owned it is refreshed on every + // extract so a shim update reaches existing packages ($refresh = true). + $clientassets = [ + ['SCORM_API_wrapper.js', __DIR__ . '/../../assets/scorm/SCORM_API_wrapper.js', false], + ['SCOFunctions.js', __DIR__ . '/../../assets/scorm/SCOFunctions.js', false], + ['scorm_tracker.js', __DIR__ . '/../../js/scorm_tracker.js', true], + ['exe_scorm_bridge.js', __DIR__ . '/../../js/scorm_bridge_shim.js', true], + // External-embed shim: promotes whitelisted/PDF iframes to the parent in + // secure mode (dormant otherwise). Plugin-owned, refreshed on every extract. + ['exe_embed_shim.js', __DIR__ . '/../../js/exe_embed_shim.js', true], + ]; + foreach ($clientassets as $asset) { + [$destname, $assetpath, $refresh] = $asset; + if (!is_file($assetpath)) { + continue; + } $present = $fs->get_file( $context->id, 'mod_exelearning', 'content', (int) $data->revision, '/libs/', - $shimname + $destname ); if ($present) { - continue; - } - $assetpath = __DIR__ . '/../../assets/scorm/' . $shimname; - if (!is_file($assetpath)) { - continue; + if (!$refresh) { + continue; + } + $present->delete(); } $fs->create_file_from_pathname([ 'contextid' => $context->id, @@ -266,7 +283,7 @@ public static function extract_stored(int $contextid, int $revision): void { 'filearea' => 'content', 'itemid' => (int) $data->revision, 'filepath' => '/libs/', - 'filename' => $shimname, + 'filename' => $destname, ], $assetpath); } diff --git a/classes/local/preview/fixed_resources.php b/classes/local/preview/fixed_resources.php new file mode 100644 index 0000000..1faed16 --- /dev/null +++ b/classes/local/preview/fixed_resources.php @@ -0,0 +1,198 @@ +. + +namespace mod_exelearning\local\preview; + +use mod_exelearning\local\editor_paths; +use mod_exelearning\local\embedded_editor_source_resolver; + +/** + * Fixed-resource resolver for the editor preview (serving contract v2, layer 1). + * + * The eXeLearning build emits bundles/preview-fixed-resources.json inside the + * static editor distribution (the same distribution this plugin installs into + * moodledata or ships bundled). It enumerates every installation-immutable file + * the preview serving route may resolve OUTSIDE a session — libraries, base + * iDevice runtimes, base themes, PDF.js, the content CSS, the logo, global + * fonts. This class is the ONLY path from a fixedResourceId to the filesystem: + * + * - ids resolve by EXACT map lookup — client input never becomes a path; + * - only resources[id].path (server-controlled build output) reaches the + * filesystem, resolved under the distribution root with a strict containment + * check (which also rejects symlink escapes via realpath()); + * - unknown ids are a lookup miss, never a filesystem probe. + * + * When the manifest is absent (older editor build, or none installed) the fixed + * layer is DISABLED gracefully: has_resource() is always false, so a revision + * carrying fixedRefs gets a 422 unknown-fixed-resources and the editor client + * demotes those paths to document writes and retries. The route never fatals. + * + * The distribution root and manifest path are resolved lazily and cached; + * {@see self::configure()} (tests, or a relocated distribution) resets the cache. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fixed_resources { + /** @var string|null Distribution-root override (tests / relocated install). */ + private static $rootoverride = null; + + /** @var string|null Manifest-path override (tests / relocated install). */ + private static $manifestoverride = null; + + /** @var array|null Lazily loaded manifest cache. */ + private static $cache = null; + + /** + * Point the resolver at an explicit distribution root and manifest path. + * + * @param string|null $root Distribution root every manifest path resolves under. + * @param string|null $manifestpath Absolute path to preview-fixed-resources.json. + */ + public static function configure(?string $root, ?string $manifestpath): void { + self::$rootoverride = $root; + self::$manifestoverride = $manifestpath; + self::$cache = null; + } + + /** + * Restore the default (installed static editor) resolution and clear the cache. + */ + public static function reset(): void { + self::$rootoverride = null; + self::$manifestoverride = null; + self::$cache = null; + } + + /** + * The distribution root: the active installed static editor directory, or + * the test override. Null when no editor is installed. + * + * @return string|null + */ + private static function dist_root(): ?string { + if (self::$rootoverride !== null) { + return self::$rootoverride; + } + return embedded_editor_source_resolver::get_active_dir(); + } + + /** + * Absolute path to the fixed-resource manifest, or null when unresolvable. + * + * @return string|null + */ + private static function manifest_path(): ?string { + if (self::$manifestoverride !== null) { + return self::$manifestoverride; + } + $root = self::dist_root(); + if ($root === null) { + return null; + } + return $root . '/bundles/preview-fixed-resources.json'; + } + + /** + * Load and validate the manifest once. A missing or invalid manifest resolves + * to an empty map (the fixed layer is disabled but the route keeps serving). + * + * @return array + */ + private static function load(): array { + if (self::$cache !== null) { + return self::$cache; + } + $map = []; + $manifestpath = self::manifest_path(); + if ($manifestpath !== null && is_file($manifestpath)) { + $json = @file_get_contents($manifestpath); + $parsed = ($json !== false) ? json_decode($json, true) : null; + $valid = is_array($parsed) + && ($parsed['schemaVersion'] ?? null) === 1 + && isset($parsed['resources']) + && is_array($parsed['resources']); + if ($valid) { + foreach ($parsed['resources'] as $id => $entry) { + if (is_array($entry) && isset($entry['path']) && is_string($entry['path'])) { + $map[(string) $id] = ['path' => $entry['path'], 'size' => (int) ($entry['size'] ?? 0)]; + } + } + } + } + self::$cache = $map; + return $map; + } + + /** + * Exact-id membership test (the apply_revision validation hook). + * + * @param string $id Fixed resource id. + * @return bool True when the manifest lists this id. + */ + public static function has_resource(string $id): bool { + return array_key_exists($id, self::load()); + } + + /** + * All ids the manifest declares (diagnostics / conformance tooling). + * + * @return string[] + */ + public static function get_manifest_ids(): array { + return array_keys(self::load()); + } + + /** + * Resolve a fixed resource id to its bytes. Returns null for unknown ids, + * manifest paths escaping the distribution root (path arithmetic or symlink + * escape, caught by realpath + containment), and missing files. The only + * client-supplied value here is the exact-match id; the path is manifest data. + * + * @param string $id Fixed resource id. + * @return \stdClass|null Object with {bytes:string, size:int}, or null. + */ + public static function get_resource(string $id): ?\stdClass { + $map = self::load(); + if (!isset($map[$id])) { + return null; + } + $root = self::dist_root(); + if ($root === null) { + return null; + } + $realroot = realpath($root); + if ($realroot === false) { + return null; + } + // The realpath() call collapses '..' and follows symlinks; the containment check + // then rejects anything (absolute paths, traversal, symlink escapes) + // that resolved outside the distribution root. + $resolved = realpath($realroot . '/' . $map[$id]['path']); + if ($resolved === false || !editor_paths::is_within($resolved, $realroot)) { + return null; + } + $bytes = @file_get_contents($resolved); + if ($bytes === false) { + return null; + } + $result = new \stdClass(); + $result->bytes = $bytes; + $result->size = strlen($bytes); + return $result; + } +} diff --git a/classes/local/preview/preview_session.php b/classes/local/preview/preview_session.php new file mode 100644 index 0000000..aec96ea --- /dev/null +++ b/classes/local/preview/preview_session.php @@ -0,0 +1,229 @@ +. + +namespace mod_exelearning\local\preview; + +/** + * A single preview session handle, minted by {@see session_store}. + * + * Wraps the on-disk session directory and its ACTIVE revision, and resolves a + * requested path across the three contract layers (documents → assetRefs→assets + * → fixedRefs→fixed-manifest → miss) through {@see self::get_file()}. The active + * revision is read once when the handle is constructed (the store passes the + * pointer value), and revision directories are immutable once published, so a + * handle serves one consistent revision for its whole lifetime — no request ever + * mixes revision N and N+1. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class preview_session { + /** @var string The previewId (server-minted UUID). */ + public $previewid; + + /** @var int The owning user's id. */ + public $owneruserid; + + /** @var int Active revision number; 0 until the first revision publishes. */ + public $revision; + + /** @var string Absolute path to the session directory. */ + private $dir; + + /** @var int Session-asset bytes (from meta). */ + private $assetbytes; + + /** @var int Active-revision document bytes (from meta). */ + private $documentbytes; + + /** @var array{documents:array,assetrefs:array,fixedrefs:array}|null */ + private $manifestcache = null; + + /** @var array|null Asset index cache (assetKey → size). */ + private $assetindexcache = null; + + /** + * Build a handle bound to a session directory and its active revision. + * + * @param string $previewid + * @param int $owneruserid + * @param string $dir Absolute session directory path. + * @param int $revision Active revision number. + * @param int $assetbytes Session-asset bytes. + * @param int $documentbytes Active-revision document bytes. + */ + public function __construct( + string $previewid, + int $owneruserid, + string $dir, + int $revision, + int $assetbytes, + int $documentbytes + ) { + $this->previewid = $previewid; + $this->owneruserid = $owneruserid; + $this->dir = $dir; + $this->revision = $revision; + $this->assetbytes = $assetbytes; + $this->documentbytes = $documentbytes; + } + + /** + * Absolute path to this session's directory. + * + * @return string + */ + public function dir(): string { + return $this->dir; + } + + /** + * Session-asset bytes plus active-revision document bytes (the value the + * per-session byte budget is measured against). + * + * @return int + */ + public function session_bytes(): int { + return $this->assetbytes + $this->documentbytes; + } + + /** + * Session-asset bytes only (assets persist across revisions). + * + * @return int + */ + public function asset_bytes(): int { + return $this->assetbytes; + } + + /** + * Resolve a served path against the active revision through the three + * contract layers. Unsafe paths and a session without a published revision + * (revision 0) resolve to null for everything. + * + * @param string $rawpath The requested path (below the capability prefix). + * @return \stdClass|null {kind, bytes, contenttype, scriptable, etag} or null. + */ + public function get_file(string $rawpath): ?\stdClass { + if ($this->revision === 0) { + return null; + } + $path = serving::normalize_content_path($rawpath); + if ($path === null) { + return null; + } + $manifest = $this->load_manifest(); + $contenttype = serving::content_type_for($path); + $scriptable = serving::is_scriptable($contenttype); + + // Layer 3: generated documents. + if (array_key_exists($path, $manifest['documents'])) { + $bytes = @file_get_contents($this->dir . '/revisions/' . $this->revision . '/' + . session_store::doc_basename($path)); + if ($bytes !== false) { + return $this->file('document', $bytes, $contenttype, $scriptable, null); + } + return null; + } + + // Layer 2: session project assets, addressed by assetKey. + if (isset($manifest['assetrefs'][$path])) { + $key = $manifest['assetrefs'][$path]; + $index = $this->load_asset_index(); + if (array_key_exists($key, $index)) { + $bytes = @file_get_contents($this->dir . '/assets/' . session_store::asset_basename($key)); + if ($bytes !== false) { + return $this->file('asset', $bytes, $contenttype, $scriptable, $key); + } + } + return null; + } + + // Layer 1: fixed installation resources, addressed by fixedResourceId. + if (isset($manifest['fixedrefs'][$path])) { + $resource = fixed_resources::get_resource($manifest['fixedrefs'][$path]); + if ($resource !== null) { + return $this->file('fixed', $resource->bytes, $contenttype, $scriptable, null); + } + } + + return null; + } + + /** + * Build a resolved-file value object. + * + * @param string $kind document|asset|fixed + * @param string $bytes + * @param string $contenttype + * @param bool $scriptable + * @param string|null $etag + * @return \stdClass + */ + private function file(string $kind, string $bytes, string $contenttype, bool $scriptable, ?string $etag): \stdClass { + $file = new \stdClass(); + $file->kind = $kind; + $file->bytes = $bytes; + $file->contenttype = $contenttype; + $file->scriptable = $scriptable; + $file->etag = $etag; + return $file; + } + + /** + * Read the active revision's manifest (documents + both ref maps) once. + * + * @return array{documents:array,assetrefs:array,fixedrefs:array} + */ + private function load_manifest(): array { + if ($this->manifestcache !== null) { + return $this->manifestcache; + } + $empty = ['documents' => [], 'assetrefs' => [], 'fixedrefs' => []]; + $json = @file_get_contents($this->dir . '/revisions/' . $this->revision . '/revision.json'); + if ($json === false) { + $this->manifestcache = $empty; + return $empty; + } + $parsed = json_decode($json, true); + if (!is_array($parsed)) { + $this->manifestcache = $empty; + return $empty; + } + $this->manifestcache = [ + 'documents' => is_array($parsed['documents'] ?? null) ? $parsed['documents'] : [], + 'assetrefs' => is_array($parsed['assetrefs'] ?? null) ? $parsed['assetrefs'] : [], + 'fixedrefs' => is_array($parsed['fixedrefs'] ?? null) ? $parsed['fixedrefs'] : [], + ]; + return $this->manifestcache; + } + + /** + * Read the session asset index (assetKey → size) once. + * + * @return array + */ + private function load_asset_index(): array { + if ($this->assetindexcache !== null) { + return $this->assetindexcache; + } + $json = @file_get_contents($this->dir . '/assets/index.json'); + $parsed = ($json !== false) ? json_decode($json, true) : null; + $this->assetindexcache = is_array($parsed) ? $parsed : []; + return $this->assetindexcache; + } +} diff --git a/classes/local/preview/serving.php b/classes/local/preview/serving.php new file mode 100644 index 0000000..bedf0a7 --- /dev/null +++ b/classes/local/preview/serving.php @@ -0,0 +1,824 @@ +. + +namespace mod_exelearning\local\preview; + +/** + * Protocol + response layer for the editor preview serving contract v2. + * + * This class is the Moodle mirror of eXeLearning core's + * src/services/preview-serving.ts. It owns everything that must stay + * byte-identical to core so the two can never drift: + * + * - the sandbox-first Content-Security-Policy ({@see self::csp_header()}, + * byte-identical to previewCspHeader()); + * - traversal-safe path normalization and MIME/charset resolution + * ({@see self::normalize_content_path()}, {@see self::content_type_for()}); + * - the tiered serving response (documents no-store / assets no-cache + ETag + + * Range / fixed private,max-age=31536000; the sandbox CSP on every scriptable + * type from ANY layer; hardening headers on every response including 404s); + * - the management-endpoint helpers (create-session body, two-stage asset + * upload, revision publication) the authenticated endpoint delegates to. + * + * The two thin HTTP adapters — preview.php (serving) and + * editor/preview_session.php (management) — carry no protocol logic of their + * own; they parse the request, call the helpers here, and emit the result. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class serving { + /** @var int Contract protocol version advertised by create-session. */ + const PROTOCOL_VERSION = 2; + + /** @var int Advertised upper bound (bytes) for one upload request; clients batch below it. */ + const RECOMMENDED_BATCH_BYTES = 67108864; + + /** + * previewId capability shape (server-minted UUID). Case-insensitive to match + * core UUID_RE; \core\uuid::generate() emits lowercase. + * + * @var string + */ + const UUID_RE = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; + + /** + * Content-map MIME table, byte-identical to core src/utils/mime-types.ts. + * + * @var array + */ + const MIME_TYPES = [ + 'js' => 'application/javascript', + 'mjs' => 'application/javascript', + 'css' => 'text/css', + 'json' => 'application/json', + 'html' => 'text/html', + 'htm' => 'text/html', + 'xml' => 'application/xml', + 'txt' => 'text/plain', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'webp' => 'image/webp', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'ogg' => 'audio/ogg', + 'pdf' => 'application/pdf', + 'zip' => 'application/zip', + ]; + + /** + * The sandbox-first preview CSP. MUST stay byte-identical to eXe core + * previewCspHeader() (src/shared/security/previewSandbox.ts): a single line, + * directives joined by "; ", no trailing ";". The sandbox tokens are hardcoded + * to allow-scripts allow-popups allow-forms (== PREVIEW_SANDBOX): the preview + * is ALWAYS opaque and must NOT inherit the published-content escape hatch + * (player_iframe::sandbox_tokens() can add allow-same-origin under the dev-only + * legacy hatch, which would defeat opacity — never reuse it here). + * + * @return string + */ + public static function csp_header(): string { + return implode('; ', [ + 'sandbox allow-scripts allow-popups allow-forms', + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "media-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com", + "child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com", + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'self'", + ]); + } + + /** + * Permissions-Policy value for every preview response (== previewPermissionsPolicy(); + * the 4-feature preview value, NOT the published-content superset). + * + * @return string + */ + public static function permissions_policy(): string { + return implode(', ', ['camera=()', 'microphone=()', 'geolocation=()', 'payment=()']); + } + + /** + * Hardening headers applied to EVERY serving response, 404s included. + * Cache-Control is NOT here — it is tiered per resolution layer by the caller. + * Access-Control-Allow-Origin: * is safe on this authless, cookieless route + * (never pair it with Access-Control-Allow-Credentials). + * + * @return array + */ + public static function base_headers(): array { + return [ + 'X-Content-Type-Options' => 'nosniff', + 'Referrer-Policy' => 'no-referrer', + 'Permissions-Policy' => self::permissions_policy(), + 'Access-Control-Allow-Origin' => '*', + ]; + } + + /** + * Scriptable document types that MUST carry the sandbox CSP. Not just + * text/html: an author-supplied image/svg+xml (or XML) runs its inline + * \n"; - $tags = $marker . - "\n " . - "\n " . - $initscript; - $tagshtml = $marker . - "\n " . - "\n " . - $initscript; + $embedmarker = ''; // Iterate over all HTML files in the filearea. $files = $fs->get_area_files( @@ -84,14 +86,55 @@ public static function inject(int $contextid, int $revision): void { continue; } $html = $file->get_content(); - if ($html === '' || strpos($html, $marker) !== false) { + if ($html === '') { continue; } + // Relative prefix to the package's libs/ dir: root pages use 'libs/', nested + // html/.html pages climb one level with '../libs/'. $path = $file->get_filepath(); - $payload = ($path === '/') ? $tags : $tagshtml; - // Insert just before (case-insensitive). - $newhtml = preg_replace('~~i', $payload . '', $html, 1); - if ($newhtml === null || $newhtml === $html) { + $libs = ($path === '/') ? 'libs/' : '../libs/'; + $newhtml = $html; + $changed = false; + + // Script payloads, built once per file from $libs. The bridge client + // (scorm_tracker.js before exe_scorm_bridge.js: the shim calls + // window.exeScormTracker.createScormApi) and the external-embed shim both go + // at the TOP of ; the pipwerks SCORM wrapper + init kick go just before + // . No host list is baked for the embed shim: it promotes any candidate + // and the parent relay is the authoritative gate (open vs strict, DEC-0061). + $bridge = $bridgemarker . + "\n " . + "\n \n"; + $embed = $embedmarker . + "\n \n"; + $scorm = $marker . + "\n " . + "\n " . + $initscript; + + // Idempotent insertions, applied in order so each one matches against + // the HTML already modified by the previous (e.g. the embed insert sees the + // bridge-modified ). Each fires at most once, guarded by its own marker. + // Entry = [marker, payload, anchor regex, top?]: top appends the payload AFTER + // the matched tag, otherwise it is prepended BEFORE the matched . + $inserts = [ + [$bridgemarker, $bridge, '~]*>~i', true], + [$embedmarker, $embed, '~]*>~i', true], + [$marker, $scorm, '~~i', false], + ]; + foreach ($inserts as [$mk, $payload, $regex, $top]) { + if (strpos($newhtml, $mk) !== false) { + continue; + } + $replacement = $top ? '$0' . $payload : $payload . '$0'; + $replaced = preg_replace($regex, $replacement, $newhtml, 1); + if ($replaced !== null && $replaced !== $newhtml) { + $newhtml = $replaced; + $changed = true; + } + } + + if (!$changed) { continue; } // Replace content in the filearea: delete and recreate. diff --git a/classes/local/ui/player_iframe.php b/classes/local/ui/player_iframe.php new file mode 100644 index 0000000..68eda23 --- /dev/null +++ b/classes/local/ui/player_iframe.php @@ -0,0 +1,294 @@ +. + +/** + * Package iframe security mode and sandbox policy (DEC-0059). + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exelearning\local\ui; + +/** + * Centralises the package iframe sandbox policy, CSP and headers (DEC-0059). + * + * The arbitrary author HTML/JS of an `.elpx` package is always embedded in view.php in a + * sandboxed, opaque-origin iframe: the iframe drops allow-same-origin, so the package runs + * in an opaque origin and cannot read or modify Moodle's DOM, cookies or session. SCORM + * scoring is relayed to the parent over a validated postMessage bridge + * (js/scorm_bridge_shim.js in the iframe, js/scorm_bridge_relay.js in the parent), and the + * parent keeps the sesskey and performs the track.php request. The historical same-origin + * "legacy" mode is no longer a production setting; it survives only as a dev-only escape hatch + * (the EXELEARNING_UNSAFE_LEGACY_IFRAME constant) for environments whose service worker cannot + * serve an opaque subframe (the php-wasm Moodle Playground), and defaults off. + * + * Centralised here so the policy is unit-testable without rendering view.php. + * See research ADR DEC-0059 (advances the Tier 2 roadmap of DEC-0019). + */ +final class player_iframe { + /** @var string Secure mode: opaque-origin iframe + postMessage SCORM bridge (the production mode). */ + public const MODE_SECURE = 'secure'; + + /** + * Legacy mode: same-origin iframe. NOT a production mode — reachable only through the dev-only + * EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch, for environments whose service worker cannot + * serve an opaque subframe (the php-wasm Moodle Playground). + * + * @var string + */ + public const MODE_LEGACY = 'legacy'; + + /** @var string Open embeds: promote any cross-origin https iframe (DEC-0061). */ + public const EMBED_OPEN = 'open'; + + /** @var string Strict embeds: only the maintained host allowlist (the default). */ + public const EMBED_STRICT = 'strict'; + + /** @var string Strict CSP profile (default): no bare https: token-exfiltration channels. */ + public const CSP_STRICT = 'strict'; + + /** @var string Compatible CSP profile: allows https: img/media/script for external assets. */ + public const CSP_COMPATIBLE = 'compatible'; + + /** + * Default host whitelist for external video embeds promoted to the parent page. + * + * In secure mode the package is opaque, so cross-origin players (YouTube/Vimeo) + * load blank. Iframes whose src host is on this list are replaced by a placeholder + * in the package (js/exe_embed_shim.js) and rendered as a real player by the parent + * (js/exe_embed_relay.js). PDFs are handled separately (by .pdf extension) and need + * no host entry. + * + * @var string[] + */ + public const DEFAULT_EMBED_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', + ]; + + /** + * The package iframe mode. Secure (opaque origin) in production; the same-origin legacy mode is + * reachable only through the dev-only EXELEARNING_UNSAFE_LEGACY_IFRAME escape hatch, which + * defaults off and is never exposed as a Moodle setting. + * + * @return string self::MODE_SECURE, or self::MODE_LEGACY when the escape hatch is enabled. + */ + public static function resolve_mode(): string { + return self::is_unsafe_legacy() ? self::MODE_LEGACY : self::MODE_SECURE; + } + + /** + * Whether the dev-only same-origin escape hatch is enabled. Never a Moodle setting: it is the + * EXELEARNING_UNSAFE_LEGACY_IFRAME PHP constant (defined in config.php) or the env var of the + * same name, intended only for environments whose service worker cannot serve an opaque + * subframe (the php-wasm Moodle Playground). Defaults off. + * + * @return bool True when the constant or env var is set and truthy. + */ + public static function is_unsafe_legacy(): bool { + if (defined('EXELEARNING_UNSAFE_LEGACY_IFRAME')) { + return (bool) constant('EXELEARNING_UNSAFE_LEGACY_IFRAME'); + } + $env = getenv('EXELEARNING_UNSAFE_LEGACY_IFRAME'); + return $env !== false && filter_var($env, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Whether the configured mode isolates the package (opaque origin + bridge). + * + * @return bool True in secure mode. + */ + public static function is_secure(): bool { + return self::resolve_mode() === self::MODE_SECURE; + } + + /** + * Sandbox token list for the (always opaque) package iframe. + * + * Deliberately OMITS allow-same-origin (forcing an opaque origin so the package cannot + * reach Moodle's DOM/cookies/session), allow-top-navigation (a package must never change + * the parent URL), allow-modals (alert/confirm/prompt UX traps) and + * allow-popups-to-escape-sandbox (an escaped popup would reopen at Moodle's real origin + * without the sandbox). allow-scripts/allow-popups/allow-forms are kept because + * eXeLearning v4 iDevices need jQuery + scripts, popups (interactive-video, hidden-image) + * and forms (quick-questions, form, scrambled-list). See ADR DEC-0059 / DEC-0019 / AN-008. + * + * @return string Space-separated sandbox token list. + */ + public static function sandbox_tokens(): string { + if (!self::is_secure()) { + // Dev-only escape hatch: same-origin so a service worker that only serves same-origin + // documents (the php-wasm Playground) can load the package CSS/JS. Never used in production. + return 'allow-same-origin allow-scripts allow-popups allow-forms'; + } + return 'allow-scripts allow-popups allow-forms'; + } + + /** + * Resolve the external-embed policy (DEC-0061). Default 'strict' restricts promotion to + * the maintained provider allowlist with canonical URL reconstruction; 'open' is an + * explicit opt-in that promotes any cross-origin https iframe (the player is sandboxed + + * cross-origin, so SOP isolates it from Moodle). Any unset or unrecognised value fails + * safe to 'strict' (toward the more restrictive policy). + * + * @return string self::EMBED_OPEN or self::EMBED_STRICT. + */ + public static function embed_mode(): string { + $value = get_config('mod_exelearning', 'embedmode'); + return ($value === self::EMBED_OPEN) ? self::EMBED_OPEN : self::EMBED_STRICT; + } + + /** + * Resolve the content CSP profile. 'strict' (default) blocks bare https: exfiltration + * channels (the per-user file token lives in the URL, so open img/media/script would let + * author JS leak it). 'compatible' re-opens img/media/script to https: for content that + * loads external author assets (third-party images, a MathJax CDN) — documented weaker. + * Any unset or unrecognised value fails safe to 'strict'. + * + * @return string self::CSP_STRICT or self::CSP_COMPATIBLE. + */ + public static function csp_profile(): string { + $value = get_config('mod_exelearning', 'cspprofile'); + return ($value === self::CSP_COMPATIBLE) ? self::CSP_COMPATIBLE : self::CSP_STRICT; + } + + /** + * Normalized host whitelist for external video embeds (lowercase, de-duplicated). + * Only consulted by the relay in 'strict' mode. + * + * @return string[] + */ + public static function embed_whitelist(): array { + $clean = []; + foreach (self::DEFAULT_EMBED_HOSTS as $host) { + $host = strtolower(trim((string) $host)); + if ($host !== '') { + $clean[$host] = true; + } + } + return array_keys($clean); + } + + /** + * Permissions-Policy header value for the embedded package (DEC-0060). + * + * Denies hardware/sensor features the package never needs. `fullscreen` is + * intentionally NOT denied: the iframe grants it via its allow= attribute and + * iDevices use it. Emitted by exelearning_pluginfile() in secure mode. + * + * @return string The Permissions-Policy header value. + */ + public static function permissions_policy(): string { + return 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), ' + . 'bluetooth=(), hid=(), magnetometer=(), accelerometer=(), gyroscope=(), ' + . 'midi=(), display-capture=()'; + } + + /** + * Content-Security-Policy header value for the embedded package (DEC-0060). + * + * Strict (default): object-src/base-uri closed, frame-ancestors 'self', connect-src + * limited to this site, and NO bare https: in script/img/media-src so the per-user file + * token in the URL cannot be exfiltrated (e.g. via new Image().src). frame-src is limited + * to the maintained providers. Compatible re-opens img/media/script (and frame-src) to + * https: for content with external author images or a MathJax CDN — documented weaker. + * The CSP-level `sandbox` keeps the document opaque even if the token URL is opened + * outside the iframe (a new tab), mirroring the iframe sandbox tokens. + * + * @param string $siteorigin The scheme://host[:port] origin of this Moodle site. + * @param string|null $profile self::CSP_STRICT/CSP_COMPATIBLE, or null to use csp_profile(). + * @return string The Content-Security-Policy header value. + */ + public static function content_security_policy(string $siteorigin, ?string $profile = null): string { + $profile = ($profile === self::CSP_COMPATIBLE || $profile === self::CSP_STRICT) + ? $profile + : self::csp_profile(); + if ($profile === self::CSP_COMPATIBLE) { + $scriptsrc = "script-src 'self' $siteorigin 'unsafe-inline' 'unsafe-eval' https:; "; + $imgsrc = "img-src 'self' $siteorigin data: blob: https:; "; + $mediasrc = "media-src 'self' $siteorigin data: blob: https:; "; + $framesrc = "frame-src 'self' $siteorigin https:; "; + } else { + $providers = 'https://www.youtube-nocookie.com https://player.vimeo.com ' + . 'https://www.dailymotion.com https://mediateca.educa.madrid.org'; + $scriptsrc = "script-src 'self' $siteorigin 'unsafe-inline' 'unsafe-eval'; "; + $imgsrc = "img-src 'self' $siteorigin data: blob:; "; + $mediasrc = "media-src 'self' $siteorigin data: blob:; "; + $framesrc = "frame-src 'self' $siteorigin $providers; "; + } + $policy = "default-src 'self' $siteorigin; " + . $scriptsrc + . "style-src 'self' $siteorigin 'unsafe-inline'; " + . $imgsrc + . $mediasrc + . "font-src 'self' $siteorigin data:; " + . "connect-src 'self' $siteorigin; " + . $framesrc + . "object-src 'none'; base-uri 'none'; form-action 'self' $siteorigin; " + . "frame-ancestors 'self'"; + // The CSP-level `sandbox` keeps the document opaque even if its URL is opened outside the + // iframe. Omit it under the dev-only legacy escape hatch (the php-wasm Playground), which + // needs same-origin rendering; the iframe sandbox attribute is relaxed in lockstep. + if (self::is_secure()) { + $policy .= "; sandbox allow-scripts allow-popups allow-forms"; + } + return $policy; + } + + /** + * Defense-in-depth response headers for a served package file (DEC-0060). + * + * In secure mode EVERY served file gets Referrer-Policy: no-referrer and + * X-Content-Type-Options: nosniff. The per-user file token lives in the URL path, so + * even a CSS/JS subresource that pulls a cross-origin image must not leak it via the + * Referer header; and nosniff forces each file to be interpreted by its declared + * Content-Type, so a package cannot smuggle executable HTML behind, e.g., a .pdf path + * (the promoted PDF player is unsandboxed). The document-level Content-Security-Policy + * and Permissions-Policy are added only for an HTML document (subresources ignore + * them). The caller (exelearning_pluginfile) is just a header-emitting loop, since the + * package always renders opaque. Keeping the decision and the values here makes them + * unit-testable (the pluginfile callback that emits them exits via send_stored_file + * and cannot be unit-tested directly). + * + * @param string $filename The served file name (only *.html(?) get CSP/Permissions-Policy). + * @param string $wwwroot This Moodle site's $CFG->wwwroot (origin is derived from it). + * @return array Map of header name => value (empty when no headers apply). + */ + public static function content_headers(string $filename, string $wwwroot): array { + // Apply to every served package file (the token rides in the URL for all of them). + $headers = [ + 'Referrer-Policy' => 'no-referrer', + 'X-Content-Type-Options' => 'nosniff', + ]; + // CSP + Permissions-Policy are document-level and only meaningful on an HTML page. + if (preg_match('~\.html?$~i', $filename)) { + $siteorigin = preg_replace('~^(https?://[^/]+).*~i', '$1', $wwwroot); + $headers['Permissions-Policy'] = self::permissions_policy(); + $headers['Content-Security-Policy'] = self::content_security_policy($siteorigin); + } + return $headers; + } +} diff --git a/classes/task/preview_session_cleanup.php b/classes/task/preview_session_cleanup.php new file mode 100644 index 0000000..2d10b74 --- /dev/null +++ b/classes/task/preview_session_cleanup.php @@ -0,0 +1,52 @@ +. + +namespace mod_exelearning\task; + +use mod_exelearning\local\preview\session_store; + +/** + * Scheduled task: reap idle-expired editor preview sessions. + * + * The preview session store keeps ephemeral, file-backed sessions under the + * Moodle temp directory (serving contract v2). Sessions are also checked + * opportunistically on access, but a session that is created and then never + * touched again is only reclaimed here. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class preview_session_cleanup extends \core\task\scheduled_task { + /** + * The human-readable task name shown in the scheduled tasks admin report. + * + * @return string + */ + public function get_name(): string { + return get_string('previewsessioncleanup', 'mod_exelearning'); + } + + /** + * Sweep every idle-expired preview session. + */ + public function execute(): void { + $swept = session_store::sweep_expired(); + if ($swept > 0) { + mtrace('mod_exelearning: swept ' . $swept . ' expired preview session(s).'); + } + } +} diff --git a/db/tasks.php b/db/tasks.php new file mode 100644 index 0000000..5f7d428 --- /dev/null +++ b/db/tasks.php @@ -0,0 +1,37 @@ +. + +/** + * mod_exelearning scheduled task definitions. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => 'mod_exelearning\task\preview_session_cleanup', + 'blocking' => 0, + 'minute' => '*/15', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], +]; diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md new file mode 100644 index 0000000..9aecfe2 --- /dev/null +++ b/docs/preview-serving-contract.md @@ -0,0 +1,227 @@ +# Preview Serving Contract v2 — Host-Served Opaque HTTP Preview (Moodle adapter) + +`mod_exelearning` implements the eXeLearning **Preview Serving Contract v2** so +the embedded editor can render the **preview of untrusted author HTML/JS** in a +browser-enforced **opaque origin** over a real HTTP capability URL served by this +plugin. There is no `srcdoc` or Service-Worker fallback in the embed: when the +HTTP preview is unavailable the editor **fails closed** with a clear error rather +than silently downgrading the isolation boundary (the same-origin +`static-service-worker` trusted-content mode is a standalone-only compatibility +path, never used inside an embedded host). + +This is a per-host **adapter**. The single source of truth is eXe core: + +- **Canonical contract:** [`exelearning/doc/development/preview-serving-contract.md`](../../exelearning/doc/development/preview-serving-contract.md) +- **Isolation policy (verbatim CSP):** `exelearning/src/shared/security/previewSandbox.ts` → `previewCspHeader()` +- **Reference server:** `exelearning/src/services/preview-session-manager.ts`, `preview-serving.ts`, `preview-fixed-resources.ts` +- **Shared conformance vectors:** `exelearning/test/fixtures/preview-contract/vectors.json` (vendored here at + `tests/fixtures/preview-contract/vectors.json` and replayed by `conformance_test.php`) + +The **browser client is reused byte-for-byte**; only the *server* side is +reimplemented here on Moodle's own primitives (`tokenpluginfile`-style cookieless +serving, a file-backed session store, sesskey-gated management, a scheduled task). + +> **Why v2, in one line.** v1 re-hashed and re-serialized the whole project on +> every refresh; v2 splits the preview into three layers with different +> lifecycles so a refresh costs `O(changed documents + new assets)`. + +## The three layers + +| Layer | Contents | Lifecycle | Transferred | +|---|---|---|---| +| **Fixed installation resources** | official libs, base iDevice runtimes, base themes, PDF.js, content CSS, logo, fonts | immutable per installed editor version | **never** — served from the installed static editor, gated by `bundles/preview-fixed-resources.json` | +| **Session project assets** | author images/audio/video/PDF | immutable per `assetKey`, session lifetime | **once per session** | +| **Generated documents** | page HTML, generated CSS/JS, user themes/iDevices | change every edit | **only changed files**, as an atomic revision delta | + +## Implementation map + +| Concern | Location | +|---|---| +| Storage (three layers, atomic revisions, budgets, TTL, eviction) | `classes/local/preview/session_store.php` | +| Session handle (three-layer `get_file()` resolution) | `classes/local/preview/preview_session.php` | +| Protocol/response (CSP, tiered headers, ETag/Range, wire helpers) | `classes/local/preview/serving.php` | +| Fixed-resource manifest resolver (layer 1) | `classes/local/preview/fixed_resources.php` | +| Authless serving endpoint | `preview.php` | +| Authenticated management endpoint | `editor/preview_session.php` | +| Idle-session cleanup | `classes/task/preview_session_cleanup.php` + `db/tasks.php` | + +## A. Management API (authenticated — the author's session) + +A single dispatcher, `editor/preview_session.php`, gated by +`require_login` + `require_sesskey` + `require_capability('moodle/course:manageactivities')` +on the activity's module context and owner-scoped to `$USER`. It routes the +contract's four operations by **HTTP method + PATH_INFO** (the same slash-argument +shape `preview.php` serves), never a query `action`. `cmid` and `sesskey` stay in +the query string on every request; the client sends them as `managementQuery`: + +| Contract operation | Request | Success | +|---|---|---| +| create | `POST {script}` | `201 {previewId, protocolVersion: 2, revision: 0, limits}` | +| assets | `POST {script}/{previewId}/assets` (multipart: `assets` JSON `[{key,size}]`, `files[]`) | `200 {stored, alreadyStored, rejected}` | +| revision | `POST {script}/{previewId}/revisions` (multipart: `revision` JSON, `files[]`) | `200 {revision, active: true}` | +| delete | `DELETE {script}/{previewId}` | `200 {success: true}` | + +`{script}` is `{wwwroot}/mod/exelearning/editor/preview_session.php`. The routing +lives in `serving::route_management()` (method + path → operation); an unknown path +is `404` and a known path with the wrong method is `405` (with an `Allow` header). +There is no `action=` back-compat shim. + +Enforced v2 semantics (identical to core): + +- **Asset keys** match `^[0-9a-fA-F-]{36}@[0-9a-f]{8,64}$` and are **immutable** — + re-uploading a key returns it in `alreadyStored` and never replaces bytes. +- **Two-stage byte budget** — declared sizes before buffering, actual bytes while + buffering (`413`); per-entry `size-mismatch` / `asset-too-large` / budget reasons. +- **Failed writes are never indexed** — an asset whose bytes cannot be durably + written is reported `rejected {reason:"write-failed"}` and is *not* added to the + index or the byte counter, so a later revision that references it fails + `missing-assets` rather than serving a blank `404`. +- **Revision validation order** — `409 {reason:"revision-conflict", currentRevision}` + (stale/ non-consecutive) → `400` (unsafe path) → `422 {reason:"missing-assets", missing}` + → `422 {reason:"unknown-fixed-resources", resources}` → `413` (file-count / byte budgets). +- **Atomicity** — the full document set is staged in `revisions/{n}` and published + by an atomic swap of a `current` pointer; a GET reads the pointer once and serves + from that immutable revision directory, never mixing revision *N* and *N+1*. A + document write/copy **or manifest (`revision.json`)** write failure while staging + aborts the publish **before** the swap (`500`), discarding the staged revision + and leaving the active revision intact — no revision goes live without its bytes + or its document map. +- **Budgets & TTL** — 30-min idle TTL, 4 sessions/user, 5000 files/session, + 200 MiB/session, 128 MiB/asset, 2 GiB global (LRU eviction on create). + +## B. Serving route (authless capability URL — serves the opaque iframe) + +``` +GET /mod/exelearning/preview.php/{previewId}/ +``` + +Reference endpoint: [`preview.php`](../preview.php). + +- **Cookieless capability URL** (`NO_MOODLE_COOKIES`): the opaque iframe sends no + SameSite cookie, so the route is gated purely on the unguessable server-minted + `previewId` + idle TTL — the model the published package uses via + `tokenpluginfile.php`. +- `previewId` must match the UUID shape; anything else → `404`. +- **Bare capability root** — `GET /preview.php/{previewId}` and + `GET /preview.php/{previewId}/` (empty relative path) → **`302` redirect** to + `{previewId}/index.html`. Document bytes are never served from the bare URL, so + the opaque iframe's base URL is always the session directory. The `Location` is + **relative** (`{previewId}/index.html` without a trailing slash, `index.html` + with one) so it resolves against the request URL under any `$CFG->wwwroot` + subdirectory — matching the canonical conformance vector. +- **Three-layer resolution** against the active revision only: + `documents[path]` → `assets[assetRefs[path]]` → `manifest[fixedRefs[path]]` → `404`. + A client path never becomes a filesystem path; only manifest-controlled paths + reach the disk (under the distribution root, with containment checks). +- **Range** on session assets — a single satisfiable range → `206`; a + syntactically **valid** but unsatisfiable single range (first-byte-pos ≥ length + e.g. `bytes=99-`, or a zero suffix `bytes=-0`) → `416`; everything else is + **ignored** and served as a normal `200` full body (never `416`): a non-`bytes` + unit, a multi-range set, unparseable garbage, and an inverted spec whose + last-byte-pos is below its first-byte-pos (`bytes=5-2`, RFC 9110 invalid). + **Conditional** (`ETag`/`304`) requests are honored. + +## Required response headers (on every response, including 404s) + +``` +X-Content-Type-Options: nosniff +Referrer-Policy: no-referrer +Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() +Access-Control-Allow-Origin: * (authless + cookieless; NEVER with credentials) +Content-Type: +``` + +`Cache-Control` is **tiered by layer** — generated document `no-store`; session +asset `no-cache` (+ `ETag`, honor `If-None-Match`); fixed resource +`private, max-age=31536000`; 404 `no-store`. + +On **every scriptable document type** — `text/html`, **`image/svg+xml`**, +`application/xml`, `text/xml`, `application/xhtml+xml`, from **any** layer — +additionally emit the sandbox-first CSP **verbatim**: + +``` +Content-Security-Policy: + sandbox allow-scripts allow-popups allow-forms; default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; + img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; + font-src 'self' data:; connect-src 'self'; + frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; + child-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com; + object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; +``` + +`serving::csp_header()` emits this as a single line joined by `; ` with no +trailing `;` — **byte-identical** to core `previewCspHeader()` +(`serving_test::test_csp_header_is_byte_identical_to_core` is the drift check). + +> **The preview is always opaque.** The preview CSP hardcodes its sandbox tokens +> and does **not** reuse `player_iframe::sandbox_tokens()`: that helper can add +> `allow-same-origin` under the published-content dev-only legacy escape hatch, +> which would defeat the preview boundary. The two policies are deliberately +> independent. + +## The fixed-resource manifest + +`serving`'s layer-1 resolver reads `bundles/preview-fixed-resources.json` from the +**installed static editor distribution** — the admin-installed copy in moodledata, +else the copy bundled in `dist/static/`, resolved through +`embedded_editor_source_resolver::get_active_dir()`. Each `resources[id].path` is +resolved under that distribution root with a strict containment check +(`editor_paths::is_within` on the realpath, rejecting `..` and symlink escapes). + +- Ids resolve by **exact map lookup**; unknown id → miss (never a filesystem probe). +- **Graceful degradation:** when the manifest is absent (older editor build, or + none installed) the fixed layer is disabled — `fixedRefs` in a revision get a + `422 unknown-fixed-resources` and the client demotes those paths to document + writes and retries. The route never fatals. + +## Cleanup + +`\mod_exelearning\task\preview_session_cleanup` (registered in `db/tasks.php`, +every 15 minutes) sweeps idle-expired sessions; the serving and management paths +also check the TTL opportunistically on access. + +## Conformance + +`tests/local/preview/conformance_test.php` replays the shared vectors verbatim. +Alongside it: `serving_test.php` (protocol helpers incl. the CSP drift check), +`session_store_test.php` (revision ordering/atomicity, budgets, traversal, +immutability, TTL, eviction, ownership), `management_test.php` (wire validation +and status mapping), and `fixed_resources_test.php` (manifest resolution + +containment). + +## Client wiring (editor bootstrap) + +`editor/index.php` injects the normalized activation block into +`window.__EXE_EMBEDDING_CONFIG__` so a preview-capable editor build selects the +opaque HTTP transport (`HttpPreviewProvider`) against this plugin's own routes: + +```jsonc +"previewHttp": { + "protocolVersion": 2, + "managementBaseUrl": "{wwwroot}/mod/exelearning/editor/preview_session.php", + "servingBaseUrl": "{wwwroot}/mod/exelearning/preview.php", + "managementQuery": { "cmid": "", "sesskey": "" } +} +``` + +Two URLs, two trust models (matching §A / §B): + +- **`managementBaseUrl`** — authenticated + owner-scoped. Auth is the Moodle login + cookie plus the `sesskey` query value (no `managementHeaders` are needed); the + client appends `managementQuery` to every management request. +- **`servingBaseUrl`** — the authless capability URL that backs the opaque iframe. + +**Editor-build dependency.** The endpoints, the routing, and this config block are +in place, but they stay **dormant** until an embedded editor build that ships +`HttpPreviewProvider` (and a `bundles/preview-fixed-resources.json` manifest for +the fixed layer) is installed. Older builds ignore `previewHttp`; nothing breaks. + +**Playground.** Under the php-wasm Moodle Playground (the `MOODLE_PLAYGROUND` +constant) `previewHttp` is **omitted**, so a preview-capable editor **fails closed** +with a clear error rather than silently downgrading: a service worker cannot serve +a genuinely opaque iframe, so the Playground has no safe HTTP preview. Enabling +preview there is a **blueprint-only, development-only** opt-in to +`previewTransport: 'static-service-worker'` (which the core preview panel renders +with a visible warning banner) — it is never an admin setting and is never +auto-activated. diff --git a/docs/testing-http-preview-core-artifact.md b/docs/testing-http-preview-core-artifact.md new file mode 100644 index 0000000..1e1bd46 --- /dev/null +++ b/docs/testing-http-preview-core-artifact.md @@ -0,0 +1,69 @@ +# Testing the HTTP preview with a core pull-request artifact + +The Moodle adapter implements Preview Serving Contract v2, but a released editor older than `HttpPreviewProvider` ignores the injected `previewHttp` block. Before merging or releasing an editor upgrade, test this branch against one reproducible static-editor artifact built from the target eXeLearning core commit. + +## Build the canonical editor artifact + +From the eXeLearning core checkout: + +```bash +make bundle +make build-static +``` + +The resulting static distribution must contain all of the following: + +```text +public/app/workarea/interface/elements/preview/HttpPreviewProvider.js +public/app/workarea/interface/elements/preview/StaticServiceWorkerPreviewProvider.js +public/bundles/preview-fixed-resources.json +``` + +It must not contain `SrcdocPreviewProvider.js` or `srcdocInliner.js`. + +Archive the complete static distribution once and record its SHA-256. Every host integration test should consume the same archive rather than building a different editor copy. + +## Install it for a Moodle integration test + +Install the archive through the normal embedded-editor installer, or replace the development copy under `dist/static/`. Do not commit the generated distribution to this repository. + +Open an activity editor and verify the browser network sequence: + +1. `POST editor/preview_session.php?cmid=...&sesskey=...` returns protocol version 2. +2. New project assets are uploaded once to `/{previewId}/assets`. +3. Revision 1 is published to `/{previewId}/revisions`. +4. The iframe loads `preview.php/{previewId}/index.html`. +5. The iframe sandbox omits `allow-same-origin`. +6. Scriptable responses include the sandbox CSP. +7. Editing one page publishes only changed generated documents. +8. An unchanged image or video is not uploaded again. +9. Closing the editor or TTL cleanup removes the preview session. + +The test should also cover external media, page navigation, a large ranged asset, session expiry, and recreation after a serving `404`. + +## Playground policy + +A php-wasm Service Worker cannot back an opaque iframe. Moodle Playground therefore omits `previewHttp` and fails closed by default. + +A development blueprint may deliberately enable the same-origin compatibility transport only with both fields: + +```jsonc +{ + "previewTransport": "static-service-worker", + "allowUnsafeEmbeddedPreview": true +} +``` + +`previewTransport` alone is rejected. This setting must never be exposed as a Moodle administration option or enabled in production. The core editor displays a visible warning because this mode is not a security sandbox. + +## Merge evidence + +Record in the PR: + +- the core commit SHA; +- the static archive SHA-256; +- Moodle, PHP and database versions; +- the browser used; +- the preview management and serving requests observed; +- confirmation that the iframe is opaque; +- confirmation that unchanged assets are not retransmitted. diff --git a/docs/tracking-architecture.md b/docs/tracking-architecture.md index 0dddb2d..aca73dc 100644 --- a/docs/tracking-architecture.md +++ b/docs/tracking-architecture.md @@ -13,6 +13,14 @@ > ingestor}`. The overall (`itemnumber=0`) is taken from the package statement and validated > server-side, because per-iDevice `answered` statements carry no weight. > +> **Secure iframe (DEC-0065):** in the default opaque-origin secure mode (DEC-0059/DEC-0060) the same +> xAPI-primary path applies, but the trust gate changes. The emitter's `event.origin` is the opaque +> string `"null"`, so `js/xapi_listener.js` trusts a statement by **window identity** +> (`event.source === the package iframe's contentWindow`) instead of origin — exactly like the SCORM +> bridge relay. The SCORM channel is kept inert by the **parent-side relay** +> (`js/scorm_bridge_relay.js` `disableTracking`), not the baked-in shim, so it holds even for packages +> extracted before the flag existed. Legacy (same-origin) mode keeps the `event.origin` check. +> > **Kill switch:** the site-admin setting *Use xAPI grading when the package supports it* > (`exelearning/xapiprimaryenabled`, default on; helper `exelearning_xapi_primary_enabled()`) turns the > whole xAPI channel off without a code change — `view.php` then keeps the SCORM shim live and skips the diff --git a/docs/xapi-integration-plan.md b/docs/xapi-integration-plan.md index ac3eee1..237d46f 100644 --- a/docs/xapi-integration-plan.md +++ b/docs/xapi-integration-plan.md @@ -76,11 +76,18 @@ Implemented as an **inline IIFE** (the `js/scorm_tracker.js` / DEC-0056 pattern) client JS is injected synchronously and needs no AMD build). - Listen to `window` `message` events; accept **only** `event.data.type === 'exe-xapi-statement'`. -- **Validate `event.origin`** against the iframe `pluginfile.php`/wwwroot origin; drop `'*'` / - mismatched senders (defense in depth even though `config_injector` sets `parentOrigin` — RIE-013). +- **Trust gate, mode-dependent (DEC-0065).** *Legacy (same-origin):* validate `event.origin` against + the wwwroot origin; drop `'*'` / mismatched senders (RIE-013). *Secure (opaque-origin, the default — + DEC-0059/DEC-0060):* the emitter's `event.origin` is the string `"null"`, so trust by **window + identity** (`event.source === the package iframe's contentWindow`) instead — the same anchor the + SCORM bridge relay uses. `view.php` selects the mode via `iframeid` (secure) vs `allowedOrigin` + (legacy). - De-dup by `statement.id` within the page session. - Forward to `xapi_track.php` with `sesskey`, `cmid`, and the page-load `registration`. - Never read or expose PII in JS. +- In **secure mode** the parallel SCORM bridge is kept **inert** (so the package is graded once) by the + parent-side relay (`js/scorm_bridge_relay.js` `disableTracking`), not the baked-in shim — robust even + for packages extracted before the flag existed (DEC-0065). ## 4. Server endpoint (`xapi_track.php`) — *as shipped (DEC-0064)* diff --git a/docs/xapi-qa-checklist.md b/docs/xapi-qa-checklist.md index 46b4706..d15c7d7 100644 --- a/docs/xapi-qa-checklist.md +++ b/docs/xapi-qa-checklist.md @@ -1,4 +1,4 @@ -# xAPI ingestion — manual QA checklist (DEC-0064) +# xAPI ingestion — manual QA checklist (DEC-0064, DEC-0065) > Unit/integration tests (`tests/local/xapi/*`, `tests/js/xapi_listener.test.js`) cover the > validation, grading parity and the listener resend. This checklist is the **manual, real-package** @@ -24,7 +24,9 @@ - **Attempts** — `exelearning_attempt`: `itemnumber>0` = per-iDevice, `itemnumber=0` = overall. - **Audit** — `exelearning_tracking_events`: one row per `statement.id` with `verb` + `registration`. - **Channel check** — view source / Network: an XAPI package POSTs to **`xapi_track.php`** and the - SCORM shim is inert (no `track.php` POSTs); a LEGACY package POSTs to **`track.php`** only. + SCORM shim is inert (no `track.php` POSTs); a LEGACY package POSTs to **`track.php`** only. In + **secure** iframe mode (DEC-0065) the package iframe is opaque and the SCORM **bridge relay** + suppresses the `track.php` POST, so an XAPI package still shows only `xapi_track.php`. ## Scenarios @@ -41,12 +43,15 @@ | 9 | **Kill switch off** | Uncheck *Use xAPI grading…*; reload an XAPI activity | The package now grades via **SCORM** (`track.php`), the xAPI listener is **not** injected, and a direct POST to `xapi_track.php` returns `{ok:true,disabled:true}` without grading. Re-check the box → back to xAPI. | | 10 | **Idempotency** | Re-deliver the same statement (e.g. duplicate `postMessage`, or replay the POST) | Exactly one audit row and one attempt row; the grade is not applied twice (`duplicate:true`). | | 11 | **Registration hardening** | Craft a direct `xapi_track.php` POST with an over-long / non-alphanumeric `context.registration` and no body registration | No 500: the token is sanitised + capped to 40 chars; the attempt/audit store the bounded value. | -| 12 | **Origin rejection** | (If reproducible) deliver a statement from a mismatched/`'*'` origin | Dropped by the listener; never reaches `xapi_track.php`. | +| 12 | **Origin / window-identity rejection** | Deliver a statement from a window that is not the package iframe — legacy: a mismatched/`'*'` origin; secure: a different `event.source` (e.g. another frame) | Dropped by the listener; never reaches `xapi_track.php`. | +| 13 | **xAPI grading in secure iframe mode (DEC-0065)** | *Site admin ▸ … ▸ eXeLearning ▸ iframe security mode* = **secure** (default). Run the XAPI package; a student grades an interaction | Grades land via `xapi_track.php` exactly like legacy; the SCORM **bridge relay** forwards no score (no `track.php` POST); per-iDevice/overall columns correct. The opaque iframe's `event.origin` is `"null"` yet statements are accepted (window identity = `event.source` is the package iframe). | +| 14 | **Kill switch flipped during an open session (known limitation, DEC-0065/DEC-0064)** | Open an XAPI activity as a student; while it stays open, an admin **unchecks** *Use xAPI grading…*; the student answers **without reloading** | That already-open page grades **neither** channel until reloaded (SCORM suppressed client-side, xAPI ignored server-side); after a reload it grades via SCORM. Pre-existing since DEC-0064 — the kill switch takes effect on the **next** page load, so flip it outside activity hours. | ## Sign-off - [ ] Scenarios 1–4 (core grading + the answered-only edge) pass. - [ ] Scenarios 5–9 (resilience, attempts, preview, grading off, kill switch) pass. -- [ ] Scenarios 10–12 (idempotency, input hardening, origin) pass. +- [ ] Scenarios 10–12 (idempotency, input hardening, origin/window-identity) pass. +- [ ] Scenarios 13–14 (secure-mode xAPI grading; kill-switch-mid-session limitation understood) pass. - [ ] Gradebook totals and activity completion are correct for at least one PERITEM and one OVERALL run. - [ ] `exelearning_tracking_events` monitoring query returns 0 for a clean run (no orphaned `answered`). diff --git a/editor/index.php b/editor/index.php index 22b034c..93575da 100644 --- a/editor/index.php +++ b/editor/index.php @@ -139,7 +139,7 @@ function exelearning_editor_error_page(string $message): void { $wwwrootorigin = $parsedwwwroot['scheme'] . '://' . $parsedwwwroot['host'] . (!empty($parsedwwwroot['port']) ? ':' . $parsedwwwroot['port'] : ''); -$embeddingconfig = json_encode([ +$embeddingconfigdata = [ 'basePath' => $editorbaseurl, 'parentOrigin' => $wwwrootorigin, 'trustedOrigins' => [$wwwrootorigin], @@ -151,7 +151,32 @@ function exelearning_editor_error_page(string $message): void { ], 'platform' => 'moodle', 'pluginVersion' => get_config('mod_exelearning', 'version'), -]); +]; + +// Normalized HTTP preview activation (serving contract v2). Once the embedded +// editor build ships HttpPreviewProvider it renders the opaque preview over the +// plugin's own capability URLs: management (authenticated, sesskey + cmid in the +// query) at editor/preview_session.php, authless serving at preview.php. +// +// Omitted under the php-wasm Playground: a service worker cannot serve a +// genuinely opaque iframe, so the editor must FAIL CLOSED there (a clear preview +// error) rather than silently downgrade the isolation boundary. Enabling preview +// in the Playground is a blueprint-only, dev-only opt-in to previewTransport +// 'static-service-worker' (which the core preview panel renders with a visible +// warning) — never an admin setting. See docs/preview-serving-contract.md. +if (!(defined('MOODLE_PLAYGROUND') && MOODLE_PLAYGROUND)) { + $embeddingconfigdata['previewHttp'] = [ + 'protocolVersion' => 2, + 'managementBaseUrl' => (new moodle_url('/mod/exelearning/editor/preview_session.php'))->out(false), + 'servingBaseUrl' => (new moodle_url('/mod/exelearning/preview.php'))->out(false), + 'managementQuery' => [ + 'cmid' => (string) $cm->id, + 'sesskey' => sesskey(), + ], + ]; +} + +$embeddingconfig = json_encode($embeddingconfigdata); // Approved style registry consumed by the editor's themeRegistryOverride // hook (see exelearning/exelearning#1722). Filters built-ins, appends @@ -216,12 +241,30 @@ enumerable: true, // the Yjs theme bind and leaves the editor unresponsive. WP and Omeka-S // ship the same workaround: swallow 404s on .css / idevices URLs and // return an empty stylesheet so the editor keeps booting. - // Disable any new service-worker registration (the static editor's - // preview-sw.js is served from the same static.php router; environments - // that proxy or cache that router — e.g. moodle-playground — return a - // 404 there and the registration error spams the console without - // blocking anything). + // Neutralize the static editor's preview-sw.js registration (its SW is + // served from the same static.php router; environments that proxy or cache + // that router — e.g. moodle-playground — 404 the SW script and the + // registration error spams the console). The stub MUST return a faithful + // ServiceWorkerRegistration shape, not a bare { scope: "" }: the editor's + // preview provider calls registration.addEventListener("updatefound", …) and + // reads installing/waiting/active, so a bare object throws "addEventListener + // is not a function" and aborts the preview/export iframe. A dummy with a + // non-empty scope, null workers (so the provider's activation wait resolves + // at once and it claims no clients) and no-op event/lifecycle methods lets + // the provider complete and fall back cleanly. (function() { + function fakeSwRegistration(options) { + return { + scope: (options && options.scope) || "/", + installing: null, + waiting: null, + active: null, + addEventListener: function() {}, + removeEventListener: function() {}, + update: function() { return Promise.resolve(); }, + unregister: function() { return Promise.resolve(true); } + }; + } if ("serviceWorker" in navigator) { try { var registerOriginal = navigator.serviceWorker.register @@ -229,11 +272,11 @@ enumerable: true, : null; navigator.serviceWorker.register = function(scriptURL, options) { if (typeof scriptURL === "string" && scriptURL.indexOf("preview-sw.js") !== -1) { - return Promise.resolve({ scope: "" }); + return Promise.resolve(fakeSwRegistration(options)); } return registerOriginal ? registerOriginal(scriptURL, options) - : Promise.resolve({ scope: "" }); + : Promise.resolve(fakeSwRegistration(options)); }; } catch (e) { // Some embeds make navigator.serviceWorker non-writable; ignore. diff --git a/editor/preview_session.php b/editor/preview_session.php new file mode 100644 index 0000000..065881e --- /dev/null +++ b/editor/preview_session.php @@ -0,0 +1,150 @@ +. + +/** + * Authenticated, owner-scoped management API for the editor preview + * (serving contract v2 — docs/preview-serving-contract.md, §A). + * + * A single dispatcher routing the four contract operations over method + PATH_INFO + * (the same slash-argument shape preview.php serves), gated by require_login + + * sesskey + capability on the activity's module context and owner-scoped to the + * authoring $USER. `cmid` and `sesskey` are query parameters on every request: + * + * POST {script} → create + * POST {script}/{previewId}/assets → assets (multipart) + * POST {script}/{previewId}/revisions → revision (multipart) + * DELETE {script}/{previewId} → delete + * + * The authless serving counterpart is preview.php. All protocol logic (including + * the method/path routing) lives in \mod_exelearning\local\preview\serving so + * this script only authenticates, marshals the request, and emits the JSON result. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); + +// @codingStandardsIgnoreLine — path is fixed relative to /mod/exelearning/editor/. +require('../../../config.php'); + +use mod_exelearning\local\preview\serving; +use mod_exelearning\local\preview\session_store; + +$cmid = required_param('cmid', PARAM_INT); + +$cm = get_coursemodule_from_id('exelearning', $cmid, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); + +require_login($course, true, $cm); +require_sesskey(); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); + +// Release the session lock early: serving GETs must not queue behind this write. +\core\session\manager::write_close(); + +header('Content-Type: application/json; charset=utf-8'); + +/** + * Emit a management result ({status, body}) and stop. + * + * @param array $result Management result with 'status' and 'body' keys. + * @return never + */ +function exelearning_preview_emit(array $result): void { + http_response_code($result['status']); + echo json_encode($result['body']); + die; +} + +// Route on method + PATH_INFO ("/{previewId}/{op}"); cmid/sesskey stay in the query. +$route = serving::route_management($_SERVER['REQUEST_METHOD'] ?? 'GET', (string) get_file_argument()); + +try { + if ($route['op'] === 'error') { + if (!empty($route['allow'])) { + header('Allow: ' . $route['allow']); + } + exelearning_preview_emit(['status' => $route['status'], 'body' => ['success' => false, 'error' => $route['message']]]); + } + + if ($route['op'] === 'create') { + exelearning_preview_emit(serving::create_session_response((int) $USER->id)); + } + + // Every other operation targets an existing, owner-scoped session. + $owned = session_store::get_owned($route['previewid'], (int) $USER->id); + if ($owned['status'] !== 200) { + $message = ($owned['status'] === 403) ? 'Access denied' : 'Preview session not found'; + exelearning_preview_emit(['status' => $owned['status'], 'body' => ['success' => false, 'error' => $message]]); + } + $session = $owned['session']; + + if ($route['op'] === 'assets') { + $parts = exelearning_preview_uploaded_parts(); + exelearning_preview_emit(serving::handle_assets_request($session, $_POST['assets'] ?? null, $parts)); + } + + if ($route['op'] === 'revision') { + $parts = exelearning_preview_uploaded_parts(); + exelearning_preview_emit(serving::handle_revision_request($session, $_POST['revision'] ?? null, $parts)); + } + + // The only remaining routed operation is delete. + session_store::delete_session($route['previewid']); + exelearning_preview_emit(['status' => 200, 'body' => ['success' => true]]); +} catch (Throwable $e) { + debugging('mod_exelearning preview session endpoint failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + exelearning_preview_emit(['status' => 500, 'body' => ['success' => false, 'error' => get_string('error')]]); +} + +/** + * Read the multipart files[] upload into an index-aligned array of parts, each + * carrying its PHP upload error code, name and (when readable) bytes. A per-file + * upload error is NOT collapsed to empty content here — serving::collect_upload() + * rejects the whole batch instead, so a document that exceeded the server upload + * limit can never be published as a 0-byte file. + * + * @return array + */ +function exelearning_preview_uploaded_parts(): array { + if (empty($_FILES['files']) || !isset($_FILES['files']['tmp_name'])) { + return []; + } + $tmpnames = $_FILES['files']['tmp_name']; + $errors = $_FILES['files']['error']; + $names = $_FILES['files']['name'] ?? []; + // Normalize the single-file shape to arrays. + if (!is_array($tmpnames)) { + $tmpnames = [$tmpnames]; + $errors = [$errors]; + $names = [$names]; + } + $parts = []; + foreach ($tmpnames as $i => $tmpname) { + $error = isset($errors[$i]) ? (int) $errors[$i] : UPLOAD_ERR_NO_FILE; + $name = isset($names[$i]) ? (string) $names[$i] : ''; + $bytes = null; + if ($error === UPLOAD_ERR_OK && is_uploaded_file($tmpname)) { + $read = file_get_contents($tmpname); + $bytes = ($read === false) ? null : $read; + } + $parts[] = ['error' => $error, 'name' => $name, 'bytes' => $bytes]; + } + return $parts; +} diff --git a/js/exe_embed_relay.js b/js/exe_embed_relay.js new file mode 100644 index 0000000..4b389cf --- /dev/null +++ b/js/exe_embed_relay.js @@ -0,0 +1,639 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Parent-side external-embed relay for the secure (opaque-origin) package mode. + * + * Companion to js/exe_embed_shim.js (baked into the package, runs INSIDE the iframe). + * In secure mode the package is opaque, so cross-origin players (YouTube, Vimeo) and + * PDFs render blank. The shim replaces each candidate iframe with a placeholder and + * postMessages its geometry here; this relay (the trusted half) validates each URL and + * overlays the real player inline over the placeholder. + * + * Trust model (DEC-0061): the promoted player is rendered cross-origin and SANDBOXED, so + * the same-origin policy isolates it from this LMS page (it cannot read the DOM, cookies, + * session or file token). Two modes: + * - 'open' (default): promote any iframe whose src is https AND cross-origin to the LMS + * (rejecting same-origin, sub/superdomains of the LMS, 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 (the overlay is clamped). + * - 'strict': only a maintained host allowlist with per-provider canonical-URL + * reconstruction (the pre-DEC-0061 behaviour), for high-security deployments. + * "Any https .pdf" is always allowed (same-origin only for this package's own files). + * + * Messages are authenticated by window identity (event.source === a known CONTENT + * iframe, never a promoted player); the opaque origin has no useful event.origin. + * + * Exposed two ways from a single body: window.exeEmbedRelay (browser bootstrap) and + * module.exports (Vitest). See research ADR DEC-0061. + * + * MIRROR of the canonical eXeLearning embedder source in eXe core + * (public/app/common/exe_embed_bridge/exe_embed_relay.js). Keep in sync with core; + * changes flow from core outward. Verified by core scripts/check-embed-sync.mjs. + */ +(function () { + 'use strict'; + + /** + * Build a host lookup map from a whitelist array (lowercased). Used by 'strict' mode. + * + * @param {string[]} list + * @returns {Object} + */ + 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 + * @returns {string} + */ + 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 + * @returns {?string} + */ + 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 + * @param {string} contentSrc + * @returns {boolean} + */ + function isSameOriginPackageFile(url, contentSrc) { + var dir = contentDir(contentSrc); + if (dir && url.href.indexOf(dir) === 0) { + 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 LMS yet target the machine/internal network, so they are + * rejected even though SOP would isolate them. + * + * @param {string} host Lowercased URL.hostname. + * @returns {boolean} + */ + function isIpOrLocalHost(host) { + if (!host) { return true; } + if (host === 'localhost' || /\.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. 'lms.example.org.' (the + * FQDN-root form) resolves to the same vhost as 'lms.example.org' but compares + * unequal as a raw string, so without this it would slip past the same-origin / + * related-to-LMS gate below and be promoted as a cross-origin player. + * + * @param {string} host + * @returns {string} + */ + function normalizeHost(host) { + return (host || '').toLowerCase().replace(/\.$/, ''); + } + + /** + * Whether a host equals, is a subdomain of, or is a superdomain of the LMS host + * (dotted boundary so 'evil-lms.example' does not match 'lms.example'). Such hosts + * may share the LMS cookies, so they are rejected. Both sides are normalised so the + * trailing-dot FQDN-root form cannot evade the comparison. + * + * @param {string} host + * @param {string} lmsHost + * @returns {boolean} + */ + 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 LMS 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 + * @returns {boolean} + */ + function isCrossOriginHttps(url) { + if (url.protocol !== 'https:') { 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 + * @param {string} objectId + * @returns {?string} + */ + 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}. + * @returns {?Object} + */ + 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 LMS 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] && url.protocol === 'https:') { + 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 (host === 'mediateca.educa.madrid.org') { + 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 the 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 omitting + * allow-top-navigation/allow-modals, so a hostile embed cannot redirect the LMS tab or + * spam dialogs. A same-origin package PDF is served as application/pdf + nosniff (never + * executable HTML) and is left unsandboxed so the browser's built-in viewer renders it; a + * CROSS-ORIGIN PDF URL comes from the untrusted package, so it is sandboxed WITHOUT + * allow-top-navigation (a server can serve scripted HTML at a .pdf path, which unsandboxed + * could top-navigate the parent tab to a phishing page). + * + * @param {Object} result {url, kind, sameorigin?} from validate(). + * @returns {HTMLIFrameElement} + */ + 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 (result.kind === 'video') { + 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, so it cannot script or navigate), left unsandboxed so the + // browser's built-in viewer renders it (it shows the broken-document icon inside a + // sandbox). The load guard still removes it if it redirects to the LMS origin. + frame.setAttribute('allow', 'fullscreen'); + frame.setAttribute('referrerpolicy', 'no-referrer'); + } else { + // Cross-origin PDF whose URL is controlled by the untrusted package. A server can + // serve scripted HTML at a ".pdf" path; unsandboxed, that frame could top-navigate + // the Moodle tab to a phishing page on a click (a package must never change the + // parent URL). Sandbox it WITHOUT allow-top-navigation/allow-scripts; allow-same- + // origin keeps the provider's own origin (SOP-isolated from the LMS). Trade-off: a + // genuine cross-origin PDF may render the broken-document icon under the sandbox -- + // accepted, since local package PDFs (the common case) take the branch above and + // blocking the tab-redirect vector matters more than inlining a remote PDF. + 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[]} + * @returns {Object} + */ + function createRelay(config) { + config = config || {}; + var strict = config.mode === 'strict'; + 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. + * + * @returns {number} + */ + 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 LMS (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 || data.type !== 'exe-embed' || data.action !== 'sync' || !Array.isArray(data.embeds)) { + return; + } + var iframe = frameForSource(event.source); + if (!iframe) { + return; + } + sync(overlayFor(iframe), data.embeds, iframe.src); + } + + // Browser-only glue below (window listeners, reflow on scroll/resize, pinging + // the content iframes). Exercised by the Playwright/Firefox e2e + // (tests/e2e/embed.spec.cjs), not the happy-dom unit tests. + /* v8 ignore start */ + 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]); + } + }); + } + /* v8 ignore stop */ + + // Remove every overlay element and its players from the DOM and empty the + // overlays list (dispose() reuses this to tear the relay down completely). + 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 (driftTimer !== null) { + 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 }); + }, + /* v8 ignore start */ + 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; + } + /* v8 ignore stop */ + }; + } + + /** + * Bootstrap: create a relay from config and start listening. + * + * @param {Object} config {mode: 'open'|'strict', whitelist: string[]} + * @returns {Object} + */ + /* v8 ignore next 3 */ + function init(config) { + return createRelay(config).init(); + } + + 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, + init: init + }; + // Test runner (Vitest/Node) consumes module.exports. + if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } + // Browser bootstrap (view.php) consumes window.exeEmbedRelay. + if (typeof window !== 'undefined') { window.exeEmbedRelay = exp; } +})(); diff --git a/js/exe_embed_shim.js b/js/exe_embed_shim.js new file mode 100644 index 0000000..bfe024e --- /dev/null +++ b/js/exe_embed_shim.js @@ -0,0 +1,369 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * In-iframe external-embed shim for the secure (opaque-origin) package mode. + * + * Baked into the package (libs/exe_embed_shim.js) and loaded from the of every + * page, alongside the SCORM bridge. In secure mode the package runs in an opaque-origin + * sandbox, so the sandbox flag propagates to nested iframes and cross-origin players + * (YouTube, Vimeo) plus PDFs render blank. This shim replaces each cross-origin (https) + * or .pdf iframe with a same-size placeholder and reports its geometry to the parent, + * which validates it and overlays the real player inline (see js/exe_embed_relay.js). It + * self-activates ONLY in the opaque origin (so the same baked file stays dormant in + * legacy same-origin mode, where external players already render inline and the relay is + * not loaded). + * + * There is no host list here: the shim promotes any cross-origin https (or .pdf) iframe + * as a candidate and the parent relay is the authoritative gate (open vs strict mode, + * DEC-0061). postMessage targetOrigin is '*' because the opaque origin has no stable + * value; the parent authenticates messages by event.source instead. + * + * Exposed two ways from a single body: window.exeEmbedShim (browser bootstrap) and + * module.exports (Vitest). See research ADR DEC-0059. + * + * MIRROR of the canonical eXeLearning embedder source in eXe core + * (public/app/common/exe_embed_bridge/exe_embed_shim.js). Keep in sync with core; + * changes flow from core outward. Verified by core scripts/check-embed-sync.mjs. + */ +(function () { + 'use strict'; + + /** + * Whether this document runs in an opaque origin (secure sandbox). In an opaque + * origin document.cookie throws and window.origin is "null". + * + * @returns {boolean} + */ + function isOpaqueOrigin() { + try { + void document.cookie; + return window.origin === 'null'; + } catch (e) { + return true; + } + } + + /** + * Whether a URL path ends in .pdf (PDFs also fail under the opaque sandbox). + * + * @param {string} url + * @returns {boolean} + */ + function isPdfUrl(url) { + try { + return /\.pdf$/i.test(new URL(url, window.location.href).pathname); + } catch (e) { + return false; + } + } + + /** + * Whether a src resolves to an https URL on a host other than this document's own + * (served) host -- i.e. a cross-origin external embed. The opaque document is still + * served from the platform, so window.location.hostname is the platform host and the + * comparison is reliable. The parent relay re-validates authoritatively (DEC-0061); + * this is only a candidate filter so same-origin content iframes are left untouched. + * + * @param {string} src + * @returns {boolean} + */ + function isCrossOriginHttps(src) { + try { + var u = new URL(src, window.location.href); + // Strip a single trailing dot so the LMS host in its FQDN-root form + // ('host.') counts as same-host and is not reported as a candidate. + var host = u.hostname.toLowerCase().replace(/\.$/, ''); + var here = window.location.hostname.toLowerCase().replace(/\.$/, ''); + return u.protocol === 'https:' && host !== here; + } catch (e) { + return false; + } + } + + /** + * Whether an iframe src should be promoted to the parent: any cross-origin https + * embed or a .pdf (both render blank under the opaque sandbox). No host list -- the + * parent relay decides what actually renders (open vs strict mode). + * + * @param {string} src + * @returns {boolean} + */ + function isPromotable(src) { + return isCrossOriginHttps(src) || isPdfUrl(src); + } + + /** + * Recognise a known video provider from an embed src and extract its object id, so the + * shim can report {provider, objectId} instead of the author URL (DEC-0067 id-only + * channel). The parent rebuilds the canonical URL from a fixed template; this avoids + * passing the author's URL across the boundary for recognised providers. Returns null + * for unknown hosts or unexpected paths (the caller then falls back to URL mode). The + * id shape is intentionally permissive here; the parent re-checks it against a strict + * regex before templating it. + * + * @param {string} src + * @returns {?{provider: string, objectId: string}} + */ + function extractProvider(src) { + var u; + try { + u = new URL(src, window.location.href); + } catch (e) { + return null; + } + if (u.protocol !== 'https:') { + return null; + } + var host = u.hostname.toLowerCase().replace(/\.$/, ''); + var m; + if (host === 'youtu.be') { + m = u.pathname.match(/^\/([A-Za-z0-9_-]{6,})$/); + return m ? { provider: 'youtube', objectId: m[1] } : null; + } + if (host.indexOf('youtube') !== -1) { + m = u.pathname.match(/^\/embed\/([A-Za-z0-9_-]{6,})$/); + return m ? { provider: 'youtube', objectId: m[1] } : null; + } + if (host.indexOf('vimeo') !== -1) { + m = u.pathname.match(/^\/video\/([0-9]+)$/); + return m ? { provider: 'vimeo', objectId: m[1] } : null; + } + if (host.indexOf('dailymotion') !== -1) { + m = u.pathname.match(/^\/embed\/video\/([A-Za-z0-9]{5,})$/); + return m ? { provider: 'dailymotion', objectId: m[1] } : null; + } + if (host === 'mediateca.educa.madrid.org') { + m = u.pathname.match(/^\/video\/([A-Za-z0-9]{8,})(?:\/fs)?$/); + return m ? { provider: 'mediateca-madrid', objectId: m[1] } : null; + } + return null; + } + + /** + * Render a width/height attribute value as a CSS length. + * + * @param {?string} value + * @param {string} fallback + * @returns {string} + */ + function cssSize(value, fallback) { + if (!value) { + return fallback; + } + return /^[0-9]+$/.test(String(value)) ? value + 'px' : String(value); + } + + /** + * Replace whitelisted/PDF iframes with placeholders that reserve their box and + * carry the embed id + url. Returns the created placeholder elements. + * + * @param {Document|Element} root A document or a container element to scan. + * @param {Object} counter {n:int} mutable id counter (kept across calls). + * @returns {Element[]} + */ + function promote(root, counter) { + var created = []; + var maker = root.ownerDocument || root; + var frames = root.querySelectorAll('iframe[src]'); + for (var i = 0; i < frames.length; i++) { + var frame = frames[i]; + if (frame.getAttribute('data-exe-embed-id')) { + continue; + } + var src = frame.getAttribute('src'); + if (!isPromotable(src)) { + continue; + } + var rect = frame.getBoundingClientRect ? frame.getBoundingClientRect() : { width: 0, height: 0 }; + var placeholder = maker.createElement('div'); + counter.n += 1; + placeholder.setAttribute('data-exe-embed-id', 'exe-embed-' + counter.n); + // Report an ABSOLUTE url: the shim runs inside the content, so resolve the + // (possibly relative) src against the content location. The parent relay + // cannot — it would resolve a relative url against the host page instead. + var absoluteUrl = src; + try { + absoluteUrl = new URL(src, window.location.href).href; + } catch (e) { + absoluteUrl = src; + } + placeholder.setAttribute('data-exe-embed-url', absoluteUrl); + // For recognised providers also stamp {provider, objectId} so the parent can + // rebuild the canonical URL from a fixed template (DEC-0067 id-only channel) + // instead of trusting the author URL. Unknown hosts keep URL-only mode. + var provider = extractProvider(absoluteUrl); + if (provider) { + placeholder.setAttribute('data-exe-embed-provider', provider.provider); + placeholder.setAttribute('data-exe-embed-object-id', provider.objectId); + } + placeholder.className = frame.className; + placeholder.style.display = 'block'; + placeholder.style.maxWidth = '100%'; + placeholder.style.width = cssSize(frame.getAttribute('width'), (rect.width || 0) + 'px'); + placeholder.style.height = cssSize(frame.getAttribute('height'), (rect.height || 0) + 'px'); + placeholder.style.background = '#000'; + frame.parentNode.replaceChild(placeholder, frame); + created.push(placeholder); + } + return created; + } + + /** + * Collect the geometry of every placeholder in the document. + * + * @param {Document} doc + * @returns {Object[]} + */ + function collect(doc) { + var embeds = []; + var nodes = doc.querySelectorAll('[data-exe-embed-id]'); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var rect = node.getBoundingClientRect(); + var rec = { + id: node.getAttribute('data-exe-embed-id'), + url: node.getAttribute('data-exe-embed-url'), + x: rect.left, + y: rect.top, + w: rect.width, + h: rect.height + }; + var provider = node.getAttribute('data-exe-embed-provider'); + var objectId = node.getAttribute('data-exe-embed-object-id'); + if (provider && objectId) { + rec.provider = provider; + rec.objectId = objectId; + } + embeds.push(rec); + } + return embeds; + } + + /** + * Bootstrap inside the package iframe (no-op outside the secure opaque origin). + * Browser-only glue (requires a framed, opaque-origin window); exercised by the + * Playwright/Firefox e2e (tests/e2e/embed.spec.cjs), not the happy-dom unit tests. + */ + /* v8 ignore start */ + function init() { + if (window.parent === window || !isOpaqueOrigin()) { + return; + } + var counter = { n: 0 }; + var scheduled = false; + var lastReported = ''; + + // force=true always posts (initial run, load, and parent 'request' pings -- + // the parent may have just started listening or lost its state); observer + // -driven reports skip when the geometry did not actually change, so an + // attribute-noisy page (carousel animations, aria flips) cannot spam the + // parent with identical syncs. + function report(force) { + var embeds = collect(document); + var serialized = JSON.stringify(embeds); + if (!force && serialized === lastReported) { + return; + } + lastReported = serialized; + window.parent.postMessage({ type: 'exe-embed', action: 'sync', embeds: embeds }, '*'); + } + function schedule() { + if (scheduled) { + return; + } + scheduled = true; + window.requestAnimationFrame(function () { + scheduled = false; + report(false); + }); + } + function run() { + promote(document, counter); + report(true); + } + + run(); + if (window.MutationObserver) { + // attributes too, not just childList: layout-affecting UI (the exported + // page's nav toggle, accordions) usually flips a class/style on an + // existing node, which reflows the placeholders without adding or + // removing any element. The filter keeps the observer to the + // reflow-causing attributes. + new MutationObserver(function () { + promote(document, counter); + schedule(); + }).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'hidden', 'open'], + }); + } + window.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule); + // A class-toggled layout change usually ANIMATES (CSS transition on the nav + // drawer): the mutation fires at the start, so re-measure again when the + // transition/animation lands to report the settled geometry. + window.addEventListener('transitionend', schedule, true); + window.addEventListener('animationend', schedule, true); + if (window.ResizeObserver) { + // Catches content-box changes that fire no window resize (the drawer + // pushing the content column, images loading late and growing the page). + var resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(document.documentElement); + if (document.body) { + resizeObserver.observe(document.body); + } + } + window.addEventListener('load', function () { + report(true); + }); + window.addEventListener('message', function (event) { + if (event.source !== window.parent) { + return; + } + var data = event.data; + if (data && data.type === 'exe-embed' && data.action === 'request') { + run(); + } + }); + } + /* v8 ignore stop */ + + var exp = { + isOpaqueOrigin: isOpaqueOrigin, + isPdfUrl: isPdfUrl, + isCrossOriginHttps: isCrossOriginHttps, + isPromotable: isPromotable, + extractProvider: extractProvider, + promote: promote, + collect: collect, + init: init + }; + // Test runner (Vitest/Node) consumes module.exports. + if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } + // Browser bootstrap consumes window.exeEmbedShim; auto-run inside the iframe. + if (typeof window !== 'undefined') { + window.exeEmbedShim = exp; + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } + } +})(); diff --git a/js/exe_media_host.js b/js/exe_media_host.js new file mode 100644 index 0000000..b80265f --- /dev/null +++ b/js/exe_media_host.js @@ -0,0 +1,533 @@ +/** + * exe-media-host — reference PARENT-side relay for the eXeLearning external-media bridge. + * + * Host plugins (Moodle, WordPress, Procomún, …) include this one file on the trusted page + * that embeds an eXeLearning package inside an opaque-origin sandboxed iframe. It lets the + * package's YouTube/Vimeo videos play (which they cannot do inside the opaque frame) by: + * + * - completing the capability handshake announced by the child runtime (window identity + * + per-view nonce + a transferred MessageChannel port), + * - opening an accessible with the real provider player on the trusted side, and + * - relaying a tiny, validated set of media commands/events over the private port. + * + * Security: the relay NEVER trusts `event.origin` (it is the string "null"). The only + * window-level message accepted is the child's `welcome`-triggering `hello`, gated by + * `event.source === iframe.contentWindow`. Steady-state media traffic flows only over the + * private port. The child sends just `{provider, videoId}`; the relay reconstructs the + * canonical URL from a fixed per-provider template (never a child-supplied URL). + * + * Classic browser script (no ES module syntax); depends on the shared `exeMediaPolicy`. + * + * mod_exelearning VARIANT (DEC-0067): vendored from eXeLearning's exe-media-host.js, but the + * provider adapters drive the player by RAW postMessage (YouTube enablejsapi=1, Vimeo api=1) + * instead of the YouTube IFrame Player API / Vimeo Player SDK, so the trusted Moodle page + * never loads third-party player script (erseco). The handshake, accessible modal and the + * command/event relay are otherwise unchanged. The child runtime (exe_media_bridge.js + + * exe_media_policy.js, baked into the package by eXeLearning) is unmodified; the interactive + * video iDevice already speaks this contract (window.exeMediaBridge.openMedia → controller). + * Canonical upstream: exelearning/public/app/common/exe_media_bridge/. + * + * @license AGPL-3.0 + */ +(function (root) { + 'use strict'; + + var policy = root.exeMediaPolicy; + var TYPE = policy.TYPE; + var VERSION = policy.VERSION; + + function tr(key) { + return typeof root._ === 'function' ? root._(key) : key; + } + + function isThenable(v) { + return v && typeof v.then === 'function'; + } + + function genNonce(opts) { + if (opts && typeof opts.genId === 'function') return opts.genId(); + if (root.crypto && typeof root.crypto.randomUUID === 'function') return root.crypto.randomUUID(); + return 'n-' + Math.random().toString(36).slice(2); + } + + function makeChannel(opts) { + if (opts && typeof opts.channelFactory === 'function') return opts.channelFactory(); + return new MessageChannel(); + } + + function getInterval(opts) { + return { + set: (opts && opts.setInterval) || (root.setInterval ? root.setInterval.bind(root) : function () {}), + clear: (opts && opts.clearInterval) || (root.clearInterval ? root.clearInterval.bind(root) : function () {}), + }; + } + + // ---- Raw-postMessage provider adapters (NO YouTube IFrame API / Vimeo SDK) ---------- + // mod_exelearning controls the promoted player by posting the providers' documented + // postMessage commands straight to the player iframe and parsing its event messages, so + // the trusted Moodle page never loads third-party player SDK script (erseco, DEC-0067). + // Both providers use JSON-STRING messages. The host polls getCurrentTime()/getDuration() + // every 250ms to emit timeupdate; these adapters keep those values fresh from the + // player's pushed infoDelivery (YouTube) / timeupdate (Vimeo) events. Adapters are still + // injectable (opts.youtubeFactory / opts.vimeoFactory) for tests. + + function num(v) { + return typeof v === 'number' && isFinite(v) ? v : undefined; + } + + function parentOrigin() { + try { + return (root.location && root.location.origin) || ''; + } catch (e) { + return ''; + } + } + + // Pure command builders (exposed for unit tests). Closed action set; null otherwise. + function ytCommand(action, value) { + switch (action) { + case 'play': return { event: 'command', func: 'playVideo', args: [] }; + case 'pause': return { event: 'command', func: 'pauseVideo', args: [] }; + case 'seek': return { event: 'command', func: 'seekTo', args: [Number(value) || 0, true] }; + case 'listen': return { event: 'listening' }; + default: return null; + } + } + function vimeoCommand(action, value) { + switch (action) { + case 'play': return { method: 'play' }; + case 'pause': return { method: 'pause' }; + case 'seek': return { method: 'setCurrentTime', value: Number(value) || 0 }; + default: return null; + } + } + + // Pure inbound-event parsers (exposed for unit tests). Both providers post JSON strings. + function parseRaw(raw) { + if (typeof raw === 'string') { + try { return JSON.parse(raw); } catch (e) { return null; } + } + return raw && typeof raw === 'object' ? raw : null; + } + function parseYtEvent(raw) { + var d = parseRaw(raw); + if (!d || typeof d.event !== 'string') return null; + if (d.event === 'onReady') return { kind: 'ready' }; + if (d.event === 'onError') return { kind: 'error', code: String(d.info != null ? d.info : '') }; + if (d.event === 'infoDelivery' && d.info && typeof d.info === 'object') { + return { kind: 'info', currentTime: num(d.info.currentTime), duration: num(d.info.duration), playerState: num(d.info.playerState) }; + } + if (d.event === 'onStateChange') { + return { kind: 'state', playerState: num(typeof d.info === 'object' && d.info ? d.info.playerState : d.info) }; + } + return null; + } + function parseVimeoEvent(raw) { + var d = parseRaw(raw); + if (!d || typeof d.event !== 'string') return null; + if (d.event === 'ready') return { kind: 'ready' }; + if (d.event === 'timeupdate' && d.data) return { kind: 'timeupdate', currentTime: num(d.data.seconds), duration: num(d.data.duration) }; + if (d.event === 'play') return { kind: 'play' }; + if (d.event === 'pause') return { kind: 'pause' }; + if (d.event === 'ended' || d.event === 'finish') return { kind: 'ended' }; + if (d.event === 'error') return { kind: 'error', code: 'vimeo_error' }; + return null; + } + + function youtubeRawAdapter(container, videoId, cb) { + var doc = container.ownerDocument || root.document; + var origin = parentOrigin(); + var target = 'https://www.youtube-nocookie.com'; + var src = target + '/embed/' + videoId + '?enablejsapi=1&rel=0&modestbranding=1' + + (origin ? '&origin=' + encodeURIComponent(origin) : '') + + (cb.start ? '&start=' + Math.floor(cb.start) : '') + + (cb.autoplay ? '&autoplay=1' : ''); + var frame = doc.createElement('iframe'); + frame.setAttribute('src', src); + frame.setAttribute('allow', 'autoplay; encrypted-media; fullscreen; picture-in-picture'); + frame.setAttribute('allowfullscreen', ''); + frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + frame.style.cssText = 'border:0;width:100%;height:100%;'; + var cache = { currentTime: 0, duration: 0 }; + + function post(msg) { + if (!msg) return; + try { frame.contentWindow.postMessage(JSON.stringify(msg), target); } catch (e) { /* not ready */ } + } + function onMessage(e) { + if (e.source !== frame.contentWindow) return; + var ev = parseYtEvent(e.data); + if (!ev) return; + if (ev.kind === 'ready') { + if (cb.onReady) cb.onReady(cache.duration || undefined); + } else if (ev.kind === 'info') { + if (typeof ev.currentTime === 'number') cache.currentTime = ev.currentTime; + if (typeof ev.duration === 'number') cache.duration = ev.duration; + signalState(ev.playerState); + } else if (ev.kind === 'state') { + signalState(ev.playerState); + } else if (ev.kind === 'error' && cb.onError) { + cb.onError(ev.code); + } + } + function signalState(s) { + if (s === 1 && cb.onPlay) cb.onPlay(); + else if (s === 2 && cb.onPause) cb.onPause(); + else if (s === 0 && cb.onEnded) cb.onEnded(); + } + if (root.addEventListener) root.addEventListener('message', onMessage); + frame.addEventListener('load', function () { post(ytCommand('listen')); }); + container.appendChild(frame); + + return { + play: function () { post(ytCommand('play')); }, + pause: function () { post(ytCommand('pause')); }, + seek: function (t) { post(ytCommand('seek', t)); }, + getCurrentTime: function () { return cache.currentTime; }, + getDuration: function () { return cache.duration; }, + destroy: function () { + if (root.removeEventListener) root.removeEventListener('message', onMessage); + if (frame.parentNode) frame.parentNode.removeChild(frame); + }, + }; + } + + function vimeoRawAdapter(container, videoId, cb) { + var doc = container.ownerDocument || root.document; + var target = 'https://player.vimeo.com'; + var pid = 'exe-vimeo-' + videoId; + var src = target + '/video/' + videoId + '?api=1&player_id=' + encodeURIComponent(pid) + + (cb.autoplay ? '&autoplay=1' : ''); + var frame = doc.createElement('iframe'); + frame.setAttribute('src', src); + frame.setAttribute('id', pid); + frame.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture'); + frame.setAttribute('allowfullscreen', ''); + frame.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); + frame.style.cssText = 'border:0;width:100%;height:100%;'; + var cache = { currentTime: 0, duration: 0 }; + + function post(msg) { + if (!msg) return; + try { frame.contentWindow.postMessage(JSON.stringify(msg), target); } catch (e) { /* not ready */ } + } + function subscribe() { + ['timeupdate', 'play', 'pause', 'ended', 'error'].forEach(function (name) { + post({ method: 'addEventListener', value: name }); + }); + if (cb.start) post(vimeoCommand('seek', cb.start)); + } + function onMessage(e) { + if (e.source !== frame.contentWindow) return; + var ev = parseVimeoEvent(e.data); + if (!ev) return; + if (ev.kind === 'ready') { + subscribe(); + if (cb.onReady) cb.onReady(cache.duration || undefined); + } else if (ev.kind === 'timeupdate') { + if (typeof ev.currentTime === 'number') cache.currentTime = ev.currentTime; + if (typeof ev.duration === 'number') cache.duration = ev.duration; + } else if (ev.kind === 'play' && cb.onPlay) { + cb.onPlay(); + } else if (ev.kind === 'pause' && cb.onPause) { + cb.onPause(); + } else if (ev.kind === 'ended' && cb.onEnded) { + cb.onEnded(); + } else if (ev.kind === 'error' && cb.onError) { + cb.onError(ev.code); + } + } + if (root.addEventListener) root.addEventListener('message', onMessage); + container.appendChild(frame); + + return { + play: function () { post(vimeoCommand('play')); }, + pause: function () { post(vimeoCommand('pause')); }, + seek: function (t) { post(vimeoCommand('seek', t)); }, + getCurrentTime: function () { return cache.currentTime; }, + getDuration: function () { return cache.duration; }, + destroy: function () { + if (root.removeEventListener) root.removeEventListener('message', onMessage); + if (frame.parentNode) frame.parentNode.removeChild(frame); + }, + }; + } + + // ---- Accessible modal ------------------------------------------------------------- + + function buildModal(session) { + var opts = session.opts || {}; + var doc = opts.document || root.document; + var dialog = doc.createElement('dialog'); + dialog.className = 'exe-media-modal'; + dialog.setAttribute('aria-label', tr('Video player')); + try { + dialog.setAttribute('closedby', 'any'); // light-dismiss where supported + } catch (e) { + /* older engines */ + } + + var closeBtn = doc.createElement('button'); + closeBtn.setAttribute('type', 'button'); + closeBtn.className = 'exe-media-modal__close'; + closeBtn.setAttribute('aria-label', tr('Close video')); + closeBtn.textContent = tr('Close'); + closeBtn.addEventListener('click', function () { + requestClose(session); + }); + + var body = doc.createElement('div'); + body.className = 'exe-media-modal__body'; + + dialog.appendChild(closeBtn); + dialog.appendChild(body); + + // Safari light-dismiss fallback: a click whose target is the dialog itself is the backdrop. + dialog.addEventListener('click', function (e) { + if (e.target === dialog) requestClose(session); + }); + // Esc / platform close request. + dialog.addEventListener('close', function () { + if (session.hiding) { + session.hiding = false; + return; + } + requestClose(session); + }); + + (doc.body || doc.documentElement).appendChild(dialog); + if (typeof dialog.showModal === 'function') dialog.showModal(); + + session.dialog = dialog; + return body; + } + + function showModal(session) { + if (session.dialog && typeof session.dialog.showModal === 'function') session.dialog.showModal(); + } + + function hideModal(session) { + if (session.dialog && typeof session.dialog.close === 'function') { + session.hiding = true; + session.dialog.close(); + } + } + + function requestClose(session) { + if (session.closed) return; + session.closed = true; + sendEvent(session, { action: 'closed' }); + destroyAdapter(session); + if (session.dialog) { + if (typeof session.dialog.close === 'function') session.dialog.close(); + if (typeof session.dialog.remove === 'function') session.dialog.remove(); + } + } + + function destroyAdapter(session) { + if (session.pollTimer != null) { + session.interval.clear(session.pollTimer); + session.pollTimer = null; + } + if (session.adapter && session.adapter.destroy) session.adapter.destroy(); + session.adapter = null; + } + + // ---- Media event relay ------------------------------------------------------------ + + function sendEvent(session, ev) { + ev.type = TYPE; + ev.v = VERSION; + if (session.port1) session.port1.postMessage(ev); + } + + function emitTimeupdate(session, ct, dur) { + if (typeof ct === 'number' && isFinite(ct) && typeof dur === 'number' && isFinite(dur)) { + sendEvent(session, { action: 'timeupdate', currentTime: ct, duration: dur }); + } + } + + function startPolling(session) { + if (session.pollTimer != null) return; + session.pollTimer = session.interval.set(function () { + if (!session.adapter) return; + var ct = session.adapter.getCurrentTime(); + var dur = session.adapter.getDuration(); + if (isThenable(ct) || isThenable(dur)) { + Promise.all([Promise.resolve(ct), Promise.resolve(dur)]).then(function (r) { + emitTimeupdate(session, r[0], r[1]); + }); + } else { + emitTimeupdate(session, ct, dur); + } + }, 250); + } + + function openMedia(session, provider, videoId, openOpts) { + var opts = session.opts || {}; + // Single active media per session: discard any previous player/poll-timer/ + // before opening a new one, so repeated 'open' commands cannot stack modals or orphan + // provider players (host-page resource exhaustion). Remove the old dialog directly + // (no .close()) so its 'close' listener does not fire a spurious 'closed' to the child. + destroyAdapter(session); + if (session.dialog) { + if (typeof session.dialog.remove === 'function') session.dialog.remove(); + session.dialog = null; + } + var container = buildModal(session); + var callbacks = { + start: openOpts.start, + autoplay: openOpts.autoplay, + onReady: function (duration) { + sendEvent(session, { action: 'ready', duration: duration }); + startPolling(session); + }, + onPlay: function () { sendEvent(session, { action: 'play' }); }, + onPause: function () { sendEvent(session, { action: 'pause' }); }, + onEnded: function () { sendEvent(session, { action: 'ended' }); }, + onSeeked: function (t) { sendEvent(session, { action: 'seeked', currentTime: t }); }, + onError: function (code) { sendEvent(session, { action: 'error', code: String(code), fatal: true }); }, + }; + var factory = + provider === 'vimeo' + ? opts.vimeoFactory || vimeoRawAdapter + : opts.youtubeFactory || youtubeRawAdapter; + session.adapter = factory(container, videoId, callbacks); + } + + function answerState(session, reqId, field) { + if (!session.adapter) return; + var value = field === 'duration' ? session.adapter.getDuration() : session.adapter.getCurrentTime(); + Promise.resolve(value).then(function (v) { + var ev = { action: 'state', reqId: reqId }; + ev[field === 'duration' ? 'duration' : 'currentTime'] = v; + sendEvent(session, ev); + }); + } + + function processCommand(session, data) { + if (!data || data.type !== TYPE || data.v !== VERSION) return; + if (data.exelearningBridge !== session.nonce) return; // nonce gate + if (!policy.COMMANDS[data.action]) return; + + if (data.action === 'open') { + var url = policy.canonicalEmbedUrl(data.provider, data.videoId); + if (!url) { + sendEvent(session, { action: 'error', code: 'unsupported_provider', fatal: true }); + return; + } + session.closed = false; + openMedia(session, data.provider, data.videoId, { start: data.start, autoplay: data.autoplay }); + return; + } + if (!policy.validateCommand(data, session.nonce)) return; + + var a = session.adapter; + switch (data.action) { + case 'play': if (a) a.play(); break; + case 'pause': if (a) a.pause(); break; + case 'seek': if (a) a.seek(data.t); break; + case 'getCurrentTime': answerState(session, data.reqId, 'currentTime'); break; + case 'getDuration': answerState(session, data.reqId, 'duration'); break; + case 'hide': hideModal(session); break; + case 'show': showModal(session); break; + case 'close': requestClose(session); break; + default: break; + } + } + + // ---- Handshake + attachment ------------------------------------------------------- + + var records = []; + + function teardown(session) { + if (!session) return; + destroyAdapter(session); + if (session.port1 && session.port1.close) session.port1.close(); + if (session.dialog && session.dialog.remove) session.dialog.remove(); + } + + function handleHello(rec, data) { + var existing = rec.session; + if (existing && existing.helloId === data.helloId) return; // duplicate + if (existing) teardown(existing); // new helloId → re-pair + + var nonce = genNonce(rec.opts); + var channel = makeChannel(rec.opts); + var session = { + iframe: rec.iframe, + opts: rec.opts, + nonce: nonce, + helloId: data.helloId, + port1: channel.port1, + adapter: null, + dialog: null, + pollTimer: null, + hiding: false, + closed: false, + interval: getInterval(rec.opts), + }; + rec.session = session; + + channel.port1.onmessage = function (e) { + processCommand(session, e.data); + }; + if (typeof channel.port1.start === 'function') channel.port1.start(); + + rec.iframe.contentWindow.postMessage( + { type: TYPE, v: VERSION, action: 'welcome', helloId: data.helloId, exelearningBridge: nonce }, + '*', + [channel.port2], + ); + } + + function onWindowMessage(rec, e) { + if (!rec.iframe || !rec.iframe.contentWindow || e.source !== rec.iframe.contentWindow) return; + if (policy.isHello(e.data)) handleHello(rec, e.data); + } + + /** + * Register an iframe that renders eXeLearning content and start relaying its media. + * @returns {{detach: function}} handle + */ + function attach(iframe, opts) { + opts = opts || {}; + var win = opts.win || root; + var rec = { iframe: iframe, opts: opts, session: null, win: win, handler: null }; + rec.handler = function (e) { + onWindowMessage(rec, e); + }; + if (win.addEventListener) win.addEventListener('message', rec.handler); + records.push(rec); + return { + detach: function () { + if (win.removeEventListener) win.removeEventListener('message', rec.handler); + teardown(rec.session); + var i = records.indexOf(rec); + if (i >= 0) records.splice(i, 1); + }, + }; + } + + function resetForTests() { + for (var i = 0; i < records.length; i++) { + var rec = records[i]; + if (rec.win && rec.win.removeEventListener) rec.win.removeEventListener('message', rec.handler); + teardown(rec.session); + } + records = []; + } + + root.exeMediaHost = { + attach: attach, + buildModal: buildModal, + _youtubeAdapter: youtubeRawAdapter, + _vimeoAdapter: vimeoRawAdapter, + _ytCommand: ytCommand, + _vimeoCommand: vimeoCommand, + _parseYtEvent: parseYtEvent, + _parseVimeoEvent: parseVimeoEvent, + _processCommand: processCommand, + _resetForTests: resetForTests, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/exe_media_policy.js b/js/exe_media_policy.js new file mode 100644 index 0000000..34cf942 --- /dev/null +++ b/js/exe_media_policy.js @@ -0,0 +1,258 @@ +/** + * exe_media_policy — pure, framework-free policy for the external-media bridge. + * + * Single source of truth shared by: + * - the child runtime (exe_media_bridge.js) that runs inside exported content, and + * - the reference parent relay (exe-media-host.js) that runs in the trusted host page. + * + * Responsibilities (all pure — no DOM mutation, no postMessage): + * - Detect and normalize supported external media (YouTube, Vimeo) into a neutral + * descriptor; recognize PDFs for a deferred open-in-new-tab fallback. + * - Rebuild canonical, privacy-friendly embed URLs from a bare provider id, so the + * bridge never has to trust an attacker-supplied URL (kills redirect-laundering). + * - Validate the versioned postMessage/MessageChannel contract (handshake, commands, + * events) with a strict schema, a closed action enum and a provider allowlist. + * + * This file is a CLASSIC browser script (no ES module syntax) so it can be shipped + * verbatim inside exported packages and loaded over file:// — while still being + * importable (for its side effect) by the Vitest suite, which reads the global it sets. + * + * @license AGPL-3.0 + */ +(function (root) { + 'use strict'; + + var TYPE = 'exe-media'; + var VERSION = 1; + + // Bare-id shapes. YouTube ids are exactly 11 url-safe chars; Vimeo ids are numeric. + var ID_RE = { + youtube: /^[A-Za-z0-9_-]{11}$/, + vimeo: /^[0-9]{6,12}$/, + }; + + // Host allow-lists per provider. Anything else never matches a provider, so IPs, + // loopback, *.local and look-alike domains (evil.com/watch?v=...) cannot slip in. + var YOUTUBE_HOSTS = { + 'youtube.com': 1, + 'www.youtube.com': 1, + 'm.youtube.com': 1, + 'music.youtube.com': 1, + 'youtube-nocookie.com': 1, + 'www.youtube-nocookie.com': 1, + }; + var YOUTU_BE_HOSTS = { 'youtu.be': 1, 'www.youtu.be': 1 }; + var VIMEO_HOSTS = { 'vimeo.com': 1, 'www.vimeo.com': 1 }; + var VIMEO_PLAYER_HOSTS = { 'player.vimeo.com': 1 }; + + // Closed action enums. Anything outside these is dropped before any work happens. + var COMMANDS = { open: 1, play: 1, pause: 1, seek: 1, getCurrentTime: 1, getDuration: 1, hide: 1, show: 1, close: 1 }; + var EVENTS = { ready: 1, play: 1, pause: 1, ended: 1, timeupdate: 1, seeked: 1, state: 1, error: 1, closed: 1 }; + + function isObject(v) { + return v !== null && typeof v === 'object'; + } + + function finiteNonNeg(n) { + return typeof n === 'number' && isFinite(n) && n >= 0; + } + + function isAllowedProvider(provider) { + return provider === 'youtube' || provider === 'vimeo'; + } + + function isValidVideoId(provider, id) { + var re = ID_RE[provider]; + return !!re && typeof id === 'string' && re.test(id); + } + + /** + * Build the canonical, privacy-friendly embed URL for a provider id. Returns null + * for unknown providers or ids that fail the provider's shape — so a malicious id + * can never be templated into a live URL. + */ + function canonicalEmbedUrl(provider, id) { + if (provider === 'youtube' && isValidVideoId('youtube', id)) { + return 'https://www.youtube-nocookie.com/embed/' + id; + } + if (provider === 'vimeo' && isValidVideoId('vimeo', id)) { + return 'https://player.vimeo.com/video/' + id; + } + return null; + } + + function firstPathSegment(pathname) { + var parts = pathname.split('/'); + for (var i = 0; i < parts.length; i++) { + if (parts[i]) return parts[i]; + } + return ''; + } + + function youtubeIdFromUrl(u) { + var host = u.hostname.toLowerCase(); + if (YOUTU_BE_HOSTS[host]) { + return firstPathSegment(u.pathname); + } + if (YOUTUBE_HOSTS[host]) { + var v = u.searchParams.get('v'); + if (v) return v; + var m = u.pathname.match(/^\/(?:embed|v|shorts)\/([^/?#]+)/); + if (m) return m[1]; + } + return ''; + } + + function vimeoIdFromUrl(u) { + var host = u.hostname.toLowerCase(); + if (VIMEO_PLAYER_HOSTS[host]) { + var m = u.pathname.match(/^\/video\/([0-9]+)/); + if (m) return m[1]; + } + if (VIMEO_HOSTS[host]) { + return firstPathSegment(u.pathname); + } + return ''; + } + + function descriptor(provider, id, originalUrl, embedUrl, extra) { + var d = { + provider: provider, + providerVideoId: id, + originalUrl: originalUrl, + embedUrl: embedUrl, + aspectRatio: provider === 'pdf' ? undefined : '16:9', + interactive: false, + requiresBridge: provider !== 'pdf', + }; + if (extra && extra.title) d.title = extra.title; + return d; + } + + /** + * Normalize a URL string or an element (iframe/anchor) into a neutral media + * descriptor, or null when it is not a supported external embed. + */ + function parseExternalMedia(input) { + var url = input; + var title; + if (input && typeof input.getAttribute === 'function') { + url = input.getAttribute('src') || input.getAttribute('href') || ''; + title = input.getAttribute('title') || undefined; + } + if (typeof url !== 'string' || !url) return null; + + var u; + try { + u = new URL(url); + } catch (e) { + return null; + } + // Reject userinfo (https://user@host/...) and non-web schemes outright. + if (u.username || u.password) return null; + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + + var ytId = youtubeIdFromUrl(u); + if (ytId) { + if (!isValidVideoId('youtube', ytId)) return null; + return descriptor('youtube', ytId, url, canonicalEmbedUrl('youtube', ytId), { title: title }); + } + + var vId = vimeoIdFromUrl(u); + if (vId) { + if (!isValidVideoId('vimeo', vId)) return null; + return descriptor('vimeo', vId, url, canonicalEmbedUrl('vimeo', vId), { title: title }); + } + + if (u.pathname.toLowerCase().slice(-4) === '.pdf') { + return descriptor('pdf', null, url, url, { title: title }); + } + + return null; + } + + function hasEnvelope(d) { + return isObject(d) && d.type === TYPE && d.v === VERSION; + } + + function isHello(d) { + return hasEnvelope(d) && d.action === 'hello' && typeof d.helloId === 'string' && d.helloId.length > 0; + } + + function isWelcome(d) { + return ( + hasEnvelope(d) && + d.action === 'welcome' && + typeof d.helloId === 'string' && + typeof d.exelearningBridge === 'string' && + d.exelearningBridge.length > 0 + ); + } + + /** + * Validate a child→parent command. Defense in depth: envelope + a present, matching + * nonce (the `!!expectedNonce` guard prevents an absent expected nonce from ever + * authenticating a forged message) + closed action enum + per-action payload checks. + */ + function validateCommand(d, expectedNonce) { + if (!hasEnvelope(d)) return false; + if (!expectedNonce) return false; + if (typeof d.exelearningBridge !== 'string' || d.exelearningBridge !== expectedNonce) return false; + if (!COMMANDS[d.action]) return false; + switch (d.action) { + case 'open': + return ( + Number.isInteger(d.reqId) && + isAllowedProvider(d.provider) && + isValidVideoId(d.provider, d.videoId) && + (d.start == null || finiteNonNeg(d.start)) + ); + case 'seek': + return finiteNonNeg(d.t); + case 'getCurrentTime': + case 'getDuration': + return Number.isInteger(d.reqId); + default: + return true; // play / pause / hide / show / close — no payload + } + } + + /** + * Validate a parent→child event. Same envelope + closed enum + per-action payload. + */ + function validateEvent(d) { + if (!hasEnvelope(d)) return false; + if (!EVENTS[d.action]) return false; + switch (d.action) { + case 'timeupdate': + return finiteNonNeg(d.currentTime) && finiteNonNeg(d.duration); + case 'seeked': + return finiteNonNeg(d.currentTime); + case 'state': + return Number.isInteger(d.reqId); + case 'ready': + return d.duration == null || finiteNonNeg(d.duration); + case 'error': + return typeof d.code === 'string' && typeof d.fatal === 'boolean'; + default: + return true; // play / pause / ended / closed + } + } + + var api = { + TYPE: TYPE, + VERSION: VERSION, + COMMANDS: COMMANDS, + EVENTS: EVENTS, + parseExternalMedia: parseExternalMedia, + canonicalEmbedUrl: canonicalEmbedUrl, + isAllowedProvider: isAllowedProvider, + isValidVideoId: isValidVideoId, + isHello: isHello, + isWelcome: isWelcome, + validateCommand: validateCommand, + validateEvent: validateEvent, + }; + + root.exeMediaPolicy = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/scorm_bridge_relay.js b/js/scorm_bridge_relay.js new file mode 100644 index 0000000..c444a5a --- /dev/null +++ b/js/scorm_bridge_relay.js @@ -0,0 +1,253 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Parent-side SCORM bridge relay for the secure (opaque-origin) package mode. + * + * Injected inline by view.php (secure mode only) so its message listener is in place + * before the package iframe loads. It is the trusted half of the bridge: the iframe + * runs in an opaque origin and CANNOT reach this page, so the only thing it can do is + * postMessage buffered SCORM scores here. This relay validates every message and, for + * accepted ones, performs the authenticated track.php request (keeping the sesskey on + * this trusted side) plus a sendBeacon flush on pagehide. + * + * Validation (defence in depth — track.php re-validates and clamps server-side): + * - event.source === iframe.contentWindow (window identity, the primary anchor: + * no other window can forge it, and an opaque origin has no useful event.origin); + * - type === 'scorm' and action in the closed list {ready, track}; + * - a per-view nonce on 'track' messages; + * - the payload shape (cmi is an object). + * Unknown or invalid messages are ignored silently. + * + * Exposed two ways from a single body: window.exeScormBridge (browser bootstrap) and + * module.exports (Vitest). See research ADR DEC-0059. + */ +(function () { + 'use strict'; + + /** + * Whether a payload is a child -> parent score message (shape only). + * + * @param {*} data The event.data value. + * @returns {boolean} True for {type:'scorm', action:'track', cmi:{...}}. + */ + function isTrackMessage(data) { + return !!data && data.type === 'scorm' && data.action === 'track' + && !!data.cmi && typeof data.cmi === 'object'; + } + + /** + * Whether a payload is the child's readiness announcement. + * + * @param {*} data The event.data value. + * @returns {boolean} True for {type:'scorm', action:'ready'}. + */ + function isReadyMessage(data) { + return !!data && data.type === 'scorm' && data.action === 'ready'; + } + + /** + * Whether a 'track' message should be accepted: correct shape AND matching nonce. + * Pure, so it can be unit-tested without a DOM. The caller is still responsible + * for the window-identity check (which cannot be expressed on data alone). + * + * @param {*} data The event.data value. + * @param {string} expectednonce The per-view nonce handed to the iframe. + * @returns {boolean} True when the message is a valid, authenticated track message. + */ + function acceptTrack(data, expectednonce) { + // The !!expectednonce guard means an empty/undefined expected nonce can never + // authenticate: otherwise a forged message that simply omits the field would + // satisfy undefined === undefined and collapse the nonce factor. + return isTrackMessage(data) && !!expectednonce && data.exelearningBridge === expectednonce; + } + + /** + * Create a relay bound to a config + (injectable) environment. + * + * @param {Object} config {iframeid, cmid, trackurl, session, nonce, blockedid, + * disableTracking}. + * @param {Object} [deps] {document, window, fetch, sendBeacon} for testing. + * @returns {Object} {init, onMessage, flushBeacon, postTrack, acceptTrack}. + */ + function createRelay(config, deps) { + config = config || {}; + deps = deps || {}; + var doc = deps.document || (typeof document !== 'undefined' ? document : null); + var win = deps.window || (typeof window !== 'undefined' ? window : null); + var fetchImpl = deps.fetch || (win && win.fetch ? win.fetch.bind(win) : null); + var beacon = deps.sendBeacon + || (win && win.navigator && win.navigator.sendBeacon + ? win.navigator.sendBeacon.bind(win.navigator) : null); + + var iframeid = config.iframeid; + var trackurl = config.trackurl; + var cmid = config.cmid; + var session = config.session; + var nonce = config.nonce; + var blockedid = config.blockedid; + // xAPI-primary (DEC-0065): keep the bridge fully live (handshake, window.API, + // watchdog) but forward NO SCORM score, because the package is graded + // via xAPI. The decision lives here, on the trusted parent, not in the baked-in + // shim — so it holds even for a package whose shim predates this flag. + var disabletracking = config.disableTracking === true; + var watchdogms = config.watchdogms || 8000; + var gracems = config.gracems || 2500; + var latest = null; + var watchdog = null; + var sawready = false; + + function iframe() { return doc ? doc.getElementById(iframeid) : null; } + + // Watchdog: if the in-iframe shim never announces 'ready' (e.g. an opaque-origin + // iframe the environment cannot serve, like a PHP-WASM service-worker host that + // does not control opaque subframes, so the token URL falls through to a 404), + // reveal the "blocked by security configuration" notice instead of silently + // degrading to the weaker same-origin mode (DEC-0060). + function showBlocked() { + var b = (doc && blockedid) ? doc.getElementById(blockedid) : null; + if (b) { b.style.display = ''; } + var fr = iframe(); + if (fr) { fr.style.display = 'none'; } + } + // Decide between two timing signals so the notice never sits behind a long blank + // wait. The iframe element fires 'load' even when its navigation ended in an error + // page (e.g. the 404 above), so once it has loaded we only grant a short grace for + // the shim to handshake; if 'load' never fires we still fall back to watchdogms. + function armBlockedTimer(ms) { + if (!win || !win.setTimeout) { return null; } + return win.setTimeout(function () { if (!sawready) { showBlocked(); } }, ms); + } + function startWatchdog() { + if (!win || !win.setTimeout || !blockedid) { return; } + watchdog = armBlockedTimer(watchdogms); + // Faster, load-driven path. The iframe may not be parsed yet when this relay + // runs inline (it is injected before the iframe element), so attach now if it + // exists, otherwise once the DOM is ready. + var onload = function () { if (!sawready) { armBlockedTimer(gracems); } }; + var attach = function () { + var fr = iframe(); + if (fr && fr.addEventListener) { fr.addEventListener('load', onload, false); return true; } + return false; + }; + if (!attach() && doc && doc.addEventListener) { + doc.addEventListener('DOMContentLoaded', attach, false); + } + } + function clearWatchdog() { + sawready = true; + if (watchdog && win && win.clearTimeout) { win.clearTimeout(watchdog); watchdog = null; } + } + + function buildBody(cmi, itemscores) { + // Mirror the legacy track.php payload, but identity (cmid in the query, + // sesskey, mode) lives on this trusted parent — only the CMI buffer and + // per-iDevice scores come from the iframe. + return JSON.stringify({ + id: cmid, + session: session, + cmi: cmi, + itemscores: itemscores || {} + }); + } + + function postTrack(cmi, itemscores) { + var body = buildBody(cmi, itemscores); + latest = body; + if (!fetchImpl) { return; } + try { + fetchImpl(trackurl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body, + credentials: 'same-origin', + keepalive: true + }).catch(function () { /* parent retries on the next commit / pagehide beacon. */ }); + } catch (e) { /* ignore */ } + } + + function flushBeacon() { + if (!latest || !beacon) { return; } + try { beacon(trackurl, new Blob([latest], { type: 'application/json' })); } catch (e) { /* ignore */ } + } + + function onMessage(e) { + var fr = iframe(); + // Window identity (primary anchor). The explicit contentWindow check rejects + // the degenerate null === null match if the frame is present but not navigable. + if (!fr || !fr.contentWindow || e.source !== fr.contentWindow) { return; } + var data = e.data; + if (isReadyMessage(data)) { + clearWatchdog(); // The iframe is alive; secure mode rendered. + try { + fr.contentWindow.postMessage({ + type: 'scorm', + action: 'config', + nonce: nonce + }, '*'); + } catch (e2) { /* ignore */ } + return; + } + if (!acceptTrack(data, nonce)) { return; } // type + action + nonce + shape. + // xAPI-primary: validate the message but suppress the SCORM POST. The shim + // cannot reach track.php itself (opaque origin, no sesskey), so this is the + // single authoritative drop point — no double grading with the xAPI channel. + if (disabletracking) { return; } + postTrack(data.cmi, data.itemscores); + } + + function init() { + if (win && win.addEventListener) { + win.addEventListener('message', onMessage, false); + win.addEventListener('pagehide', flushBeacon, false); + } + startWatchdog(); + } + + return { + init: init, + onMessage: onMessage, + flushBeacon: flushBeacon, + postTrack: postTrack, + acceptTrack: acceptTrack, + startWatchdog: startWatchdog, + showBlocked: showBlocked + }; + } + + /** + * Bootstrap: create a relay from config and start listening. + * + * @param {Object} config See createRelay. + * @returns {Object} The relay instance. + */ + function init(config) { + var relay = createRelay(config); + relay.init(); + return relay; + } + + var exp = { + isTrackMessage: isTrackMessage, + isReadyMessage: isReadyMessage, + acceptTrack: acceptTrack, + createRelay: createRelay, + init: init + }; + // Test runner (Vitest/Node) consumes module.exports. + if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } + // Browser bootstrap (view.php) consumes window.exeScormBridge. + if (typeof window !== 'undefined') { window.exeScormBridge = exp; } +})(); diff --git a/js/scorm_bridge_shim.js b/js/scorm_bridge_shim.js new file mode 100644 index 0000000..956b3e3 --- /dev/null +++ b/js/scorm_bridge_shim.js @@ -0,0 +1,245 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * In-iframe SCORM bridge shim for the secure (opaque-origin) package mode. + * + * This script is baked into every extracted package (copied to libs/ and injected + * at the top of by \mod_exelearning\local\scorm\scorm_injector) and runs + * INSIDE the package iframe. It self-activates only when the iframe is a sandboxed, + * opaque-origin document (secure mode, view.php drops allow-same-origin); in the + * legacy same-origin mode it stays dormant so eXeLearning's pipwerks walks up to + * the window.API hosted by the Moodle parent, exactly as before. + * + * When active it: + * 1. Installs an in-memory localStorage/sessionStorage polyfill, because an + * opaque-origin document throws SecurityError on real web storage and several + * shipped engine scripts (libs/exe_atools, exe_export.js, the checklist iDevice, + * edicuatex) touch it. The polyfill keeps them working for the session. + * 2. Defines a local window.API (the SCORM 1.2 surface from js/scorm_tracker.js) + * whose buffered scores are posted to the Moodle parent over postMessage instead + * of being XHR'd here. The parent (js/scorm_bridge_relay.js) holds the sesskey + * and performs the authenticated track.php request; this iframe never sees it. + * 3. Performs a handshake: it announces 'ready' and the parent replies with a nonce + * that authenticates subsequent score messages. Teacher-mode visibility is NOT + * handled here: the package hides teacher content by default and the host appends + * ?exe-teacher=1 to the iframe src to reveal the selector (read from the package's + * own location.search, which works even under the opaque origin). + * + * Exposed two ways from a single body: window.exeScormBridgeShim (browser, with an + * auto-boot that is a no-op outside an opaque sandbox) and module.exports (Vitest). + * See research ADR DEC-0059. + */ +(function () { + 'use strict'; + + /** + * Build an in-memory Storage-like object (getItem/setItem/removeItem/clear/key + * + length), used to shadow the native web storage that throws in an opaque + * origin. Values are coerced to strings, matching the Storage contract. + * + * @returns {Object} A minimal in-memory Storage implementation. + */ + function createMemoryStorage() { + var store = {}; + var api = { + getItem: function (k) { + k = String(k); + return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; + }, + setItem: function (k, v) { store[String(k)] = String(v); }, + removeItem: function (k) { delete store[String(k)]; }, + clear: function () { store = {}; }, + key: function (i) { + var keys = Object.keys(store); + return (i >= 0 && i < keys.length) ? keys[i] : null; + } + }; + Object.defineProperty(api, 'length', { get: function () { return Object.keys(store).length; } }); + return api; + } + + /** + * Detect whether the current window is a sandboxed, opaque-origin document + * (secure mode). Opaque origins serialize to the string "null"; as a secondary + * probe, web storage access throws a SecurityError (ONLY that — a QuotaExceededError + * or a disabled-storage policy in a real same-origin iframe must not count). Either + * signal means "activate". + * + * @param {Window} win The window to test (default: the global window). + * @returns {boolean} True when running in an opaque sandbox. + */ + function isSandboxedOpaque(win) { + win = win || (typeof window !== 'undefined' ? window : null); + if (!win) { return false; } + try { + if (win.origin === 'null' || (win.location && win.location.origin === 'null')) { return true; } + } catch (e) { return true; } + try { + var probe = '__exeprobe__'; + win.localStorage.setItem(probe, '1'); + win.localStorage.removeItem(probe); + } catch (e2) { + // Only an opaque origin denies web storage with a SecurityError. A + // QuotaExceededError (storage full) or a browser/policy that blocks first-party + // storage in a REAL same-origin iframe also throws here; treating those as + // "opaque" would activate the shim in legacy mode (where no parent relay listens), + // so buffered scores would post into a postMessage void and be silently lost. + return !!(e2 && e2.name === 'SecurityError'); + } + return false; + } + + /** + * Replace win.localStorage/win.sessionStorage with in-memory polyfills so package + * scripts that touch web storage do not throw in an opaque origin. Best effort: + * if the property cannot be redefined, content storage access may still throw, but + * grading (which never relies on web storage) is unaffected. + * + * @param {Window} win The window to patch. + */ + function installStoragePolyfill(win) { + var names = ['localStorage', 'sessionStorage']; + for (var i = 0; i < names.length; i++) { + var name = names[i]; + var mem = createMemoryStorage(); + try { + Object.defineProperty(win, name, { + configurable: true, + get: (function (m) { return function () { return m; }; })(mem) + }); + } catch (e) { + try { win[name] = mem; } catch (e2) { /* give up; see docblock. */ } + } + } + } + + /** + * Whether a postMessage payload is a recognised parent -> child control message. + * + * @param {*} data The event.data value. + * @returns {boolean} True for a {type:'scorm', action:'config'|'ack'} message. + */ + function isParentMessage(data) { + return !!data && data.type === 'scorm' && (data.action === 'config' || data.action === 'ack'); + } + + /** + * Wire the local window.API to the Moodle parent over postMessage. Requires + * win.exeScormTracker (js/scorm_tracker.js) to be loaded first. + * + * @param {Window} win The (opaque-origin) iframe window. + * @returns {Object|null} Handles for testing, or null if the tracker is missing. + */ + function activate(win) { + var tracker = win.exeScormTracker; + if (!tracker) { return null; } + + var parentwin = win.parent; + var nonce = null; + var ready = false; + var queue = []; + + function postToParent(msg) { + try { + if (parentwin && parentwin.postMessage) { parentwin.postMessage(msg, '*'); } + } catch (e) { /* ignore */ } + } + + // Bridge transport handed to the shared tracker: forward buffered scores to + // the parent. Identity (cmid, sesskey) lives in the parent; only the CMI + // buffer + per-iDevice scores cross the bridge, and track.php re-validates + // and clamps them server-side. Fire-and-forget; queued until the handshake + // delivers the nonce. + function transport(data) { + var msg = { + exelearningBridge: nonce, + type: 'scorm', + action: 'track', + cmi: data.cmi, + itemscores: data.itemscores + }; + if (ready) { postToParent(msg); } else { queue.push(msg); } + return true; + } + + var instance = tracker.createScormApi({ + transport: transport, + // Resolve per-iDevice objectids from THIS document (same frame), not the + // parent's iframe element. + getScoringDocument: function () { return win.document; }, + // No beforeunload flush here: postMessage is async and may not reach the + // parent during unload. The parent's pagehide sendBeacon (relay) is the + // reliable unload safety net instead. + bindUnload: false + }); + win.API = instance.api; + + function onMessage(e) { + if (e.source !== parentwin) { return; } // Only trust the hosting Moodle frame. + var data = e.data; + if (!isParentMessage(data)) { return; } + if (data.action === 'config') { + nonce = data.nonce; + ready = true; + while (queue.length) { + var m = queue.shift(); + m.exelearningBridge = nonce; + postToParent(m); + } + } + } + + if (win.addEventListener) { win.addEventListener('message', onMessage, false); } + // Announce readiness; the parent replies with the nonce. + postToParent({ exelearningBridge: null, type: 'scorm', action: 'ready' }); + + return { + api: instance.api, + transport: transport, + onMessage: onMessage + }; + } + + /** + * Boot the shim: activate only inside an opaque sandbox. No-op otherwise (legacy + * same-origin mode, or any non-sandboxed context such as the test runner). + * + * @param {Window} win The window to boot in (default: the global window). + * @returns {Object|null} The activate() handles when activated, else null. + */ + function boot(win) { + win = win || (typeof window !== 'undefined' ? window : null); + if (!win || !isSandboxedOpaque(win)) { return null; } + installStoragePolyfill(win); + return activate(win); + } + + var exp = { + createMemoryStorage: createMemoryStorage, + isSandboxedOpaque: isSandboxedOpaque, + installStoragePolyfill: installStoragePolyfill, + isParentMessage: isParentMessage, + activate: activate, + boot: boot + }; + // Test runner (Vitest/Node) consumes module.exports. + if (typeof module !== 'undefined' && module.exports) { module.exports = exp; } + // Browser: expose for inspection and auto-boot (no-op outside an opaque sandbox). + if (typeof window !== 'undefined') { + window.exeScormBridgeShim = exp; + boot(window); + } +})(); diff --git a/js/scorm_tracker.js b/js/scorm_tracker.js index 46d2595..3c83d89 100644 --- a/js/scorm_tracker.js +++ b/js/scorm_tracker.js @@ -133,6 +133,12 @@ * - cmid, trackurl, session: identity and endpoint. * - getScoringDocument(): returns the iframe content document (default: reads * #exelearningobject) for objectid resolution. + * - transport(data, sync): optional sink for the buffered scores. When provided + * it REPLACES the direct XHR (secure mode: js/scorm_bridge_shim.js posts the + * data to the Moodle parent, which owns the authenticated request). It gets + * {cmi, itemscores} and a sync flag, and returns false to signal failure + * (keeps the buffer dirty for retry). When absent, the XHR path below runs + * (legacy same-origin mode and the unit tests). * - xhrFactory(): returns an XMLHttpRequest-like object (default: real XHR). * - setTimeout / clearTimeout: timer functions (default: globals). * - bindUnload: wire a beforeunload synchronous flush (default: true in a browser). @@ -148,6 +154,7 @@ var setTimeoutFn = config.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null); var clearTimeoutFn = config.clearTimeout || (typeof clearTimeout !== 'undefined' ? clearTimeout : null); var xhrFactory = config.xhrFactory || function () { return new XMLHttpRequest(); }; + var transport = config.transport || null; var getScoringDocument = config.getScoringDocument || function () { var fr = (typeof document !== 'undefined') && document.getElementById('exelearningobject'); return fr && fr.contentDocument; @@ -168,7 +175,19 @@ // xAPI-primary packages keep window.API alive but never POST (DEC-0064). if (disableTracking) { dirty = false; return true; } if (!dirty) { return true; } - var snapshot = JSON.stringify(cmi); + // Bridge transport (secure mode): hand the buffered CMI + per-iDevice + // scores to the injected sink instead of doing the XHR here. The sink + // (js/scorm_bridge_shim.js) posts them to the Moodle parent, which owns + // the authenticated track.php request, retry and the pagehide beacon. + // Fire-and-forget: clear dirty once the message leaves; a thrown/false + // result keeps it dirty so the next autocommit re-sends it. + if (transport) { + try { + var accepted = transport({ cmi: cmi, itemscores: itemScores }, sync === true); + if (accepted !== false) { dirty = false; return true; } + return false; + } catch (te) { errCode = '101'; return false; } + } var payload = buildPayload(cmid, session, cmi, itemScores); try { var xhr = xhrFactory(); @@ -186,6 +205,9 @@ // and only if no newer value was buffered meanwhile. On failure dirty // stays set so the next autocommit / beforeunload re-sends it (a failed // autocommit must never silently drop a grade write to the gradebook). + // Snapshot the buffer here (this path only) — captured synchronously + // before xhr.send() below, so the onload comparison value is unchanged. + var snapshot = JSON.stringify(cmi); xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300 && JSON.stringify(cmi) === snapshot) { diff --git a/js/xapi_listener.js b/js/xapi_listener.js index a9b5818..842d0a0 100644 --- a/js/xapi_listener.js +++ b/js/xapi_listener.js @@ -97,6 +97,17 @@ var mode = config.mode || 'grading'; var allowed = config.allowedOrigin || ((typeof window !== 'undefined' && window.location) ? window.location.origin : ''); + // Trust gate. Legacy (same-origin) trusts a statement by event.origin === host + // origin (RIE-013). Secure mode (DEC-0065) serves the package in an opaque origin + // where event.origin is the string "null", so origin can never authenticate; there + // the anchor is WINDOW IDENTITY — event.source === the package iframe's + // contentWindow, exactly like the SCORM bridge relay (js/scorm_bridge_relay.js). + // Setting iframeid (or, for tests, expectedSource) selects window-identity mode; + // otherwise the origin check is used. + var iframeid = config.iframeid || null; + var injectedsource = config.expectedSource; // explicit window (tests) or null/undefined + var usewindowidentity = !!(iframeid || (injectedsource !== undefined && injectedsource !== null)); + var docref = config.document || (typeof document !== 'undefined' ? document : null); var xhrFactory = config.xhrFactory || function () { return new XMLHttpRequest(); }; // Bounded resend so a transient non-2xx / network blip does not silently lose a // grade-bearing statement — js/scorm_tracker.js self-heals the same way (a failed @@ -153,9 +164,34 @@ }, retryDelay * (attempt + 1)); } + // Resolve the only window allowed to deliver statements in window-identity mode. + // Lazy (per message): view.php injects this listener inline BEFORE the iframe + // element exists, but the element is present by the time the package emits. + function expectedSource() { + if (injectedsource !== undefined && injectedsource !== null) { return injectedsource; } + if (iframeid && docref) { + var el = docref.getElementById(iframeid); + return el ? el.contentWindow : null; + } + return null; + } + + // Whether an event may be forwarded: window identity in secure mode (the opaque + // "null" origin is ignored), or an exact host origin in legacy mode. event.source + // is set by the browser to the posting window and cannot be forged by page script, + // so it is a sound anchor when the origin is unusable (DEC-0065). + function isTrusted(event) { + if (!event) { return false; } + if (usewindowidentity) { + var src = expectedSource(); + return !!src && event.source === src; + } + return isTrustedOrigin(event.origin, allowed); + } + // Validate, de-dup and forward a single message. Returns true when forwarded. function handleMessage(event) { - if (!event || !isTrustedOrigin(event.origin, allowed)) { return false; } + if (!isTrusted(event)) { return false; } if (!isStatementMessage(event.data)) { return false; } var statement = event.data.statement; var id = statement.id; diff --git a/lang/ca/exelearning.php b/lang/ca/exelearning.php index b6194ab..8e5145a 100644 --- a/lang/ca/exelearning.php +++ b/lang/ca/exelearning.php @@ -100,6 +100,10 @@ $string['embeddededitorstatus'] = 'Editor incrustat'; $string['embeddednotinstalledadmin'] = 'Els fitxers de l\'editor integrat no estan instal·lats. Podeu instal·lar-lo des de la configuració del connector.'; $string['embeddednotinstalledcontactadmin'] = 'Els fitxers de l\'editor integrat no estan instal·lats. Contacteu amb l\'administrador del lloc per instal·lar-lo.'; +$string['embedmode'] = '~Política d\'insercions externes'; +$string['embedmode_desc'] = '~En el mode segur, els vídeos i PDF externs es mostren promocionant-los a un reproductor aïllat i d\'origen creuat a la pàgina de l\'activitat, que el navegador manté aïllat de Moodle sigui quin sigui el proveïdor. «Obre» (recomanat) permet qualsevol proveïdor https d\'origen creuat, de manera que YouTube, Vimeo, Dailymotion, EducaMadrid i qualsevol altre lloc funcionen sense necessitat de mantenir cap llista. «Estricte» només permet una llista integrada de proveïdors coneguts; useu-lo quan no es confiï que els autors de contingut no insereixin pàgines de pesca (phishing) o de seguiment. Tots dos modes rebutgen les URL del mateix origen, d\'IP/bucle local i amb informació d\'usuari.'; +$string['embedmode_open'] = '~Obre (qualsevol proveïdor https d\'origen creuat)'; +$string['embedmode_strict'] = '~Estricte (només la llista integrada de proveïdors)'; $string['err_grademinmax'] = '~La qualificació mínima no pot ser superior a la màxima.'; $string['err_gradepassrange'] = '~La qualificació per aprovar ha d\'estar entre la qualificació mínima i la màxima.'; $string['err_nocontentxml'] = '~El fitxer pujat no és un paquet eXeLearning vàlid: ha de ser un .elpx o un .zip que contingui content.xml a l\'arxiu.'; @@ -250,6 +254,10 @@ $string['gradepass_help'] = '~La qualificació global mínima necessària per aprovar. Quan s\'habilita la condició de finalització «Requereix qualificació per aprovar», l\'activitat es marca com a completada (a l\'estil SCORM) quan l\'estudiant assoleix aquesta qualificació. Deixeu-ho a 0 per desactivar la finalització basada en l\'aprovat.'; $string['gradesetchangedwarning'] = '~El contingut qualificable d\'aquesta activitat ha canviat i alguns estudiants ja tenen intents. Les qualificacions existents es conserven tal com estaven i no es recalculen amb el nou contingut. Si els canvis fan que aquestes qualificacions siguin enganyoses, elimineu els intents afectats per recalcular-les.'; $string['gradingheading'] = '~Qualificació'; +$string['iframemode'] = '~Mode de seguretat de l\'iframe del paquet'; +$string['iframemode_desc'] = '~Controla com s\'incrusta el paquet eXeLearning a la pàgina de l\'activitat. «Segur» (recomanat) executa el paquet en un iframe aïllat d\'origen opac, de manera que el seu JavaScript no pot llegir ni modificar la pàgina de Moodle circumdant, les seves galetes ni la sessió; la puntuació SCORM es transmet a Moodle a través d\'un canal postMessage validat. «Heretat» manté el comportament anterior del mateix origen i només s\'hauria d\'usar si un paquet concret funciona malament en el mode segur (per exemple, perquè depèn de l\'emmagatzematge del navegador que no està disponible per a un iframe aïllat).'; +$string['iframemode_legacy'] = '~Heretat (mateix origen)'; +$string['iframemode_secure'] = '~Segur (aïllat, en entorn de proves)'; $string['installstale'] = 'La instal·lació pot haver fallat. Torneu-ho a provar.'; $string['intro'] = '~Descripció'; $string['invalidaction'] = 'Acció no vàlida: {$a}'; @@ -351,6 +359,7 @@ $string['saving'] = 'Desant...'; $string['savingwait'] = 'Si us plau, espereu mentre es desa l\'arxiu.'; $string['search:activity'] = '~Recurs eXeLearning - informació de l\'activitat'; +$string['securemodeblocked'] = '~Aquest contingut no es pot mostrar amb la configuració de seguretat actual. Contacteu amb l\'administrador del lloc.'; $string['stillworking'] = 'Encara s\'està processant...'; $string['stylesblockimport'] = '~Bloca els estils importats per l\'usuari'; $string['stylesblockimport_desc'] = '~Quan està activat, l\'editor integrat amaga la pestanya «Estils importats» i rebutja instal·lar un estil inclòs en un projecte .elpx importat. L\'usuari només podrà triar de la llista aprovada per l\'administrador. Equival al comportament d\'eXeLearning ONLINE_THEMES_INSTALL=false.'; diff --git a/lang/en/exelearning.php b/lang/en/exelearning.php index 9910bd6..84b1ac9 100644 --- a/lang/en/exelearning.php +++ b/lang/en/exelearning.php @@ -46,6 +46,10 @@ $string['completionstatusrequired_help'] = 'When enabled, the activity is marked complete once the student has an attempt that reaches the selected status. "Passed" requires a passed attempt, "Completed" requires a completed attempt, and "Passed or completed" accepts either. This is a single activity-wide condition (one status per activity).'; $string['confirmuninstall'] = 'Are you sure you want to uninstall the embedded editor? This will remove the admin-installed copy from moodledata.'; $string['confirmuninstalltitle'] = 'Confirm uninstall'; +$string['cspprofile'] = 'Content security policy profile'; +$string['cspprofile_compatible'] = 'Compatible (weaker: allows external https images, media and scripts)'; +$string['cspprofile_desc'] = 'Controls the Content-Security-Policy applied to the package document. "Strict" (recommended) stops the package from loading images, media, scripts or frames from arbitrary external https hosts, so a malicious package cannot exfiltrate the per-user file token carried in the page URL; only the maintained video providers are allowed for promoted players. "Compatible" relaxes the policy to allow external https images, media and scripts (for example author images on third-party hosts or a MathJax CDN) and is therefore weaker. The package iframe stays opaque-origin in both profiles.'; +$string['cspprofile_strict'] = 'Strict (recommended: no external https images, media or scripts)'; $string['deleteattempt'] = 'Delete attempt'; $string['detecteditems'] = 'Gradable iDevices detected:'; $string['downloadreport'] = 'Download report data as'; @@ -96,6 +100,10 @@ $string['embeddededitorstatus'] = 'Embedded editor'; $string['embeddednotinstalledadmin'] = 'The embedded editor files are not installed. You can install it from the plugin settings.'; $string['embeddednotinstalledcontactadmin'] = 'The embedded editor files are not installed. Please contact your site administrator to install it.'; +$string['embedmode'] = 'External embed policy'; +$string['embedmode_desc'] = 'External videos and PDFs are shown by promoting them to a sandboxed, cross-origin player on the activity page, which the browser keeps isolated from Moodle whatever the provider. "Strict" (recommended) only allows a built-in list of well-known providers (YouTube, Vimeo, Dailymotion, EducaMadrid) with canonical URL reconstruction; use it where content authors are not fully trusted. "Open" allows any cross-origin https provider without a maintained list. Both modes reject same-origin, IP/loopback and userinfo URLs.'; +$string['embedmode_open'] = 'Open (any cross-origin https provider)'; +$string['embedmode_strict'] = 'Strict (built-in provider list only)'; $string['err_grademinmax'] = 'The minimum grade cannot be greater than the maximum grade.'; $string['err_gradepassrange'] = 'The grade to pass must be between the minimum and maximum grade.'; $string['err_nocontentxml'] = 'The uploaded file is not a valid eXeLearning package: it must be an .elpx or a .zip whose archive contains content.xml.'; @@ -297,6 +305,8 @@ $string['previewmode_desc'] = 'Nothing you do here will be saved to the gradebook.'; $string['previewmode_enter'] = 'Try as a student (preview)'; $string['previewmode_exit'] = 'Exit preview mode'; +$string['previewsessionbusy'] = 'The preview session is busy. Please retry.'; +$string['previewsessioncleanup'] = 'Clean up expired eXeLearning preview sessions'; $string['privacy:metadata:core_grades'] = 'Scores obtained on eXeLearning gradable items are stored in the Moodle gradebook.'; $string['privacy:metadata:exelearning'] = 'eXeLearning activity instances.'; $string['privacy:metadata:exelearning:usermodified'] = 'The user who last modified the activity settings.'; @@ -347,6 +357,7 @@ $string['saving'] = 'Saving...'; $string['savingwait'] = 'Please wait while the file is being saved.'; $string['search:activity'] = 'eXeLearning resource - activity information'; +$string['securemodeblocked'] = 'This content cannot be displayed with the current security configuration. Please contact the site administrator.'; $string['stillworking'] = 'Still working...'; $string['stylesblockimport'] = 'Block user-imported styles'; $string['stylesblockimport_desc'] = 'When enabled, the embedded editor hides the "User styles" tab and refuses to install a style bundled inside an imported .elpx project. Users may only choose from the admin-approved list above. This mirrors the eXeLearning ONLINE_THEMES_INSTALL=false behavior.'; diff --git a/lang/es/exelearning.php b/lang/es/exelearning.php index c25d263..ae6677d 100644 --- a/lang/es/exelearning.php +++ b/lang/es/exelearning.php @@ -100,6 +100,10 @@ $string['embeddededitorstatus'] = 'Editor embebido'; $string['embeddednotinstalledadmin'] = 'Los archivos del editor integrado no están instalados. Puede instalarlo desde la configuración del plugin.'; $string['embeddednotinstalledcontactadmin'] = 'Los archivos del editor integrado no están instalados. Contacte con el administrador del sitio para instalarlo.'; +$string['embedmode'] = '~Política de inserciones externas'; +$string['embedmode_desc'] = '~En el modo seguro, los vídeos y PDF externos se muestran promocionándolos a un reproductor aislado y de origen cruzado en la página de la actividad, que el navegador mantiene aislado de Moodle sea cual sea el proveedor. «Abrir» (recomendado) permite cualquier proveedor https de origen cruzado, de modo que YouTube, Vimeo, Dailymotion, EducaMadrid y cualquier otro sitio funcionan sin necesidad de mantener una lista. «Estricto» solo permite una lista integrada de proveedores conocidos; úselo cuando no se confíe en que los autores de contenido no inserten páginas de phishing o de seguimiento. Ambos modos rechazan las URL de mismo origen, de IP/bucle local y con información de usuario.'; +$string['embedmode_open'] = '~Abrir (cualquier proveedor https de origen cruzado)'; +$string['embedmode_strict'] = '~Estricto (solo la lista integrada de proveedores)'; $string['err_grademinmax'] = '~La calificación mínima no puede ser mayor que la calificación máxima.'; $string['err_gradepassrange'] = '~La calificación para aprobar debe estar entre la calificación mínima y la máxima.'; $string['err_nocontentxml'] = '~El archivo subido no es un paquete eXeLearning válido: debe ser un .elpx o un .zip cuyo archivo contenga content.xml.'; @@ -250,6 +254,10 @@ $string['gradepass_help'] = '~La calificación global mínima necesaria para aprobar. Cuando se habilita la condición de finalización «Requiere calificación para aprobar», la actividad se marca como completada (al estilo SCORM) cuando el estudiante alcanza esta calificación. Déjelo en 0 para desactivar la finalización basada en aprobado.'; $string['gradesetchangedwarning'] = '~El contenido calificable de esta actividad ha cambiado y algunos estudiantes ya tienen intentos. Las calificaciones existentes se conservan tal cual y no se recalculan con el nuevo contenido. Si los cambios hacen que esas calificaciones sean engañosas, elimine los intentos afectados para recalcularlas.'; $string['gradingheading'] = '~Calificación'; +$string['iframemode'] = '~Modo de seguridad del iframe del paquete'; +$string['iframemode_desc'] = '~Controla cómo se inserta el paquete eXeLearning en la página de la actividad. «Seguro» (recomendado) ejecuta el paquete en un iframe aislado de origen opaco, de modo que su JavaScript no puede leer ni modificar la página de Moodle circundante, sus cookies ni la sesión; la puntuación SCORM se transmite a Moodle a través de un canal postMessage validado. «Heredado» mantiene el comportamiento anterior de mismo origen y solo debería usarse si un paquete concreto funciona mal en el modo seguro (por ejemplo, porque depende del almacenamiento del navegador que no está disponible para un iframe aislado).'; +$string['iframemode_legacy'] = '~Heredado (mismo origen)'; +$string['iframemode_secure'] = '~Seguro (aislado, en entorno de pruebas)'; $string['installstale'] = 'La instalación puede haber fallado. Inténtelo de nuevo.'; $string['intro'] = '~Descripción'; $string['invalidaction'] = 'Acción no válida: {$a}'; @@ -351,6 +359,7 @@ $string['saving'] = 'Guardando...'; $string['savingwait'] = 'Por favor, espere mientras se guarda el archivo.'; $string['search:activity'] = 'Recurso eXeLearning - información de la actividad'; +$string['securemodeblocked'] = '~Este contenido no se puede mostrar con la configuración de seguridad actual. Contacte con el administrador del sitio.'; $string['stillworking'] = 'Sigue en proceso...'; $string['stylesblockimport'] = 'Bloquear estilos importados por el usuario'; $string['stylesblockimport_desc'] = 'Cuando está activado, el editor integrado oculta la pestaña «Estilos importados» y rechaza instalar un estilo incluido en un proyecto .elpx importado. El usuario sólo podrá elegir entre la lista aprobada por el administrador. Equivale al comportamiento de eXeLearning ONLINE_THEMES_INSTALL=false.'; diff --git a/lang/eu/exelearning.php b/lang/eu/exelearning.php index b44df00..f553125 100644 --- a/lang/eu/exelearning.php +++ b/lang/eu/exelearning.php @@ -100,6 +100,10 @@ $string['embeddededitorstatus'] = 'Editore txertatua'; $string['embeddednotinstalledadmin'] = 'Editore txertatuaren fitxategiak ez daude instalatuta. Pluginaren ezarpenetan instala dezakezu.'; $string['embeddednotinstalledcontactadmin'] = 'Editore txertatuaren fitxategiak ez daude instalatuta. Jarri harremanetan guneko administratzailearekin instalatzeko.'; +$string['embedmode'] = '~Kanpoko txertaketen politika'; +$string['embedmode_desc'] = '~Modu seguruan, kanpoko bideoak eta PDFak jardueraren orrian jatorri gurutzatuko erreproduzitzaile isolatu batera sustatuz erakusten dira, eta nabigatzaileak Moodletik isolatuta mantentzen du, hornitzailea edozein dela ere. «Ireki» (gomendatua) jatorri gurutzatuko edozein https hornitzaile onartzen du, hala nola YouTube, Vimeo, Dailymotion, EducaMadrid eta beste edozein gune funtzionatzen dute zerrenda bat mantendu beharrik gabe. «Zorrotza»k hornitzaile ezagunen barneko zerrenda bat soilik onartzen du; erabili eduki-egileek phishing edo jarraipen-orriak ez txertatzeko konfiantzarik ez dagoenean. Bi moduek baztertu egiten dituzte jatorri bereko, IP/atzeranzko begizta eta erabiltzaile-informazioa duten URLak.'; +$string['embedmode_open'] = '~Ireki (jatorri gurutzatuko edozein https hornitzaile)'; +$string['embedmode_strict'] = '~Zorrotza (hornitzaileen barneko zerrenda soilik)'; $string['err_grademinmax'] = '~Gutxieneko kalifikazioa ezin da gehienezkoa baino handiagoa izan.'; $string['err_gradepassrange'] = '~Gainditzeko kalifikazioa gutxieneko eta gehienezko kalifikazioaren artean egon behar da.'; $string['err_nocontentxml'] = '~Igotako fitxategia ez da baliozko eXeLearning pakete bat: .elpx bat edo content.xml duen .zip bat izan behar du.'; @@ -250,6 +254,10 @@ $string['gradepass_help'] = '~Gainditzeko behar den gutxieneko kalifikazio orokorra. «Gainditze-kalifikazioa behar da» osatze-baldintza gaituta dagoenean, jarduera osatutzat markatzen da (SCORM estiloan) ikasleak kalifikazio hau lortzen duenean. Utzi 0 balioan gainditzean oinarritutako osatzea desgaitzeko.'; $string['gradesetchangedwarning'] = '~Jarduera honen eduki kalifikagarria aldatu da eta ikasle batzuek jada saiakerak dituzte. Lehendik dauden kalifikazioak zeuden bezala gordetzen dira eta ez dira eduki berriarekin birkalkulatzen. Aldaketek kalifikazio horiek engainagarri bihurtzen badituzte, ezabatu eragindako saiakerak birkalkulatzeko.'; $string['gradingheading'] = '~Kalifikazioa'; +$string['iframemode'] = '~Paketearen iframearen segurtasun-modua'; +$string['iframemode_desc'] = '~eXeLearning paketea jardueraren orrian nola txertatzen den kontrolatzen du. «Segurua» (gomendatua) paketea jatorri opakuko iframe isolatu batean exekutatzen du, eta horrela bere JavaScriptak ezin du inguruko Moodle orria, bere cookieak edo saioa irakurri edo aldatu; SCORM puntuazioa balidatutako postMessage kanal baten bidez bidaltzen zaio Moodleri. «Aurrekoa»k jatorri bereko aurreko portaera mantentzen du eta pakete jakin batek modu seguruan gaizki funtzionatzen badu soilik erabili beharko litzateke (adibidez, iframe isolatu batentzat erabilgarri ez dagoen nabigatzailearen biltegiratzean oinarritzen delako).'; +$string['iframemode_legacy'] = '~Aurrekoa (jatorri berekoa)'; +$string['iframemode_secure'] = '~Segurua (isolatua, proba-ingurunean)'; $string['installstale'] = 'Baliteke instalazioak huts egin izana. Saiatu berriro.'; $string['intro'] = '~Deskribapena'; $string['invalidaction'] = 'Ekintza baliogabea: {$a}'; @@ -351,6 +359,7 @@ $string['saving'] = 'Gordetzen...'; $string['savingwait'] = 'Mesedez, itxaron fitxategia gordetzen den bitartean.'; $string['search:activity'] = '~eXeLearning baliabidea - jardueraren informazioa'; +$string['securemodeblocked'] = '~Eduki hau ezin da bistaratu uneko segurtasun-konfigurazioarekin. Jarri harremanetan guneko administratzailearekin.'; $string['stillworking'] = 'Oraindik lanean...'; $string['stylesblockimport'] = '~Blokeatu erabiltzaileak inportatutako estiloak'; $string['stylesblockimport_desc'] = '~Gaituta dagoenean, editore txertatuak «Inportatutako estiloak» fitxa ezkutatzen du eta inportatutako .elpx proiektu batean sartutako estilo bat instalatzeari uko egiten dio. Erabiltzaileak administratzaileak onartutako zerrendatik soilik aukeratu ahal izango du. eXeLearning-en ONLINE_THEMES_INSTALL=false jokabidearen baliokidea da.'; diff --git a/lang/gl/exelearning.php b/lang/gl/exelearning.php index 927f1b0..020484e 100644 --- a/lang/gl/exelearning.php +++ b/lang/gl/exelearning.php @@ -100,6 +100,10 @@ $string['embeddededitorstatus'] = 'Editor embebido'; $string['embeddednotinstalledadmin'] = 'Os ficheiros do editor integrado non están instalados. Pode instalalo desde a configuración do complemento.'; $string['embeddednotinstalledcontactadmin'] = 'Os ficheiros do editor integrado non están instalados. Contacte co administrador do sitio para instalalo.'; +$string['embedmode'] = '~Política de insercións externas'; +$string['embedmode_desc'] = '~No modo seguro, os vídeos e PDF externos amósanse promocionándoos a un reprodutor illado e de orixe cruzada na páxina da actividade, que o navegador mantén illado de Moodle sexa cal sexa o provedor. «Abrir» (recomendado) permite calquera provedor https de orixe cruzada, de xeito que YouTube, Vimeo, Dailymotion, EducaMadrid e calquera outro sitio funcionan sen necesidade de manter unha lista. «Estrito» só permite unha lista integrada de provedores coñecidos; úseo cando non se confíe en que os autores de contido non inseran páxinas de phishing ou de seguimento. Ambos os modos rexeitan os URL de mesma orixe, de IP/bucle local e con información de usuario.'; +$string['embedmode_open'] = '~Abrir (calquera provedor https de orixe cruzada)'; +$string['embedmode_strict'] = '~Estrito (só a lista integrada de provedores)'; $string['err_grademinmax'] = '~A cualificación mínima non pode ser maior que a máxima.'; $string['err_gradepassrange'] = '~A cualificación para aprobar debe estar entre a cualificación mínima e a máxima.'; $string['err_nocontentxml'] = '~O ficheiro subido non é un paquete eXeLearning válido: debe ser un .elpx ou un .zip cuxo arquivo conteña content.xml.'; @@ -250,6 +254,10 @@ $string['gradepass_help'] = '~A cualificación global mínima necesaria para aprobar. Cando se habilita a condición de finalización «Require cualificación para aprobar», a actividade márcase como completada (ao estilo SCORM) cando o estudante alcanza esta cualificación. Déixeo en 0 para desactivar a finalización baseada no aprobado.'; $string['gradesetchangedwarning'] = '~O contido cualificable desta actividade cambiou e algúns estudantes xa teñen intentos. As cualificacións existentes consérvanse tal cal e non se recalculan co novo contido. Se os cambios fan que esas cualificacións sexan enganosas, elimine os intentos afectados para recalculalas.'; $string['gradingheading'] = '~Cualificación'; +$string['iframemode'] = '~Modo de seguridade do iframe do paquete'; +$string['iframemode_desc'] = '~Controla como se insire o paquete eXeLearning na páxina da actividade. «Seguro» (recomendado) executa o paquete nun iframe illado de orixe opaca, de xeito que o seu JavaScript non pode ler nin modificar a páxina de Moodle circundante, as súas cookies nin a sesión; a puntuación SCORM transmítese a Moodle a través dunha canle postMessage validada. «Herdado» mantén o comportamento anterior de mesma orixe e só debería usarse se un paquete concreto funciona mal no modo seguro (por exemplo, porque depende do almacenamento do navegador que non está dispoñible para un iframe illado).'; +$string['iframemode_legacy'] = '~Herdado (mesma orixe)'; +$string['iframemode_secure'] = '~Seguro (illado, en entorno de probas)'; $string['installstale'] = 'A instalación pode fallar. Ténteo de novo.'; $string['intro'] = '~Descrición'; $string['invalidaction'] = 'Acción non válida: {$a}'; @@ -351,6 +359,7 @@ $string['saving'] = 'Gardando...'; $string['savingwait'] = 'Por favor, agarde mentres se garda o ficheiro.'; $string['search:activity'] = '~Recurso eXeLearning - información da actividade'; +$string['securemodeblocked'] = '~Este contido non se pode amosar coa configuración de seguridade actual. Contacte co administrador do sitio.'; $string['stillworking'] = 'Segue en proceso...'; $string['stylesblockimport'] = '~Bloquear os estilos importados polo usuario'; $string['stylesblockimport_desc'] = '~Cando está activado, o editor integrado oculta a lapela «Estilos importados» e rexeita instalar un estilo incluído nun proxecto .elpx importado. O usuario só poderá elixir da lista aprobada polo administrador. Equivale ao comportamento de eXeLearning ONLINE_THEMES_INSTALL=false.'; diff --git a/lib.php b/lib.php index 5bd9e19..323ac7a 100644 --- a/lib.php +++ b/lib.php @@ -568,6 +568,15 @@ function exelearning_pluginfile($course, $cm, $context, $filearea, $args, $force // must render, not be downloaded). Same flag used by mod_scorm. $options['dontforcesvgdownload'] = true; + // Defense-in-depth headers for the embedded package HTML document, in secure mode + // only (DEC-0060). The decision + values live in player_iframe::content_headers() + // (unit tested); here we just emit them. send_stored_file() neither emits nor strips + // these, and header(..., true) only replaces same-named headers, so they survive. + $secureheaders = \mod_exelearning\local\ui\player_iframe::content_headers($file->get_filename(), $CFG->wwwroot); + foreach ($secureheaders as $hname => $hval) { + @header($hname . ': ' . $hval); + } + // Reasonable cache-control: a revision bump automatically invalidates the URL. $lifetime = $CFG->filelifetime ?? 86400; send_stored_file($file, $lifetime, 0, $forcedownload, $options); diff --git a/package-lock.json b/package-lock.json index da74de4..914e102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "GPL-3.0-or-later", "devDependencies": { + "@playwright/test": "^1.58.2", "@vitest/coverage-v8": "^4.1.8", "happy-dom": "^20.10.2", "vitest": "^4.1.8" @@ -165,6 +166,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -1219,6 +1236,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index 08e6a68..5e8881c 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,15 @@ "name": "mod_exelearning", "version": "1.0.0", "private": true, - "description": "JavaScript unit tests (Vitest) for the mod_exelearning SCORM tracker.", + "description": "JavaScript unit tests (Vitest) + Firefox e2e (Playwright) for mod_exelearning.", "license": "GPL-3.0-or-later", "scripts": { "test:js": "vitest run", - "test:js:coverage": "vitest run --coverage" + "test:js:coverage": "vitest run --coverage", + "test:e2e:embed": "playwright test -c playwright-embed.config.cjs" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@vitest/coverage-v8": "^4.1.8", "happy-dom": "^20.10.2", "vitest": "^4.1.8" diff --git a/playwright-embed.config.cjs b/playwright-embed.config.cjs new file mode 100644 index 0000000..cf4ec1a --- /dev/null +++ b/playwright-embed.config.cjs @@ -0,0 +1,25 @@ +// Playwright config for the cross-browser external-embed e2e (DEC-0061), separate from +// any Moodle/Behat setup. Serves the plugin root over a static server so the harness +// can load the real js/exe_embed_*.js and an opaque-origin sandboxed content iframe, +// then runs the check in Firefox (proves the promote-to-parent mechanism is not +// Chromium-specific). Run with: npx playwright test -c playwright-embed.config.cjs +const { defineConfig, devices } = require('@playwright/test'); + +const PORT = 8126; + +module.exports = defineConfig({ + testDir: 'tests/e2e', + testMatch: /.*\.spec\.cjs$/, + timeout: 30000, + fullyParallel: false, + use: { baseURL: 'http://localhost:' + PORT }, + webServer: { + command: 'python3 -m http.server ' + PORT, + port: PORT, + reuseExistingServer: false, + timeout: 30000, + }, + projects: [ + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + ], +}); diff --git a/preview.php b/preview.php new file mode 100644 index 0000000..cdbe8a9 --- /dev/null +++ b/preview.php @@ -0,0 +1,107 @@ +. + +/** + * Authless opaque HTTP preview serving endpoint (capability URL). + * + * The Moodle adapter of eXeLearning core's canonical preview serving contract v2 + * (docs/preview-serving-contract.md). It serves the three session layers — + * generated documents, session assets, fixed installation resources — of the + * opaque preview iframe over an unguessable capability URL. + * + * Route: GET /mod/exelearning/preview.php/{previewId}/ + * `previewId` is a server-minted UUID capability. No auth cookie is required or + * consulted (NO_MOODLE_COOKIES): the opaque preview iframe sends no SameSite + * cookie, so the route is gated purely on the unguessable previewId + idle TTL + * in the session store — the cookieless model the published package uses via + * tokenpluginfile.php + get_user_key('core_files', ...) in view.php. + * + * All protocol logic (three-layer resolution, tiered Cache-Control, the sandbox + * CSP byte-identical to core previewCspHeader(), ETag/Range) lives in + * \mod_exelearning\local\preview\serving; the store lives in session_store. This + * script only parses the capability URL and emits the computed response. + * + * @package mod_exelearning + * @copyright 2026 ATE (Área de Tecnología Educativa) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// Authless capability URL: never start a session or touch cookies. The previewId +// is the only credential; an auth cookie must not influence the response. +define('NO_MOODLE_COOKIES', true); + +// Raw byte-exact body: suppress any debug notice/warning that would otherwise be +// prepended to the served preview/asset bytes (and could defeat the headers/CSP +// contract) on a site with $CFG->debugdisplay enabled. The standard pattern for +// file-serving endpoints (tokenpluginfile / pluginfile-style scripts). +define('NO_DEBUG_DISPLAY', true); + +// @codingStandardsIgnoreLine — path is fixed relative to /mod/exelearning/. +require(__DIR__ . '/../../config.php'); + +use mod_exelearning\local\preview\serving; +use mod_exelearning\local\preview\session_store; + +/** + * Emit a serving response ({status, headers, body}) and stop. The hardening + * headers ride on every response, 404s included. + * + * @param array $response Serving response with 'status', 'headers' and 'body' keys. + * @return never + */ +function exelearning_preview_send(array $response): void { + foreach ($response['headers'] as $name => $value) { + header($name . ': ' . $value); + } + http_response_code($response['status']); + echo $response['body']; + die; +} + +// Slash arguments: PATH_INFO is "/{previewId}/{relpath}". get_file_argument() +// reads it robustly whether or not $CFG->slasharguments is enabled (the same +// helper pluginfile.php / tokenpluginfile.php use). +$parsed = serving::parse_capability_path((string) get_file_argument()); +$previewid = $parsed['previewid']; +$relpath = $parsed['relpath']; + +// Invalid capability shape -> 404 (with base headers, no CSP). +if (!preg_match(serving::UUID_RE, $previewid)) { + exelearning_preview_send(serving::not_found()); +} + +// Bare capability root ("/{previewId}" or "/{previewId}/") -> 302 to index.html, +// so the opaque iframe's base URL is the session directory and document bytes are +// never served from the bare URL. The Location is RELATIVE (resolved against the +// request URL) so it is correct under any $CFG->wwwroot subdirectory. Pure URL +// canonicalization, done before the session lookup; the redirected request +// resolves the session and resource. +if ($parsed['bareroot']) { + $location = serving::bare_root_location($previewid, $parsed['trailingslash']); + exelearning_preview_send(serving::redirect_to_index($location)); +} + +// Cookieless capability lookup: unknown or idle-expired session -> 404. +$session = session_store::get_for_serving($previewid); +if ($session === null) { + exelearning_preview_send(serving::not_found()); +} + +$response = serving::serve($session, $relpath, [ + 'ifnonematch' => $_SERVER['HTTP_IF_NONE_MATCH'] ?? null, + 'range' => $_SERVER['HTTP_RANGE'] ?? null, +]); +exelearning_preview_send($response); diff --git a/research/analisis/notas/AN-008-iframe-vs-scorm-player.md b/research/analisis/notas/AN-008-iframe-vs-scorm-player.md index 1a15dc1..07043d9 100644 --- a/research/analisis/notas/AN-008-iframe-vs-scorm-player.md +++ b/research/analisis/notas/AN-008-iframe-vs-scorm-player.md @@ -145,7 +145,8 @@ La investigación de TAREA-012 (DEC-0019) verifica la HIPÓTESIS de arriba: Mood `url.php` lo hardcodea), así que el subdominio dedicado requiere **infraestructura fuera de core**. Además confirma que el bridge SCORM es **100% same-origin** (el padre lee `iframe.contentDocument` para el `objectid` de DEC-0017, el hijo recorre -`window.parent.API`, el teacher-mode hider inyecta CSS en el `contentDocument`), por lo +`window.parent.API`, el teacher-mode hider inyectaba CSS en el `contentDocument` —histórico: +hoy el teacher-mode usa el parámetro core `?exe-teacher`, compatible con opaco, DEC-0066—), por lo que quitar `allow-same-origin` u optar por origen opaco **rompe el tracking** salvo que antes se reescriba el bridge a `postMessage` (patrón H5P). Comparativa y roadmap de hardening (Tier 1: Permissions-Policy + CSP estricto-con-toggle + quitar diff --git a/research/analisis/notas/AN-015-comparativa-soluciones-video-embeds.md b/research/analisis/notas/AN-015-comparativa-soluciones-video-embeds.md new file mode 100644 index 0000000..6acfb8b --- /dev/null +++ b/research/analisis/notas/AN-015-comparativa-soluciones-video-embeds.md @@ -0,0 +1,215 @@ +--- +id: AN-015 +titulo: "Comparativa: soluciones de embeds de vídeo (YouTube/Vimeo) en contenido no confiable — mod_exelearning vs procomún vs eXeLearning vs el paper" +fecha: 2026-06-19 +fuentes: + - REPO-005 + - REPO-010 + - REPO-011 +relacionados: + - DEC-0061 + - DEC-0059 + - DEC-0060 + - AN-008 +herramienta_ia: + interfaz: claude-code + modelo: claude-opus-4-8 +--- + +## Resumen + +Cuatro implementaciones/análisis del MISMO problema —reproducir vídeo de terceros (YouTube/Vimeo) +incrustado en un paquete `.elpx` **no confiable** que se sirve en un **iframe de origen opaco**— se +comparan aquí. La conclusión clave: **las cuatro coinciden en el núcleo** y se diferencian sólo en +**la capa** en la que actúan, el **canal de confianza** y **quién impone el sandbox del player**. + +Núcleo compartido por todas: el `.elpx` corre en origen opaco (`sandbox` SIN `allow-same-origin`, +`origin="null"`); el flag se **propaga** a los iframes anidados, así que un YouTube/Vimeo anidado de +forma ingenua **sale en blanco**; la solución es **promote-to-parent**: el player real se monta en el +**padre confiable** (origen real del proveedor, cross-origin al host → aislado por el SOP), nunca +reintroduciendo `allow-same-origin` para el host; y como `event.origin` del frame opaco es la cadena +inútil `"null"`, el mensaje se autentica por **identidad de ventana** (`event.source === iframe.contentWindow`). +El paper lo nombra literalmente «el modelo de confianza que Moodle ya usa para incrustar YouTube». + +**Veredicto (depende del rol):** no hay un ganador único. +- Para la **pureza del canal de seguridad**, **eXeLearning** gana: sólo cruza `{provider, videoId}` + (nunca la URL del autor), el padre reconstruye la URL canónica desde una plantilla fija, y el + handshake va con **identidad de ventana + nonce por vista + un MessagePort transferido** que un + sub-frame hostil anidado no puede obtener. Un id malicioso ni siquiera puede plantillarse en una URL + viva. **Pero** su asimetría: el sandbox/CSP del player vive **entero en el host** y el productor **no + puede imponerlo** → como garantía para un host arbitrario es **condicional**. +- Para un **host desplegado que debe defenderse de paquetes arbitrarios** (nuestro caso), + **mod_exelearning** es el más fuerte en conjunto: **posee e impone** el sandbox del player, trata + como hostiles **tanto la URL como la geometría**, añade guardas D1/D2, y es el **mejor validado** de + los cuatro (Vitest + e2e Playwright real en sandbox opaco + anti-drift). Además la mayor **fidelidad + visual** (overlay inline, indistinguible de un embed normal). +- **procomún** es el **más simple** (click→modal, sin sincronía de geometría) pero el de **menos + defensa en profundidad** (sin canonicalización de Vimeo, UI muerta en thumbnails, PDF sin sandbox). +- **El paper** aporta el **principio** (SoK) que las tres implementaciones materializan. + +## El problema común + +DEC-0059/DEC-0060 sirven el paquete en origen opaco para cerrar RIE-001 (que el paquete alcance la +sesión/sesskey del padre). Efecto colateral (el «dilema central» del paper, `:249`): los flags +`sandbox` se propagan a los iframes anidados → el player de YouTube/Vimeo hereda el origen opaco, +pierde el suyo (cookies/storage) y queda en blanco. Las tres implementaciones responden con +**promote-to-parent** + aislamiento por SOP del player en el origen real del proveedor. + +## Hechos citados + +### mod_exelearning (DEC-0061) — consumidor, overlay inline + +- **Shim en el contenido** `js/exe_embed_shim.js` (horneado como `libs/exe_embed_shim.js` por + `package_manager.php:256-259`, inyectado al inicio del `` por `scorm_injector.php:108-123`), + se auto-activa **sólo** en origen opaco; sustituye cada `iframe[src]` cross-origin-https-o-`.pdf` por + un placeholder y postMessea `{id, url ABSOLUTA, x, y, w, h}` al padre con `targetOrigin '*'`. +- **Relay en el padre** `js/exe_embed_relay.js` (inline en `view.php:516-531`, sólo en `$securemode`): + autentica por identidad de ventana (`frameForSource` exige un iframe de **contenido**, nunca un + player promovido — `relay.js:297-308`), **valida** cada URL (`validate()`/`isCrossOriginHttps`, + `relay.js:162-233`: https, sin userinfo, cross-origin, no IP/loopback/`.local`, no dominio relacionado + con el LMS) y **superpone** el player real sobre la geometría del placeholder. +- **Sandbox del player** (`relay.js:251`): `allow-scripts allow-same-origin allow-popups allow-forms + allow-presentation` — **omite `allow-top-navigation` y `allow-modals`**. El `allow-same-origin` aquí + es **seguro** porque el `src` del player es cross-origin (proveedor) → el SOP lo aísla del host; NO es + el `allow-same-origin` prohibido del iframe de contenido. +- **Guardas:** **D1** redirect-laundering (`armSameOriginGuard`, `relay.js:339-348`: elimina el player + si aterriza same-origin al LMS), **D2** forged-message (players excluidos de `frameForSource`), + clamp de geometría anti-clickjacking (`Math.min(embed.w, rect.width)`, overflow:hidden). +- **YouTube:** OPEN promociona verbatim; STRICT reconstruye `youtube-nocookie.com/embed/{id}` + + `referrerpolicy=strict-origin-when-cross-origin` (evita el Error 153). **Vimeo:** STRICT reconstruye + `player.vimeo.com/video/{id}`. Modos `mod_exelearning/embedmode` OPEN (invariante https+cross-origin, + sin allowlist) vs STRICT (allowlist `DEFAULT_EMBED_HOSTS` + rebuild canónico), `player_iframe.php:133-139`. +- **Limitación:** interactive-video (control del player por la API del autor) **roto** en opaco; un + puente de control YT se prototipó y **se revirtió por frágil**. Local `