Skip to content

Add secure iframe mode: opaque-origin sandbox + SCORM postMessage bridge#80

Draft
erseco wants to merge 45 commits into
mainfrom
feature/secure-iframe-scorm-bridge
Draft

Add secure iframe mode: opaque-origin sandbox + SCORM postMessage bridge#80
erseco wants to merge 45 commits into
mainfrom
feature/secure-iframe-scorm-bridge

Conversation

@erseco

@erseco erseco commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a configurable package iframe security mode (mod_exelearning/iframemode, default
secure) that isolates the arbitrary author HTML/JS of an .elpx from the Moodle
page. In secure mode the package runs in an opaque-origin sandboxed iframe, is
served via tokenpluginfile.php (so its assets load without the session cookie),
and SCORM scoring is relayed to Moodle over a validated postMessage bridge.
Legacy keeps the previous same-origin behaviour as an opt-in fallback. Secure mode
is never silently downgraded: where it cannot render, a notice is shown instead.

Implements the Tier 2 roadmap of DEC-0019 (DEC-0059) and the verified-correct serving
mechanism (DEC-0060, which corrects DEC-0059's "Route A").

Problem

The iframe used allow-scripts allow-same-origin, so package JS could read the parent
DOM/cookies and forge authenticated requests with the sesskey (RIE-001). Isolating
same-origin authenticated content from the parent with the sandbox attribute alone is
impossible: with allow-same-origin the content reaches the parent; without it, the
opaque document never sends the SameSite=Lax session cookie, so its subresources
(CSS/JS) 404 from pluginfile.php.

Technical approach

  • Opaque iframe + token serving. Secure mode drops allow-same-origin and serves
    the package through tokenpluginfile.php (make_pluginfile_url token in the URL
    path, a short-TTL core_files user key). Relative subresources inherit the token and
    load without the session cookie. tokenpluginfile still runs the component
    capability check (exelearning_pluginfilerequire_capability('mod/exelearning:view')).
  • In-iframe shim + parent relay. A baked window.API shim (js/scorm_bridge_shim.js,
    reusing js/scorm_tracker.js) buffers CMI, resolves objectids from its own DOM, and
    posts deltas to the parent. The parent relay (js/scorm_bridge_relay.js) validates by
    window identity (event.source === iframe.contentWindow), a closed action list and a
    per-view nonce, then performs the authenticated track.php POST (and a sendBeacon
    flush on pagehide). The sesskey never crosses the bridge.
  • CSP + Permissions-Policy emitted from exelearning_pluginfile() in secure mode:
    object-src 'none', base-uri 'none', frame-ancestors 'self', and connect-src
    pinned to this site (limits token exfiltration). Inline + eval scripts stay allowed
    (eXeLearning needs them).
  • No silent downgrade. If secure cannot render (e.g. a service-worker host that
    can't serve an opaque iframe, like a PHP-WASM playground), the shim never signals
    ready and a client-side watchdog reveals a "blocked by security configuration"
    notice (securemodeblocked) instead of falling back to legacy. The watchdog reacts to
    the iframe element's load event (which fires even when the navigation ends in an
    error page such as a 404) plus a short grace, so the notice appears right after the
    failed load rather than behind a fixed long wait. Legacy is opt-in.
  • Self-heal re-injects the bridge into packages extracted before this change; an
    in-iframe storage polyfill prevents opaque-origin SecurityError in shipped engine
    scripts (exe_atools, exe_export, checklist, edicuatex).

Security considerations

No allow-same-origin, no allow-popups-to-escape-sandbox, no allow-top-navigation,
no allow-modals. The content receives only a read-only file token (not the
sesskey) → strictly safer than legacy. Window-identity + nonce + closed-action
validation on the bridge; no generic exec surface; track.php/track::ingest
re-validates and clamps server-side. Residual: the token is visible in the iframe URL
(read-only, short TTL, connect-src-contained); a strict CSP toggle that also blocks
external img/script exfil is left as follow-up.

SCORM compatibility

pipwerks resolves window.API via pipwerks.SCORM.API.get(). The vendored wrapper's
get() had been altered to look only in window.parent, skipping the current window
— fine in legacy (the parent hosts the API) but fatal in secure: the opaque parent throws
SecurityError, so init() never activated and every score was silently dropped.
This branch restores the standard order (current window first; parent/opener as guarded
fallbacks), so the iframe-local shim API is found without touching the cross-origin parent.
LMSGetValue answers from a local cache; objectid routing (DEC-0017) and the
form/scrambled save guard (DEC-0042) are preserved. Covered by
tests/js/scorm_api_wrapper.test.js and verified live (attempt rows written, rawscore
100). See DEC-0062.

Moodle precedents investigated

Moodle's own H5P uses includetoken/tokenpluginfile when embedded and validates
postMessage by source+context (h5p/classes/player.php, h5p/js/embed.js); mod_scorm
walks window.parent for the API with no sandbox; SimplePie is the only core sandbox
use. No core precedent exists for a cross-origin/opaque sandbox of authenticated content
served from a separate origin (stated plainly); that is left as future infra (DEC-0019
Route B).

Configuration changes

New global setting mod_exelearning/iframemode (secure default / legacy) plus
language strings. No per-activity field; no DB/upgrade/backup changes.

Tests added (incl. security)

  • PHPUnit tests/player_iframe_test.php: mode resolution (fail-safe to secure),
    sandbox tokens per mode (secure has no allow-same-origin/allow-top-navigation/
    allow-modals/allow-popups-to-escape-sandbox), the CSP directives (object-src none,
    base-uri none, frame-ancestors self, connect-src pinned, no bare https: in
    connect-src
    ) and the Permissions-Policy.
  • Vitest tests/js/scorm_bridge.test.js: the relay rejects forged messages
    (wrong window source, wrong nonce, bad shape, unknown action), the storage polyfill,
    the handshake/queueing, and the watchdog (shows the blocked notice when ready
    never arrives; cleared when secure renders; the load-driven fast path arms the short
    grace timer on the iframe load, not the long fallback).

Verification

Verified in Chrome DevTools against a real Moodle: opaque token-served iframe
(tokenpluginfile.php/.../index.html, origin null), CSS/JS load, shim posts ready,
relay validates, track.php saved a grade ({ok:true, rawscore:100, peritem:{1:100}}),
watchdog does not false-fire. Also verified in the PHP-WASM Playground: the iframe is
built in secure mode (opaque, no allow-same-origin), tokenpluginfile returns 404
(the service worker does not control opaque subframes, so the URL falls through — the
token cannot help here; the blocker is the SW, not the cookie), the shim never readies,
and the notice is shown with the iframe hidden, confirming the no-downgrade
behaviour. phpcs --standard=moodle 0/0; Vitest 52/52; PHPUnit suite 319/319. Full
PHPUnit matrix + Behat run in CI.

Backward compatibility

legacy mode is byte-for-byte the previous behaviour (opt-in). Existing activities
self-heal the injected bridge on next view.

Limitations / follow-up

PHP-WASM / service-worker hosts (e.g. the Moodle Playground preview) cannot serve an
opaque-origin iframe — the service worker does not control opaque subframes, so the
token URL 404s — and therefore correctly show the secure-mode notice (now right after
the failed load) instead of rendering the package. This is expected: the Playground is a
preview host; real Moodle serves secure normally. Set iframemode=legacy (or the
blueprint config) if you want the Playground demo to render the package. Maximum
isolation (serving from a separate origin / subdomain) needs infrastructure outside the
plugin (DEC-0019 Route B). A strict-CSP admin toggle is a possible follow-up.


Moodle Playground Preview

The changes in this pull request can be previewed and tested using a Moodle Playground instance.

Preview in Moodle Playground

ℹ️ The eXeLearning editor is fetched from the shared release and unpacked into the plugin when the playground boots, so the first load may take a few extra seconds. ELPX upload, viewer and preview work normally.

## External embeds in secure mode (DEC-0061)

Secure mode's opaque sandbox also left YouTube/Vimeo players and PDFs blank (the
sandbox flag propagates to the nested player iframe; Chrome also blocks its PDF viewer
without allow-same-origin). This branch makes them work standalone, inline, with no
subdomain
, by promoting whitelisted embeds to the trusted parent:

  • A shim baked into the package (js/exe_embed_shim.js, injected alongside the SCORM
    bridge; self-activates only in the opaque origin, dormant in legacy) replaces
    whitelisted-video / .pdf iframes with placeholders and reports their geometry via
    postMessage.
  • An inline relay on the activity page (js/exe_embed_relay.js) validates + rebuilds the
    canonical URL (host whitelist, reject userinfo, id pattern) and overlays the real
    player inline over each placeholder.
  • PDFs: local package PDFs always render; any https .pdf renders; a same-origin
    .pdf must belong to this package (served by tokenpluginfile as application/pdf),
    so it can never be an executable same-origin route.

