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 69272c8..0eccbb9 100644 --- a/blueprint.json +++ b/blueprint.json @@ -39,7 +39,9 @@ }, { "step": "setConfigs", + "comment": "iframemode is forced to legacy here (DEC-0060): the Playground's PHP-WASM service worker cannot serve an opaque-origin iframe, so the secure mode would only show the 'blocked by security configuration' notice. Legacy (same-origin) lets the demo render the package so the preview is useful. Real Moodle keeps the secure default and is unaffected by this Playground-only override.", "configs": [ + { "name": "iframemode", "value": "legacy", "plugin": "exelearning" }, { "name": "editormode", "value": "embedded", "plugin": "exelearning" }, { "name": "display", "value": "1", "plugin": "exelearning" }, { "name": "printintro", "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/scorm/scorm_injector.php b/classes/local/scorm/scorm_injector.php index 7a477ce..259ba6a 100644 --- a/classes/local/scorm/scorm_injector.php +++ b/classes/local/scorm/scorm_injector.php @@ -33,15 +33,24 @@ */ final class scorm_injector { /** - * Injects SCORM wrapper script tags into the of index.html and all + * Injects the SCORM client script tags into the of index.html and all * html/.html pages of the extracted package. * + * Two independent, idempotent blocks are injected: + * - The bridge client (libs/scorm_tracker.js + libs/exe_scorm_bridge.js) at the + * TOP of , so its in-memory storage polyfill and local window.API are in + * place before any package script runs. It self-activates only in the secure + * opaque-origin iframe mode and is dormant otherwise (DEC-0059). + * - The pipwerks SCORM wrapper (libs/SCORM_API_wrapper.js + libs/SCOFunctions.js) + * plus an init kick, just before , used by both iframe modes. + * * @param int $contextid * @param int $revision */ public static function inject(int $contextid, int $revision): void { $fs = get_file_storage(); $marker = ''; + $bridgemarker = ''; // After loading the wrapper, force `pipwerks.SCORM.init()` so that // connection.isActive=true and subsequent `set()` calls DO reach // window.parent.API.LMSSetValue. eXeLearning only invokes init() in the @@ -57,14 +66,7 @@ public static function inject(int $contextid, int $revision): void { " }, 50);\n" . " })();\n" . " \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..e6320e8 --- /dev/null +++ b/classes/local/ui/player_iframe.php @@ -0,0 +1,239 @@ +. + +/** + * 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; + +/** + * Resolves the configured package iframe mode and the sandbox tokens it implies. + * + * A single site-wide admin setting (mod_exelearning/iframemode) selects how the + * arbitrary author HTML/JS of an `.elpx` package is embedded in view.php: + * + * - secure (default): 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. + * - legacy: the historical same-origin sandbox, kept only as a compatibility + * fallback for packages that misbehave under an opaque origin. + * + * Centralised here so the token 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. */ + public const MODE_SECURE = 'secure'; + + /** @var string Legacy mode: historical same-origin iframe. */ + 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. */ + public const EMBED_STRICT = 'strict'; + + /** + * 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', + ]; + + /** + * Resolve the configured iframe mode, defaulting to secure for any unset or + * unrecognised value (fail safe: an invalid config must not weaken isolation). + * + * @return string self::MODE_SECURE or self::MODE_LEGACY. + */ + public static function resolve_mode(): string { + $mode = get_config('mod_exelearning', 'iframemode'); + return ($mode === self::MODE_LEGACY) ? self::MODE_LEGACY : self::MODE_SECURE; + } + + /** + * 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 a given mode. + * + * Both modes deliberately OMIT allow-top-navigation (a package must never be + * able to change the parent URL) and allow-modals (alert/confirm/prompt are UX + * traps). Secure mode additionally OMITS allow-same-origin (forcing an opaque + * origin so the package cannot reach Moodle's DOM/cookies/session) 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 + * in both modes because eXeLearning v4 iDevices need jQuery + scripts, popups + * (interactive-video, hidden-image) and forms (quick-questions, form, + * scrambled-list). See research ADR DEC-0059 / DEC-0019 / AN-008. + * + * @param string $mode self::MODE_SECURE or self::MODE_LEGACY. + * @return string Space-separated sandbox token list. + */ + public static function sandbox_tokens(string $mode): string { + if ($mode === self::MODE_LEGACY) { + return 'allow-scripts allow-same-origin allow-popups allow-forms allow-popups-to-escape-sandbox'; + } + return 'allow-scripts allow-popups allow-forms'; + } + + /** + * Resolve the external-embed policy (DEC-0061). Default 'open' promotes any + * cross-origin https iframe (the player is sandboxed + cross-origin, so SOP isolates + * it from Moodle); 'strict' restricts to the maintained host allowlist. An + * unrecognised (tampered) value fails to 'strict' (toward the more restrictive), + * while an unset value keeps the intended 'open' default. + * + * @return string self::EMBED_OPEN or self::EMBED_STRICT. + */ + public static function embed_mode(): string { + $value = get_config('mod_exelearning', 'embedmode'); + if ($value === false || $value === null || $value === '') { + return self::EMBED_OPEN; + } + return $value === self::EMBED_OPEN ? self::EMBED_OPEN : self::EMBED_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). + * + * Tuned to harden without breaking eXeLearning, which relies on inline and eval'd + * scripts: object-src and base-uri are closed, framing is restricted to Moodle + * (frame-ancestors 'self'), and connect-src is limited to this site so the file + * token carried in the URL cannot be exfiltrated to a third-party host via + * fetch/XHR/beacon. External script/style/img/media/frame over https: is still + * allowed so MathJax, YouTube and author embeds keep working (a stricter, + * exfil-proof profile that also blocks those is left as a future admin toggle). + * + * @param string $siteorigin The scheme://host[:port] origin of this Moodle site. + * @return string The Content-Security-Policy header value. + */ + public static function content_security_policy(string $siteorigin): string { + return "default-src 'self' $siteorigin; " + . "script-src 'self' $siteorigin 'unsafe-inline' 'unsafe-eval' https:; " + . "style-src 'self' $siteorigin 'unsafe-inline'; " + . "img-src 'self' $siteorigin data: blob: https:; " + . "media-src 'self' $siteorigin data: blob: https:; " + . "font-src 'self' $siteorigin data:; " + . "connect-src 'self' $siteorigin; " + . "frame-src 'self' $siteorigin https:; " + . "object-src 'none'; base-uri 'none'; form-action 'self' $siteorigin; " + . "frame-ancestors 'self'; " + // Keep the document opaque even if opened outside the iframe (e.g. the token + // URL opened in a new tab); tokens mirror the secure iframe sandbox. + . "sandbox allow-scripts allow-popups allow-forms"; + } + + /** + * 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). Returns an empty array in legacy mode, so the caller (exelearning_pluginfile) + * is just a header-emitting loop. 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 { + if (!self::is_secure()) { + return []; + } + // 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/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/js/exe_embed_relay.js b/js/exe_embed_relay.js new file mode 100644 index 0000000..ed2832d --- /dev/null +++ b/js/exe_embed_relay.js @@ -0,0 +1,510 @@ +// 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. + * + * CANONICAL SOURCE for the eXeLearning embedder family. wp-exelearning + * (assets/js/exe-embed-relay.js) and omeka-s-exelearning (asset/js/exe-embed-relay.js) + * mirror this logic (only the export wrapper differs: they are auto-running IIFEs). + * Keep the three in sync; tools/check-embed-sync.mjs fails if they drift. + */ +(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; + } + + /** + * 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 = []; + + 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'; + } + + // 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; + } + var result = validate(embed.url, 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 */ + + return { + onMessage: onMessage, + sync: sync, + validate: function (raw, contentSrc) { + return validate(raw, contentSrc, { strict: strict, whitelist: whitelist }); + }, + /* v8 ignore start */ + init: function () { + window.addEventListener('message', onMessage); + window.addEventListener('resize', scheduleReflow); + window.addEventListener('scroll', scheduleReflow, true); + window.addEventListener('load', pingAll); + pingAll(); + window.setTimeout(pingAll, 500); + 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, + 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..b17fa9d --- /dev/null +++ b/js/exe_embed_shim.js @@ -0,0 +1,269 @@ +// 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. + * + * CANONICAL SOURCE for the eXeLearning embedder family. wp-exelearning + * (assets/js/exe-embed-shim.js) and omeka-s-exelearning (asset/js/exe-embed-shim.js) + * mirror this logic (only the export wrapper differs: they are auto-running IIFEs). + * Keep the three in sync; tools/check-embed-sync.mjs fails if they drift. + */ +(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); + } + + /** + * 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); + 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(); + embeds.push({ + 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 + }); + } + 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; + + function report() { + window.parent.postMessage({ type: 'exe-embed', action: 'sync', embeds: collect(document) }, '*'); + } + function schedule() { + if (scheduled) { + return; + } + scheduled = true; + window.requestAnimationFrame(function () { + scheduled = false; + report(); + }); + } + function run() { + promote(document, counter); + report(); + } + + run(); + if (window.MutationObserver) { + new MutationObserver(function () { + promote(document, counter); + schedule(); + }).observe(document.documentElement, { childList: true, subtree: true }); + } + window.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule); + window.addEventListener('load', report); + 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, + 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/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..cfb145b 100644 --- a/lang/en/exelearning.php +++ b/lang/en/exelearning.php @@ -96,6 +96,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'] = 'In secure mode, 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. "Open" (recommended) allows any cross-origin https provider, so YouTube, Vimeo, Dailymotion, EducaMadrid and any other site work without a maintained list. "Strict" only allows a built-in list of well-known providers; use it where content authors are not trusted not to embed phishing or tracking pages. 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.'; @@ -246,6 +250,10 @@ $string['gradepass_help'] = 'The minimum overall grade required to pass. When the "Require passing grade" completion condition is enabled, the activity is marked complete (SCORM-style) once the student reaches this grade. Leave at 0 to disable pass-based completion.'; $string['gradesetchangedwarning'] = 'The gradable content of this activity changed and some students already have attempts. Existing grades are kept as they were and are not recalculated against the new content. If the changes make those grades misleading, delete the affected attempts to recalculate them.'; $string['gradingheading'] = 'Grading'; +$string['iframemode'] = 'Package iframe security mode'; +$string['iframemode_desc'] = 'Controls how the eXeLearning package is embedded in the activity page. "Secure" (recommended) runs the package in a sandboxed, opaque-origin iframe so its JavaScript cannot read or change the surrounding Moodle page, its cookies or the session; SCORM scoring is relayed to Moodle through a validated postMessage channel. "Legacy" keeps the previous same-origin behaviour and should only be used if a specific package misbehaves under the secure mode (for example because it relies on browser storage that is not available to a sandboxed iframe).'; +$string['iframemode_legacy'] = 'Legacy (same-origin)'; +$string['iframemode_secure'] = 'Secure (sandboxed, isolated)'; $string['installstale'] = 'Installation may have failed. Please try again.'; $string['intro'] = 'Description'; $string['invalidaction'] = 'Invalid action: {$a}'; @@ -347,6 +355,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..475d4b5 --- /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: 'embed.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/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..dafc460 --- /dev/null +++ b/research/analisis/notas/AN-015-comparativa-soluciones-video-embeds.md @@ -0,0 +1,194 @@ +--- +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 `