Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5b485cc
feat(security): add iframemode admin setting + player_iframe sandbox …
erseco Jun 13, 2026
9c862dc
feat(scorm): add transport option to scorm_tracker for the bridge
erseco Jun 13, 2026
5a4b220
feat(scorm): add in-iframe SCORM bridge shim with storage polyfill
erseco Jun 13, 2026
f91036b
feat(security): add parent-side postMessage relay; opaque-origin ifra…
erseco Jun 13, 2026
9da6935
feat(scorm): inject and ship the bridge client into the package
erseco Jun 13, 2026
70deb3d
test: PHPUnit iframe-mode + Vitest bridge/relay/storage tests
erseco Jun 13, 2026
377fb1f
docs(research): DEC-0059 secure SCORM postMessage bridge (advances DE…
erseco Jun 13, 2026
245ff2d
fix(playground): force legacy iframemode; document opaque-origin serv…
erseco Jun 13, 2026
5a828f3
feat(security): serve secure-mode content via tokenpluginfile + CSP/P…
erseco Jun 13, 2026
4f3dc9a
fix(security): self-heal the bridge client into packages extracted be…
erseco Jun 13, 2026
441857e
feat(security): show a blocked notice instead of silently downgrading…
erseco Jun 13, 2026
1a82ed7
fix(playground): show the secure-mode notice instead of forcing legac…
erseco Jun 13, 2026
5584f72
docs(research): DEC-0060 secure iframe via tokenpluginfile (corrects …
erseco Jun 13, 2026
2a72083
test: fix CSP test regex so it forbids only the bare https: wildcard,…
erseco Jun 13, 2026
61987ce
test: cover new secure-iframe branches (transport, shim/relay/watchdo…
erseco Jun 13, 2026
b7a0e4d
fix(security): surface the secure-mode notice right after the iframe …
erseco Jun 13, 2026
7ef8401
fix(playground): force iframemode=legacy in the demo blueprint
erseco Jun 13, 2026
5d8fb99
Merge branch 'main' into feature/secure-iframe-scorm-bridge
erseco Jun 13, 2026
945f895
fix(security): sandbox the package response so it stays opaque outsid…
erseco Jun 13, 2026
a373af1
Render external embeds (YouTube/Vimeo/PDF) inline in secure mode
erseco Jun 14, 2026
45a0668
Report absolute embed URLs from the shim (fix relative local PDFs)
erseco Jun 14, 2026
ecf55a7
Add Firefox e2e + more coverage + docs for external embeds
erseco Jun 14, 2026
fed21b7
Fix CodeQL alert in the e2e spec + lift embed JS coverage past the pa…
erseco Jun 14, 2026
19a912a
Fix secure-mode SCORM saving: pipwerks get() must check the local win…
erseco Jun 14, 2026
c8df033
Add Dailymotion + EducaMadrid embed providers and clamp the overlay g…
erseco Jun 14, 2026
a99997e
Fix lingering external embed when the eXe content pages to another view
erseco Jun 14, 2026
78f3189
Document the embed providers/nav-fix and add the cross-repo drift check
erseco Jun 14, 2026
511f2b0
Document the interactive-video iDevice limitation in secure mode (DEC…
erseco Jun 14, 2026
a695be3
Embeds: structural invariant (any cross-origin https) instead of a ho…
erseco Jun 14, 2026
a7a147c
Fix lang string order: embedmode belongs after embedded*, before err_*
erseco Jun 14, 2026
75a2691
test: add remote-embeds fixture referenced by DEC-0061
erseco Jun 14, 2026
73fe6ff
Simplify secure-iframe/SCORM-bridge: dedup, dead-code and hot-path cl…
erseco Jun 14, 2026
69185fe
Harden secure-iframe embed/bridge gates and package response headers
erseco Jun 17, 2026
d3cf42f
Merge origin/main into feature/secure-iframe-scorm-bridge
erseco Jun 17, 2026
7f1f7e9
Merge origin/main into feature/secure-iframe-scorm-bridge
erseco Jun 17, 2026
8e8e970
Merge origin/main into feature/secure-iframe-scorm-bridge
erseco Jun 17, 2026
ac8ea7b
Merge branch 'main' into feature/secure-iframe-scorm-bridge
erseco Jun 17, 2026
8f65399
Merge branch 'main' into feature/secure-iframe-scorm-bridge
erseco Jun 18, 2026
500d788
Merge branch 'main' into feature/secure-iframe-scorm-bridge
erseco Jun 18, 2026
3334572
Merge origin/main into feature/secure-iframe-scorm-bridge
erseco Jun 19, 2026
2bb8e0c
feat(xapi): grade via xAPI in secure (opaque-origin) iframe mode (DEC…
erseco Jun 19, 2026
698a015
fix(secure-iframe): harden 3 low-severity edges found in the PR review
erseco Jun 19, 2026
97e26eb
docs(research): AN-015 comparison of video-embed solutions (YouTube/V…
erseco Jun 19, 2026
070976f
Merge branch 'main' into feature/secure-iframe-scorm-bridge
erseco Jun 22, 2026
91bce99
Merge branch 'main' into feature/secure-iframe-scorm-bridge
erseco Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ node_modules/
# JS test artifacts (Vitest)
coverage/
junit-js.xml

# E2E artifacts (Playwright)
test-results/
playwright-report/
.env
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
31 changes: 27 additions & 4 deletions assets/scorm/SCORM_API_wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions blueprint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
32 changes: 2 additions & 30 deletions classes/admin/admin_setting_stylesbuiltins.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 <form>: this setting is shown inside the
* admin settings page, which already wraps every setting in one <form>. A nested
* <form> 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']);
}
}
35 changes: 4 additions & 31 deletions classes/admin/admin_setting_stylesuploaded.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 <form>: this setting appears inside the
* admin settings page, which already wraps every setting in one <form>. A nested
* <form> 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']);
}
}
63 changes: 63 additions & 0 deletions classes/admin/styles_action_button.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
// 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 <http://www.gnu.org/licenses/>.

/**
* 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 <form>: these settings appear inside the
* admin settings page, which already wraps every setting in one <form>. A nested <form>
* 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']);
}
}
41 changes: 29 additions & 12 deletions classes/local/package_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -240,33 +240,50 @@ 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,
'component' => 'mod_exelearning',
'filearea' => 'content',
'itemid' => (int) $data->revision,
'filepath' => '/libs/',
'filename' => $shimname,
'filename' => $destname,
], $assetpath);
}

Expand Down
Loading
Loading