SCORM scoring is unaffected. The embed shim is independent of the bridge (different
postMessage type), the injector still bakes the SCORM bridge + pipwerks wrapper, and a
coexistence test (tests/js/embed_scorm_coexistence.test.js) guards that the bridge
still accepts scores with the embed modules loaded. Tests: tests/js/exe_embed.test.js
(validator + promotion), scorm_injector_test (shim baked without dropping the bridge);
the live container PHPUnit run (track_test + grades, 119 OK) confirms scoring saves.
Documented in DEC-0061. Test fixtures under research/fixtures/elpx/.

Providers + paging fix. The relay also recognises Dailymotion and
EducaMadrid/Mediateca de Madrid (per-provider canonical-URL validators), clamps the
overlay to the placeholder box (clickjacking defence in depth), and replaces the overlay
when the eXe content pages to another view — the in-iframe shim restarts its embed-id
counter per page, so a reused id previously kept the prior page's video. Regression test
in exe_embed.test.js; tools/check-embed-sync.mjs guards drift vs the wp/omeka mirrors.
Verified live on mod, wp and omeka with a real multi-page export (DEC-0062).

Update — open by default, no host list (DEC-0061 §6.3). The host allowlist is replaced by a
structural invariant: in the default open mode the relay promotes any iframe whose src is
https + cross-origin to Moodle (rejecting same-origin, sub/superdomains, IP/loopback/local hosts
and userinfo). A cross-origin sandboxed player is isolated from Moodle by the same-origin policy,
so the host is irrelevant to escape — the allowlist only mitigated phishing/tracking (which the
sandboxed content can already do). An admin setting embedmode (open|strict, fail-safe to strict)
keeps the allowlist + per-provider reconstruction for high-security sites. The promoted video player
is now sandboxed (allow-scripts allow-same-origin allow-popups allow-forms allow-presentation,
no top-navigation/modals) so an arbitrary embed cannot redirect the tab; a load-time guard removes any
embed that lands same-origin (redirect-laundering); promoted players are excluded from message auth
(cannot impersonate the content). PDFs stay unsandboxed (the browser PDF viewer fails inside a
sandbox). Supports any provider with zero maintenance, and reverses the earlier "no whitelist is
unsafe" note (correct only for Moodle's same-origin model, not this cross-origin sandboxed one).

@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.61290% with 39 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
classes/local/ui/player_iframe.php 76.47% 12 Missing ⚠️
js/exe_embed_shim.js 83.60% 10 Missing ⚠️
js/exe_embed_relay.js 94.15% 9 Missing ⚠️
classes/local/package_manager.php 75.00% 4 Missing ⚠️
lib.php 0.00% 3 Missing ⚠️
js/scorm_bridge_relay.js 98.55% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

erseco and others added 14 commits June 13, 2026 09:26
…load fails

On a host whose service worker cannot serve an opaque-origin iframe (e.g. the
PHP-WASM Moodle Playground), the token URL falls through to a 404 and the in-iframe
shim never announces 'ready'. The relay watchdog already reveals the
"blocked by security configuration" notice instead of degrading to same-origin, but
it waited a flat 8s, during which the iframe sat blank with no notice -- so secure
mode "looked like it worked" for several seconds.

The watchdog now reacts to the iframe element's 'load' event (which fires even when
the navigation ends in an error page such as that 404) and grants only a short grace
(~2.5s) for the handshake; if 'load' never fires it still falls back to the 8s cap.
The load listener attaches immediately if the iframe is present, otherwise on
DOMContentLoaded (the relay is injected inline before the iframe element).

Verified empirically in the Playground via Chrome DevTools: the iframe is built in
secure mode (opaque, sandbox without allow-same-origin), tokenpluginfile returns 404,
and the notice is shown with the iframe hidden -- the token does not help there
because the blocker is the service worker, not the cookie (DEC-0060).

Vitest 52/52 (two new tests cover the load-driven fast path).
The Playground's PHP-WASM service worker cannot serve an opaque-origin iframe, so
secure mode there only ever shows the "blocked by security configuration" notice. Set
iframemode=legacy in the blueprint setConfigs so the preview actually renders the
package and is useful. This is a Playground-only override applied at boot; real Moodle
keeps the secure default.
…e the iframe

Defense in depth for secure mode: the package is served via tokenpluginfile with an
executable CSP, so opening the token URL top-level (e.g. in a new tab) would run author
JS as Moodle's origin. Add a `sandbox allow-scripts allow-popups allow-forms` CSP
directive (secure mode + HTML only, via content_headers) so the document keeps an opaque
origin however it is loaded. Tokens mirror the secure iframe sandbox; the SCORM
postMessage bridge is unaffected because the iframe is already opaque. Unit test asserts
the directive is present in the secure CSP.

Raised while reviewing the sibling omeka-s-exelearning secure-iframe work; applied here
for consistency across the eXeLearning embedders.
In secure mode the package runs in an opaque-origin sandbox, which leaves
YouTube/Vimeo players and PDFs blank (the sandbox flag propagates to nested
iframes; Chrome also blocks its PDF viewer without allow-same-origin). Promote
those embeds to the trusted parent: a shim baked into the package replaces
whitelisted-video / .pdf iframes with placeholders and reports their geometry
via postMessage; an inline relay on the activity page validates + rebuilds the
URL and overlays the real player inline over each placeholder.

- js/exe_embed_shim.js: in-iframe, self-activates only in the opaque origin
  (dormant in legacy); dual-export for Vitest.
- js/exe_embed_relay.js: parent-side validate (host whitelist + canonical URL
  rebuild + same-origin package-file invariant for PDFs) and inline overlay.
- package_manager + scorm_injector: bake the shim + whitelist into every page
  head, alongside (and independent of) the SCORM bridge.
- player_iframe::embed_whitelist(); relay inlined in view.php (secure only).

PDFs: local package PDFs always render; any https .pdf renders; same-origin
PDFs must belong to this package (served as application/pdf, never executable).

Tests: Vitest validator/promote (exe_embed.test.js) + SCORM coexistence guard
(embed_scorm_coexistence.test.js); scorm_injector_test asserts the shim is baked
without dropping the SCORM bridge. Documented in DEC-0061. Fixtures under
research/fixtures/elpx/.
mod_exelearning serves package assets with relative URLs (unlike the wp/omeka
proxies, which rewrite to absolute), so a locally-packaged PDF was reported to the
parent relay as a relative src. The relay resolves URLs against the host page, not
the content, so it rejected the relative path and the local PDF did not render.

The shim runs inside the content, so it now resolves each src against the content
location and reports the ABSOLUTE URL. Verified live (secure mode): the embeds demo
renders 4 players inline (YouTube, Vimeo, remote PDF, local package PDF). Adds a
Vitest regression test (a relative src is reported absolute). Updates DEC-0061 with
the live results, including the SCORM scoring validation (track.php saved a full
attempt; gradebook + attempts report reflect it) confirming the embed shim does not
break scoring.
Comment thread js/exe_embed_shim.js Fixed
- Playwright/Firefox e2e (playwright-embed.config.cjs + tests/e2e/): loads the real
  shim + relay against an opaque-origin sandboxed harness and asserts a whitelisted
  YouTube embed and a RELATIVE local PDF are promoted to inline parent players while a
  non-whitelisted iframe is not. Proves the promote-to-parent mechanism works in
  Firefox, not just Chromium. Run with `npm run test:e2e:embed`.
- Vitest: add coverage for relay makePlayer() (video vs PDF attributes) and shim
  collect() (geometry report). The browser-bootstrap paths (init/report/observer) are
  covered by the e2e.
- README: document external embeds in Secure mode (whitelist + PDF policy + how to
  run the tests).
Comment thread tests/e2e/embed.spec.cjs Fixed
erseco added 3 commits June 14, 2026 06:54
…tch gate

- CodeQL (high): the e2e spec asserted a non-whitelisted host was absent with
  src.includes('example.com') ("incomplete URL substring sanitization"). Rewrite the
  assertions to parse exact hostnames (new URL().hostname) and an anchored regex for the
  canonical YouTube URL, so no URL is checked by substring.
- Codecov patch: cover the relay's createRelay()/onMessage -> overlay player path and
  the instance validate() in Vitest, and mark the browser-only bootstrap of both the
  shim (init/report/observer) and the relay (init/pingAll/scheduleReflow) as v8-ignore
  (they require a framed, opaque-origin window and are exercised by the Firefox e2e, not
  happy-dom). exe_embed_relay.js 95% / exe_embed_shim.js 84% line coverage; 77 unit tests.
…dow first

The vendored pipwerks API.get() started its lookup at win.parent and skipped the
current window. In secure (opaque-origin) mode the SCORM API is provided locally
as window.API by the in-iframe bridge shim (DEC-0059) and the Moodle parent is a
cross-origin/opaque frame, so reaching into parent.API threw SecurityError,
init() never activated the connection, and every LMSSetValue/LMSCommit became a
silent no-op -- no attempt rows were ever written. Legacy (same-origin) mode
masked it because the parent there hosts the API.

Restore the standard pipwerks order: try find(window) first, then fall back to
the parent and opener, with every cross-origin hop wrapped so an opaque ancestor
can never abort the lookup. Legacy keeps working: with no local API, find(window)
walks up to the same-origin parent exactly as before.

Add tests/js/scorm_api_wrapper.test.js (loads the vendored wrapper with a
controllable window) covering current-window-first, opaque-parent safety, the
null fallback, the legacy walk-up, and the full init()/set() save path. Document
the root cause and the DEC-0059 verification gap in DEC-0062.
…eometry

Extend the external-embed allowlist with Dailymotion (www/geo.dailymotion.com,
/embed/video/{id}) and EducaMadrid/Mediateca de Madrid (mediateca.educa.madrid.org,
/video/{id}/fs), each with a per-provider canonical-URL validator in the relay so
only a reconstructed, known-good embed URL is ever rendered. Clamp the relayed
player overlay to the placeholder's rect (defence in depth against geometry-driven
clickjacking; the overlay already clips with overflow:hidden). Mark the shim/relay
as the canonical source for the wp/omeka mirrors. Refresh the demo fixture and the
Vitest + Firefox e2e coverage with the new hosts and their reject cases.
Comment thread js/exe_embed_shim.js Fixed
The in-iframe shim restarts its embed-id counter on every page, so after the
content navigates (e.g. eXe multi-page next/prev), the new page's first embed
reuses id exe-embed-1. The relay reused the existing player for that id and only
repositioned it, never updating its src -- so the previous page's video (e.g.
YouTube) lingered on the next page, stretched to the new box.

Tag each player with the URL it renders (data-exe-embed-src) and, in sync(),
replace the player when a reused id now maps to a different URL instead of
repositioning the stale one. Add a regression test covering a reused id that
navigates from YouTube to Vimeo.
Update DEC-0061 with the 2026-06-14 work: the necessity re-validation against a
real eXe export (machinery confirmed required on four axes, not assumed), the
added Dailymotion + EducaMadrid providers, the sandbox-token alignment (allow-forms
in the iframe attribute AND the response-level CSP sandbox directive), and the
page-navigation lingering-embed fix.

Add tools/check-embed-sync.mjs: a local maintenance helper that verifies the
shim/relay logic + whitelist hosts + sandbox tokens stay in sync across the three
embedders (mod canonical, wp/omeka mirrors). It is not a CI gate (no shared infra);
the new header comments in the shim/relay point to it.
@erseco erseco marked this pull request as draft June 14, 2026 12:49
erseco added 2 commits June 14, 2026 14:10
…-0061)

The interactive-video iDevice with remote sources (YouTube/EducaMadrid) drives the
YouTube IFrame Player API to pause and overlay timed questions; in the opaque
sandbox the player cannot render in the iframe, and promoting it to the parent
decouples it from the iDevice's control. A player-side control bridge was
prototyped (video played, questions fired) but reverted: reconstructing the
iDevice's cover/float/slide layout from the parent is fragile. Record the
decision -- remote interactive-video needs legacy mode; local-file source works in
secure -- and that the proper fix belongs upstream in the eXe iDevice (detect the
opaque origin and degrade, or drive a parent relay itself).
…st allowlist

Replace the maintained host allowlist with a structural invariant (DEC-0061): in the
default 'open' mode the relay promotes 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. The host is irrelevant to escape -- a cross-origin sandboxed player is isolated
from the LMS by the same-origin policy -- so the allowlist only mitigated phishing/
tracking, which the sandboxed content can already do. An admin setting 'embedmode'
(open|strict, fail-safe to strict) keeps the allowlist + per-provider reconstruction for
high-security deployments. This supports any provider with no maintenance and supersedes
the earlier "no whitelist is unsafe" conclusion (correct only for Moodle's same-origin
model, not this cross-origin sandboxed one).

Security conditions (verified):
- The video player is now SANDBOXED: allow-scripts allow-same-origin allow-popups
  allow-forms allow-presentation, with NO allow-top-navigation/allow-modals -- so an
  arbitrary embed cannot redirect the tab. allow-same-origin sits on the cross-origin
  player (the provider keeps its own origin, never the LMS's) and is what lets it be
  sandboxed; it is not the forbidden content-iframe case.
- D1: a load-time guard removes a player that lands same-origin to the LMS (a cross-origin
  URL that 30x-redirects to this origin would otherwise be scriptable with allow-same-origin).
- D2: promoted players are tagged data-exe-embed-player and excluded from frameForSource/
  pingAll, so a sandboxed player cannot forge a sync message and impersonate a content source.
- The shim promotes any cross-origin https / .pdf candidate; the relay is the authoritative
  gate. PDFs stay unsandboxed (the browser PDF viewer fails inside a sandbox), unchanged.

Rewrite the Vitest suite (open + strict + IP/subdomain helpers + sandbox + forged-message
defence), update the Firefox e2e (open mode: every cross-origin/PDF iframe promoted, players
sandboxed), update tools/check-embed-sync.mjs invariants, and document it in DEC-0061.
Comment thread js/exe_embed_shim.js Fixed
erseco added 4 commits June 14, 2026 16:19
…eanups

Quality-only cleanups (no behaviour change):
- scorm_injector: drop the dead window.__exeEmbedWhitelist global, collapse the
  three <head>-insertion blocks into a table-driven loop, and build the script
  payloads once per file from one $libs prefix instead of six root/.. pairs.
- view.php: extract an inline-module emit helper and only ship the embed
  whitelist in strict mode (open mode ignores it).
- admin styles: extract the duplicated action_link() into a shared
  styles_action_button helper.
- JS: reuse armBlockedTimer in the watchdog; move scorm_tracker's snapshot to the
  async-only XHR path; hoist getBoundingClientRect out of sync()'s per-embed loop.
- Embed relay/shim: normalize a trailing-dot FQDN-root host so the served
  host in 'host.' form can no longer slip past the open-mode cross-origin
  gate and be promoted as a player.
- SCORM bridge relay: reject a forged track when the expected nonce is
  empty, and require a non-null iframe contentWindow (no null===null match).
- exelearning_pluginfile: emit Referrer-Policy: no-referrer and
  X-Content-Type-Options: nosniff on every secure-mode package file so the
  in-URL file token cannot leak via Referer and a .pdf path cannot smuggle
  executable HTML.
- Tests: trailing-dot, nonce-empty and null-contentWindow regression cases;
  content_headers per-file header assertions; check-embed-sync records
  normalizeHost as a relay invariant.
Comment thread js/exe_embed_shim.js
// 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;
erseco and others added 12 commits June 17, 2026 10:26
Brings in the xAPI/ADL research (DEC-0063, renumbered from DEC-0059 to avoid collision with this branch's DEC-0059..0062) and main CI updates. docs/indices conflicts resolved by regenerating with build_indexes.py.
Brings in the research/ cleanup (retire arquitectura/+inventario/, reconcile ADR/task states, refresh sources) and the unreferenced-fixtures removal (~13MB). Clean auto-merge; indices regenerated. DEC-0059..0063 coexist (no ID collision).
Brings in the recorded maintainer decisions (xAPI endpoint design DEC-0063, DEC-0033 scope, etc.). Conflict in status.yaml RIE-001 resolved by keeping this branch's estado: mitigado (the secure-iframe work DEC-0059..0062 actually implements the hardening) over main's estado: aceptado; the TAREA-013 note lives in the merged TODO.md. Indices regenerated.
Resolve view.php conflict between the secure-iframe SCORM bridge (DEC-0059/
DEC-0060/DEC-0062) and the xAPI ingestion layer (DEC-0064), which both
rewrite the SCORM-tracker bootstrap.

Resolution: keep the secure/legacy split (bridge relay + embed relay in secure
mode, inline scorm_tracker in legacy mode) and fold in the xAPI path, but gate
xAPI-primary to legacy mode:

  $emitsxapi = !$securemode && exelearning_xapi_primary_enabled()
             && exelearning_package_emits_xapi(...)

Rationale: js/xapi_listener.js trusts a statement by event.origin === host
origin. In secure mode the package runs in an opaque origin (event.origin is
'null'), so the listener can never receive its statements. There the SCORM
bridge relay (window-identity + nonce) is the working channel, so the SCORM shim
must stay LIVE; setting disableTracking there would record zero grades (the
DEC-0062 failure secure mode exists to fix). disableTracking and the shared
$sessiontoken/xAPI registration therefore only take effect in legacy mode.
Bridging xAPI over the secure relay is left as a follow-up.
…-0065)

The xAPI ingestion layer (DEC-0064) assumed the package iframe is served
same-origin: js/xapi_listener.js trusts a statement by event.origin === host
origin. The secure iframe mode (DEC-0059/0060) serves the package from an
opaque origin where event.origin is the string "null", so that check can never
match and every statement is dropped. The branch merge had gated xAPI off in
secure mode as a stopgap; this enables it properly.

- js/xapi_listener.js: add a window-identity trust mode. When configured with
  iframeid (or expectedSource for tests) the listener trusts a statement by
  event.source === the package iframe's contentWindow (resolved lazily) and
  ignores the opaque "null" origin, exactly like the SCORM bridge relay. Legacy
  same-origin keeps the event.origin check.
- js/scorm_bridge_relay.js: add disableTracking. When set, the relay still
  validates the SCORM message and still runs the ready handshake + watchdog, but
  forwards no track.php POST. The decision lives on the trusted parent (fresh
  each load), not the baked-in shim, so it holds even for packages extracted
  before the flag; the opaque shim cannot reach track.php itself (no sesskey).
- view.php: xAPI-primary now applies in both iframe modes (drop the !secure
  gate). Secure passes disableTracking to the relay and injects the listener
  with iframeid; legacy injects it with allowedOrigin.
- Tests (Vitest 115/115): window-identity accept/reject + precedence, lazy
  resolution; relay disableTracking suppresses the POST while ready still
  handshakes.
- Docs: ADR DEC-0065 (+ adrs/diario indices, diario entry); English
  tracking-architecture.md / xapi-integration-plan.md; QA checklist scenarios
  for secure-mode grading and the kill-switch-mid-session limitation.

Reviewed adversarially (6 lenses, verified): no new double-grading or grade-loss
path; the kill-switch mid-session divergence is a pre-existing DEC-0064 property
(documented), not introduced here.
A multi-agent adversarial review of the branch found no critical/high bugs;
these are the 3 confirmed low-severity items.

- scorm_bridge_shim.js: isSandboxedOpaque() treated ANY web-storage exception as
  "opaque", so in legacy (same-origin) mode a QuotaExceededError or a disabled-
  storage policy would wrongly activate the baked shim — which posts scores to a
  parent that has no relay listener, silently losing the grade. Narrow the
  secondary probe to a genuine SecurityError (the only opaque-origin signal).
- exe_embed_relay.js: a promoted CROSS-ORIGIN PDF was overlaid unsandboxed, so a
  package embedding https://attacker/x.pdf that serves HTML could top-navigate the
  Moodle tab to a phishing page (the exact thing the video player blocks). Sandbox
  cross-origin PDFs without allow-top-navigation/allow-scripts; same-origin package
  PDFs (served application/pdf+nosniff) stay unsandboxed so the viewer renders.
  Fixes the stale makePlayer doc-comment that already claimed the PDF was sandboxed.
- lang/{es,ca,eu,gl}: add the 9 secure-iframe/embed strings (embedmode*, iframemode*,
  securemodeblocked) that existed only in lang/en, restoring 5-language parity for
  the admin settings and the student-facing blocked notice (~ pending-review prefix).

Tests: Vitest 117/117 (new: non-SecurityError storage must not count as opaque;
cross-origin PDF is sandboxed without top-nav; same-origin PDF stays unsandboxed).
…imeo)

Compare the secure YouTube/Vimeo embed approach of mod_exelearning (DEC-0061,
promote-to-parent inline overlay) against three siblings: procomún
(fix/apertura-segura-elpx, click→modal), the eXeLearning editor
(fix/opaque-iframe-external-media, producer-side {provider,videoId}+MessagePort
channel), and the security paper (the SoK that names the model).

All four share the opaque-origin + promote-to-parent + SOP-isolation model;
they differ by layer and trust channel. Verdict (role-dependent) + a concrete
recommendation for the plugin (keep the relay; borrow eXe's id-only channel;
make interactive-video complementary; add Vimeo canonicalization).

Adds AN-015 + REPO-010 (procomún) + REPO-011 (paper) source entries and the
notas/repos indices.
Reconcile teacher-mode handling with main (#86). main retired the host-side
CSS injection in favour of the package's own ?exe-teacher=1 URL parameter, so:

- view.php: keep the secure/legacy iframe-mode resolution and append
  ?exe-teacher=1 to the resolved $iframeurl when teachermodevisible is on. The
  parameter works in secure (opaque-origin) mode too because the package reads
  its own location.search, so no host CSS injection is needed in either mode.
- Drop the exelearning_require_teacher_mode_hider() call (the helper and
  classes/local/ui/teacher_mode_hider.php were removed on main).
- Retire the SCORM bridge's teacher-mode path, now redundant: remove
  teachermodevisible from the relay config ($relaycfg), the relay handshake
  postMessage and the in-iframe shim's hideTeacherMode() (it injected the exact
  #teacher-mode-toggler-wrapper CSS that #86 retired). Update tests/js
  accordingly. SCORM tracking over the bridge is unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants