diff --git a/.distignore b/.distignore index 3c6de93..e78fe97 100644 --- a/.distignore +++ b/.distignore @@ -13,11 +13,15 @@ # --- Frontend sources ------------------------------------------------- # All TypeScript / Vue under src/ is compiled into js/ at build time # and not needed at runtime, EXCEPT src/sw/ which `SwController.php` -# reads to serve /apps/exelearning/sw.js. Whitelist that subtree -# before excluding the rest of src/. +# reads to serve /apps/exelearning/sw.js, and +# src/embed/exe_external_media/ which `ContentController.php` inlines into every +# served package. Whitelist those subtrees before excluding the rest of src/. + src/ + src/sw/ + src/sw/** ++ src/embed/ ++ src/embed/exe_external_media/ ++ src/embed/exe_external_media/** - src/** # --- Version control & developer metadata -------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 809ebe7..13b1cad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,19 @@ jobs: - name: Install dependencies run: npm ci + - name: Verify the vendored external-media artifact + # eXeLearning core is canonical (exelearning/exelearning ADR-2199-12): this repo holds the BYTES and + # verifies them against the manifest core published, rather than a copy of the + # logic that could drift. --build-hash makes it a PROVENANCE check and not merely + # an integrity one: without it a locally rebuilt-and-resigned copy would pass. + # + # The expected hash is PINNED HERE, deliberately, and not read from the vendored + # manifest. A hash taken from the copy under test cannot say anything about that + # copy: file, digest and buildHash can all be rewritten together, and a check that + # trusted them would pass a consistent forgery. Bump it in the same commit that + # re-vendors the artifact. + run: node src/embed/exe_external_media/verify.mjs src/embed/exe_external_media --build-hash "43c776daa551fd770b6dade520629362e5a8ca8745e4245c359447c4689b34d6" + # Biome lints .ts/.js under src/. Vue single-file components stay # with ESLint (better Vue support); see biome.json for scope. - name: Biome lint @@ -331,3 +344,94 @@ jobs: # that the file actually got staged into the app tree. test -f apps/exelearning/src/sw/exelearning-sw.js \ || test -f custom_apps/exelearning/src/sw/exelearning-sw.js + + preview-e2e: + name: Editor-preview API E2E (real Nextcloud) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo_sqlite, gd, zip, intl, mbstring, ctype, curl, dom, fileinfo, simplexml, xml, xmlreader, xmlwriter + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + + - name: Install JS dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Set up Nextcloud server + uses: SMillerDev/nextcloud-actions/setup-nextcloud@main + with: + version: stable33 + database-type: sqlite + cron: false + + - name: Install eXeLearning app into the running Nextcloud + uses: SMillerDev/nextcloud-actions/setup-nextcloud-app@main + with: + app: exelearning + check-code: false + + # Serve the installed Nextcloud over HTTP (the built-in PHP server routes + # everything through the front controller, `/index.php/...`), enable the + # app, create the owner + a second user, then drive the real management + # and serving routes end to end. Cookie/session auth is used on purpose so + # the CSRF middleware is genuinely exercised. + - name: Preview API end-to-end against the running Nextcloud + working-directory: ../server + env: + WORKSPACE: ${{ github.workspace }} + run: | + set -euo pipefail + php occ app:enable exelearning + php occ config:system:set trusted_domains 1 --value=127.0.0.1 + # Treat the origin as plain HTTP end-to-end so the login session cookie + # and the SameSite cookies are NOT marked Secure — curl over http never + # sends a Secure cookie back, which silently breaks the login POST's + # own CSRF/strict-cookie check and leaves every request unauthenticated. + # Use a webroot WITHOUT /index.php so cookies get Path=/ (routing still + # goes through the /index.php front controller under php -S). + php occ config:system:set overwrite.cli.url --value="http://127.0.0.1:8080" + php occ config:system:set overwriteprotocol --value=http + php occ config:system:delete forcessl 2>/dev/null || true + php occ config:system:delete overwritehost 2>/dev/null || true + + export OC_PASS='Preview-e2e-owner-1!' + php occ user:add --password-from-env previewe2e1 + export OC_PASS='Preview-e2e-other-2!' + php occ user:add --password-from-env previewe2e2 + + # The built-in server's document root is the Nextcloud tree, so the + # top-level status.php is served directly (routed app URLs go through + # /index.php/... instead). + php -S 127.0.0.1:8080 >"${RUNNER_TEMP}/nc-server.log" 2>&1 & + echo $! > "${RUNNER_TEMP}/nc-server.pid" + ready= + for _ in $(seq 1 30); do + if curl -sf "http://127.0.0.1:8080/status.php" | grep -q '"installed":true'; then ready=1; break; fi + sleep 1 + done + [ "$ready" = "1" ] || { echo "Nextcloud did not come up"; curl -s "http://127.0.0.1:8080/status.php" || true; cat "${RUNNER_TEMP}/nc-server.log"; exit 1; } + + EXE_E2E_BASE="http://127.0.0.1:8080/index.php" \ + EXE_E2E_USER1=previewe2e1 EXE_E2E_PASS1='Preview-e2e-owner-1!' \ + EXE_E2E_USER2=previewe2e2 EXE_E2E_PASS2='Preview-e2e-other-2!' \ + bash "${WORKSPACE}/tests/e2e/preview-api-e2e.sh" \ + || { echo '--- nextcloud.log (tail) ---'; tail -n 80 data/nextcloud.log 2>/dev/null || true; exit 1; } + + kill "$(cat "${RUNNER_TEMP}/nc-server.pid")" 2>/dev/null || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7677c2e..203826d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,7 +77,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - CI matrix (`.github/workflows/ci.yml`) covering NC 31/32/33 × PHP 8.2/8.3/8.4 with rotated databases (sqlite/mysql/pgsql), plus an experimental PHP 8.5 cell. Each cell installs the app into a real - Nextcloud server and verifies the Service Worker route responds. + Nextcloud server and verifies it enables cleanly, and an API-level + end-to-end job exercises the editor-preview management and serving routes + against a live Nextcloud (CSRF enforcement, ownership, revision publish, + sandbox CSP, session deletion). - `make ci-matrix` reproduces the matrix locally with Docker. - README "Compatibility" table backed by the CI matrix and a CI badge. - Initial Nextcloud app scaffold (`appinfo/info.xml`, routes, bootstrap). diff --git a/Makefile b/Makefile index 792df11..cd3115a 100644 --- a/Makefile +++ b/Makefile @@ -362,7 +362,12 @@ appstore: package # sidesteps that. # # Override host port with `make up DOCKER_PORT=9000`. -APP_RUNTIME_DIRS := appinfo lib js templates img src/sw +# src/embed carries the vendored external-media child bundle, which +# ContentController inlines into every served package AT RUNTIME — like src/sw, +# it is shipped source, not build input. Leaving it out made shimSource() return +# null inside the container: no shim injected, no embed ever promoted, and no +# error anywhere to say so. +APP_RUNTIME_DIRS := appinfo lib js templates img src/sw src/embed # Verify both that the docker CLI exists and that its daemon is # reachable. `docker version` talks to the daemon, so it fails with a diff --git a/README.md b/README.md index 822298b..130f3b3 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,24 @@ When a user clicks a `.elpx` file in Nextcloud Files: 1. A full-page view opens at `/apps/exelearning/view`. The Nextcloud Viewer modal is deliberately not used: its built-in chrome cannot be customised, and the preview wants a full-bleed layout with a single Edit affordance. -2. The package is downloaded for the current user. -3. The browser extracts the ZIP archive in memory. -4. The internal `index.html` renders inside a **sandboxed iframe**. -5. Relative assets (`html/`, `content/`, `libs/`, `theme/`, `idevices/`, - images, CSS, JS, audio, video, …) are served by a scoped Service Worker - from the in-memory extraction — no second request to the server. +2. The package is downloaded and validated for the current user. +3. The internal `index.html` and its relative assets (`html/`, `content/`, + `libs/`, `theme/`, `idevices/`, images, CSS, JS, audio, video, …) render + inside an **opaque-origin sandboxed iframe** — a sandbox **without** + `allow-same-origin`, so the untrusted author scripts cannot reach the + Nextcloud session. The bytes are served over real HTTP from a cookieless, + capability-token route (`/content/{token}/{path}`), because a Service Worker + cannot back an opaque origin. External media (YouTube/Vimeo/PDF/…) is relayed + to the trusted parent page. See [docs/secure-iframe-viewer.md](docs/secure-iframe-viewer.md). + +The bundled static editor ("Edit with eXeLearning") renders its live **editor +preview** the same way — an opaque-origin iframe served over the HTTP preview +transport (serving contract v2), never the Service Worker. See +[docs/preview-serving-contract.md](docs/preview-serving-contract.md). + +> The legacy Service Worker viewer remains only as an explicit, dev-only opt-in +> (a trusted-content compatibility mode, **not** a security boundary); the opaque +> HTTP path is the default. Thumbnails are powered by the `screenshot.png` inside the `.elpx` package. @@ -99,8 +111,9 @@ app registers through whichever one the running server reads. | npm | 10 or 11 | | Bun (optional) | latest stable, only for `build-editor` | -Browsers must support Service Workers in the Nextcloud origin. The viewer -will refuse to start otherwise. +The default opaque-origin viewer and the HTTP editor preview are served over +plain HTTP and do **not** require Service Workers. Only the legacy, opt-in +Service Worker viewer needs Service Worker support in the Nextcloud origin. ## Quick start with Docker diff --git a/appinfo/info.xml b/appinfo/info.xml index ecf3363..ef69026 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -20,7 +20,7 @@ The **eXeLearning app for Nextcloud** allows users to **view, edit and create eX - **File previews:** use the `screenshot.png` image included in resources to generate thumbnails in Nextcloud. - **Integrated editor:** the installation package includes the eXeLearning web editor. Installations from a source checkout must download it using `make download-editor`. -The app registers a **Files** action that opens `.elpx` packages in a full-page view. HTML pages, CSS, JavaScript and multimedia files are loaded through a scoped Service Worker and rendered inside a sandboxed iframe. +The app registers a **Files** action that opens `.elpx` packages in a full-page view. Because the rendered author content is untrusted, HTML pages, CSS, JavaScript and multimedia files are served from a cookieless, capability-token HTTP route into an iframe without `allow-same-origin` — a browser-enforced opaque origin isolated from the Nextcloud session. External media (YouTube, Vimeo, PDF, …) is relayed to the trusted parent page, and the bundled editor renders its live preview the same way. The legacy Service Worker viewer survives only as a dev-only opt-in. ]]> 0.0.0-alpha agpl @@ -57,4 +57,8 @@ La aplicación añade una acción en **Archivos** que abre los paquetes `.elpx` + + + OCA\ExeLearning\BackgroundJob\PreviewCleanupJob + diff --git a/appinfo/routes.php b/appinfo/routes.php index bf094eb..773266a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -21,6 +21,42 @@ 'verb' => 'GET', 'requirements' => ['sessionId' => '\d+', 'path' => '.+'], ], + [ + 'name' => 'content#serve', + 'url' => '/content/{token}/{path}', + 'verb' => 'GET', + 'requirements' => ['token' => '[A-Za-z0-9._~-]+', 'path' => '.+'], + 'defaults' => ['path' => 'index.html'], + ], + // Opaque editor preview. The serving route is an authless, cookieless + // capability URL gated solely on the unguessable previewId UUID; the + // management routes are authenticated + owner-scoped (CSRF on). + [ + 'name' => 'preview#serve', + 'url' => '/preview/{previewId}/{path}', + 'verb' => 'GET', + 'requirements' => [ + 'previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', + 'path' => '.+', + ], + ], + [ + 'name' => 'preview#serveRoot', + 'url' => '/preview/{previewId}', + 'verb' => 'GET', + 'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'], + ], + [ + 'name' => 'previewSession#create', + 'url' => '/api/preview-session', + 'verb' => 'POST', + ], + [ + 'name' => 'previewSession#delete', + 'url' => '/api/preview-session/{previewId}', + 'verb' => 'DELETE', + 'requirements' => ['previewId' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'], + ], [ 'name' => 'thumbnail#byFileId', 'url' => '/thumbnail/by-file-id/{fileId}', diff --git a/biome.json b/biome.json index 3f4d06f..c95d50d 100644 --- a/biome.json +++ b/biome.json @@ -16,7 +16,8 @@ "!**/coverage/**", "!**/js/**", "!**/exelearning/**", - "!**/*.vue" + "!**/*.vue", + "!**/src/embed/exe_external_media/**" ] }, "formatter": { diff --git a/blueprint.json b/blueprint.json index 22817ce..91a9edd 100644 --- a/blueprint.json +++ b/blueprint.json @@ -30,6 +30,13 @@ "url": "https://github-proxy.exelearning.dev/?repo=exelearning/exelearning&release=v4.0.6&asset=exelearning-static-v4.0.6.zip", "destination": "apps/exelearning/js/editor" }, + { + "step": "setConfig", + "comment": "DEV-ONLY Playground demo hatch — NEVER production. The viewer defaults to the secure opaque-origin /content/{token} route, which a Service Worker CANNOT serve (it 404s in the php-wasm Playground). This app-config value is read by the envReader wired in lib/AppInfo/Application.php and flips lib/Service/IframeSandbox::resolveMode() to 'legacy', so ViewController hands the viewer the same-origin Service-Worker/AssetController path that DOES render in the browser. Mirrors the wp-exelearning mu-plugin / mod_exelearning phpConstants demo hatch. Maps to `occ config:app:set exelearning unsafe_legacy_iframe --value 1`; re-introduces allow-same-origin and drops opaque-origin isolation, so it must never be set on a real deployment.", + "app": "exelearning", + "key": "unsafe_legacy_iframe", + "value": "1" + }, { "step": "writeFile", "path": "config/mimetypemapping.json", diff --git a/docs/architecture/adr/ADR-68-01-let-the-package-download-button-work-in-the-opaque-viewer.md b/docs/architecture/adr/ADR-68-01-let-the-package-download-button-work-in-the-opaque-viewer.md new file mode 100644 index 0000000..0b73d36 --- /dev/null +++ b/docs/architecture/adr/ADR-68-01-let-the-package-download-button-work-in-the-opaque-viewer.md @@ -0,0 +1,142 @@ +--- +id: ADR-68-01 +title: "Let the package's own download button work in the opaque viewer" +status: Proposed +date: 2026-10-07 +tracking_issue: 68 +deciders: + - "@erseco" +reviewers: + - "@erseco" +related: + prs: [68] + changes: [] + adrs: [] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "claude-opus-5-5" +--- + +# ADR-68-01: Let the package's own download button work in the opaque viewer + +## Context + +eXeLearning packages can include the download-source-file iDevice. Its button +calls the package's global `downloadElpx()`, which refetches every file of the +package, compresses them with fflate and saves the result as an `.elpx`. + +Before this PR the viewer iframe carried `allow-downloads` +(`src/elpx/iframe-renderer.ts` on `main`). The opaque viewer introduced here +dropped it from the secure token set, and its published Content-Security-Policy +has no `worker-src`. + +The opaque editor preview has the same gap. Its CSP +(`lib/Service/Preview/PreviewPolicy.php`) copied `previewCspHeader()` from eXe's +opaque-preview work (exelearning/exelearning#1968), whose `sandbox` lacks `allow-downloads` and which has a `child-src` but no +`worker-src`. The editor's preview iframe already grants `allow-downloads`, but +the effective sandbox is the intersection of the attribute and the CSP. + +## Problem + +Inside the opaque viewer the button breaks in two ways: + +1. fflate's asynchronous `zip()` compresses in Web Workers created from `blob:` + URLs. With no `worker-src`, the browser falls back to `script-src`, which has + no `blob:`, so the workers are blocked and the button hangs at + "Processing... 100%" (exelearning/exelearning#2488). +2. A sandboxed frame without `allow-downloads` cannot start a download; Chrome + drops it and logs that the flag is not set. + +## Decision drivers + +- Keep the document opaque: no `allow-same-origin`, no + `allow-popups-to-escape-sandbox`. +- Do not regress a feature that worked on `main`. +- Stay aligned with the WordPress and Omeka S plugins, which made the same fix. + +## Options considered + +### Option 1: Add `allow-downloads` and `worker-src 'self' blob:` + +The package's own rebuild completes and saves. + +### Option 2: Route the button to the original upload from the parent + +The WordPress and Omeka S plugins also do this. It needs the parent to reach the +package's globals, which only works for a same-origin frame, so it cannot apply +to the opaque viewer. + +### Option 3: Leave the button broken + +Rejected: it worked before this PR. + +## Evidence + +- WordPress: ADR-156-01 in exelearning/wp-exelearning (#156). +- Omeka S: ADR-63-01 in exelearning/omeka-s-exelearning (#63), carried into its + opaque viewer in exelearning/omeka-s-exelearning#21. +- exelearning/exelearning#2488 (the hang under the content CSP). +- mod_exelearning: `classes/local/preview/serving.php` in + exelearning/moodle-mod_exelearning#80 adds the same two allowances to its + editor preview CSP. +- Browser reproduction (Chrome) of the preview CSP inside an iframe carrying the + editor's preview sandbox attribute: without the additions the console reports + "Creating a worker from 'blob:null/…' violates … child-src … 'worker-src' was + not explicitly set" and "Download is disallowed … 'allow-downloads' is not + set"; with them the `blob:` worker runs and the download is not blocked. + +## Decision + +We will add `allow-downloads` to the secure and legacy sandbox token sets (the +iframe attribute in `src/elpx/iframe-renderer.ts` and `IframeSandbox` in PHP, +whose secure set also feeds the response-level CSP `sandbox` directive), and +`worker-src 'self' blob:` to the published Content-Security-Policy. + +The editor preview CSP (`lib/Service/Preview/PreviewPolicy.php`) gets the same two +additions on top of the #1968 `previewCspHeader()`, as mod_exelearning does in +`classes/local/preview/serving.php`. It is otherwise kept verbatim. Core's current +preview (`previewSnapshotCspHeader()`, #2199) sends only a `sandbox` directive +whose tokens already include `allow-downloads`, so it needs neither addition. + +## Consequences + +### Positive + +- The package's download button works in the opaque viewer and in the opaque + editor preview. + +### Negative + +- None for isolation. Package scripts already run with `'unsafe-inline'` and + `'unsafe-eval'`, so `blob:` workers add no capability, and `allow-downloads` + gives no access to the Nextcloud origin. + +### Neutral + +- The download is the package's in-browser rebuild, not the original file. + +## Risks + +- A package can start a download without user activation. It could already + offer the same bytes through a link opened in a popup. + +## Validation + +- `IframeSandboxTest` pins `allow-downloads` and `worker-src 'self' blob:`. +- `iframe-renderer.test.ts` pins the secure iframe token set. +- `PreviewPolicyTest` pins the editor preview CSP with both additions. +- Manual: open a package that contains the download-source-file iDevice in the + viewer and confirm its button saves an `.elpx`. + +## Follow-up work + +- If this plugin adopts core's #2199 preview CSP (`previewSnapshotCspHeader()`), + the local additions are no longer needed. + +## References + +- exelearning/exelearning#2488 +- exelearning/wp-exelearning#156 +- exelearning/omeka-s-exelearning#63, exelearning/omeka-s-exelearning#21 diff --git a/docs/preview-serving-contract.md b/docs/preview-serving-contract.md new file mode 100644 index 0000000..3df068c --- /dev/null +++ b/docs/preview-serving-contract.md @@ -0,0 +1,166 @@ +# Opaque editor preview — Nextcloud adapter + +The embedded editor renders its preview **filtered** by default: sanitised, with +no author JavaScript running. When the author opts in to running their own code, +the editor needs somewhere to put the real project bytes that is **not** the +Nextcloud page — a browser-enforced **opaque origin** the content cannot reach +out of. + +This app is that somewhere. The editor POSTs the whole project as one ZIP and +gets back an unguessable capability id; the app serves that tree from an authless +route under a sandbox CSP. There is no authored-content `srcdoc` transport and no +Service Worker fallback here: missing or invalid configuration **fails closed** +and the filtered preview stays. + +The sandbox CSP string is `previewCspHeader()` from eXe's opaque-preview work +(exelearning/exelearning#1968) plus two additions, `allow-downloads` in the sandbox +and `worker-src 'self' blob:`, so the previewed package's own `.elpx` download +button works (ADR-68-01, the same additions as mod_exelearning). Core's current +preview (`previewSnapshotCspHeader()`, #2199) sends only a `sandbox` directive +whose tokens already include `allow-downloads`. + +## Why not the Service Worker + +For *published* `.elpx` content this app serves package bytes same-origin (the +Service Worker `src/sw/exelearning-sw.js` and the authenticated +`AssetController::fetch`) — fine for content the owner has committed. + +The **editor preview** is different: it renders *untrusted, unsaved* author HTML +and SVG that can contain arbitrary scripts. Rendering that same-origin — or via a +Service Worker in our scope — would let it read the Nextcloud session, call our +APIs and pivot. A Service Worker **cannot** back an opaque origin (its +subresources bypass the SW), so the preview is served from an authless, +cookieless route and never through the Service Worker. + +## The two endpoints + +| | Request | Result | +|---|---|---| +| Management | `POST {basePath}/api/preview-session` | multipart `snapshot=`, optional `previewId` → `{previewId}` | +| Management | `DELETE {basePath}/api/preview-session/{previewId}` | drops the snapshot | +| Serving | `GET {basePath}/preview/{previewId}/{path}` | the snapshot, authless | + +Management is the only authenticated surface: a logged-in `IUserSession` user +plus Nextcloud's ordinary CSRF check (the actions are deliberately **not** +`#[NoCSRFRequired]`, mirroring `editor#save`), and the store scopes every +snapshot to its owner (403/404). + +Serving carries no authentication at all. The unguessable id plus the idle TTL is +the whole credential, which is what makes the origin opaque — an iframe pointed +at this URL carries no Nextcloud session, so author code inside it has nothing to +steal. + +## Why one whole snapshot + +An earlier revision implemented a layered protocol (contract v2): immutable asset +keys uploaded once, incremental document revisions, and a manifest of fixed +installation resources resolved out of the editor distribution — all to avoid +re-uploading unchanged bytes. The editor no longer speaks it; it was handed a +contract nothing read while the one it does read (`previewSnapshot`) was +withheld, which left the opaque preview unreachable here. One ZIP per refresh +replaced the store, the fixed-resource layer and the four-operation management +API. + +## Storage + + {datadirectory}/exelearning/preview-snapshots/{previewId}/ + meta.json ownerUserId, createdAt, bytes + .accessed empty marker; its mtime is the idle-TTL / LRU clock + content/ the extracted snapshot + +Outside the web root, so no direct web-server path can bypass the serving route +and its sandbox CSP. The root comes from `datadirectory` rather than `IAppData` +because the store needs a POSIX-local directory for atomic renames, and +`IAppData` may be object storage. Content sits in its own subdirectory so no +author path can collide with the store's own files — there are no reserved names +to police. A write is staged beside the live tree and swapped in, so a reader +sees the previous snapshot or the new one, never a half-written one. + +## What an archive must survive before extraction + +`SnapshotArchive` vets every entry **before** anything is written, then extracts +under a byte budget: + +- Unsafe entries (absolute paths, backslashes, `.`/`..` segments, NUL bytes) and + symbolic links reject the **whole** archive in the first pass. +- The zip-bomb cap is enforced on the **real decompressed bytes** as they stream + out, not on the sizes declared in the central directory — those are supplied by + whoever built the archive. The declared total is only an early reject. +- An `index.html` must be present, or it is not a preview. + +## Bounds + +Nextcloud is the one adapter where these matter beyond a single author, because +one shared instance hosts many users against one filesystem: + +| Bound | Default | +|---|---| +| Idle TTL | 30 min | +| Snapshots per user | 4 (LRU-evicted) | +| Files per snapshot | 5000 | +| Bytes per snapshot | 200 MiB | +| Global | 2 GiB (LRU-evicted, then 507) | + +Serving a snapshot pushes its idle clock back, so a preview in use never expires +under the author. Every publish sweeps, and `PreviewCleanupJob` sweeps every +10 minutes, so the store never depends on cron alone to bound its size. + +## Response headers + +Every response, 404s included, carries `X-Content-Type-Options: nosniff`, +`Referrer-Policy: no-referrer`, the preview `Permissions-Policy` and +`Access-Control-Allow-Origin: *` — safe here precisely because the route is +authless and cookieless, and never to be paired with credentials. There is +deliberately no `X-Frame-Options`: framing is governed by the CSP +`frame-ancestors` directive. + +Every **scriptable** type — `text/html`, `image/svg+xml`, XML, XHTML — also gets +the sandbox CSP, so a capability URL stays opaque even when opened directly. Not +just HTML: an author-supplied SVG runs its inline `'; + if (preg_match('/]*>/i', $html, $match, PREG_OFFSET_CAPTURE)) { + $position = $match[0][1] + strlen($match[0][0]); + $html = substr($html, 0, $position) . $previewScript . substr($html, $position); + } + $html = $this->editorHtml->prepare($html, $editorBaseHref); $response = new DataDisplayResponse($html, Http::STATUS_OK, [ @@ -170,4 +185,42 @@ public function iframe(): DataDisplayResponse|DataResponse { return $response; } + /** + * Builds the editor's opaque-preview transport configuration. + * + * The management routes remain authenticated and CSRF-protected, while the + * serving URL is an authless capability path consumed from the opaque iframe. + * + * @return array{managementUrl:string,servingBaseUrl:string,deleteUrlTemplate:string,managementHeaders:object} + */ + private function previewSnapshotConfig(): array { + $sampleId = '00000000-0000-4000-8000-000000000000'; + + $sampleUrl = $this->urlGenerator->linkToRoute( + Application::APP_ID . '.preview.serveRoot', + ['previewId' => $sampleId], + ); + $servingBaseUrl = str_ends_with($sampleUrl, '/' . $sampleId) + ? substr($sampleUrl, 0, -(strlen($sampleId) + 1)) + : $sampleUrl; + + $deleteUrlTemplate = str_replace( + $sampleId, + '{previewId}', + $this->urlGenerator->linkToRoute( + Application::APP_ID . '.previewSession.delete', + ['previewId' => $sampleId], + ), + ); + + return [ + 'managementUrl' => $this->urlGenerator->linkToRoute(Application::APP_ID . '.previewSession.create'), + 'servingBaseUrl' => $servingBaseUrl, + 'deleteUrlTemplate' => $deleteUrlTemplate, + 'managementHeaders' => (object)[ + 'requesttoken' => $this->csrfTokenManager->getToken()->getEncryptedValue(), + ], + ]; + } + } diff --git a/lib/Controller/PreviewController.php b/lib/Controller/PreviewController.php new file mode 100644 index 0000000..bb43ff4 --- /dev/null +++ b/lib/Controller/PreviewController.php @@ -0,0 +1,89 @@ +server->serve( + $previewId, + $path, + $this->headerOrNull('If-None-Match'), + $this->headerOrNull('Range'), + ); + return $this->toDataDisplay($response); + } + + /** + * GET /apps/exelearning/preview/{previewId} + * + * The bare capability root 302-redirects to `{previewId}/index.html` rather + * than serving index.html bytes inline — otherwise the document's relative + * subresource references resolve against `preview/` (dropping the id segment) + * and every asset 404s. See {@see PreviewServer::serveRoot()}. + */ + #[PublicPage] + #[NoCSRFRequired] + public function serveRoot(string $previewId): DataDisplayResponse { + return $this->toDataDisplay($this->server->serveRoot($previewId)); + } + + /** Map the framework-agnostic response onto a DataDisplayResponse. */ + private function toDataDisplay(PreviewResponse $response): DataDisplayResponse { + $headers = $response->headers; + $contentType = $headers['Content-Type'] ?? 'application/octet-stream'; + unset($headers['Content-Type']); + $result = new DataDisplayResponse($response->body, $response->status, ['Content-Type' => $contentType]); + foreach ($headers as $name => $value) { + $result->addHeader($name, $value); + } + return $result; + } + + /** A request header value, or null when it is absent/empty. */ + private function headerOrNull(string $name): ?string { + $value = (string)$this->request->getHeader($name); + return $value === '' ? null : $value; + } +} diff --git a/lib/Controller/PreviewSessionController.php b/lib/Controller/PreviewSessionController.php new file mode 100644 index 0000000..c275ff9 --- /dev/null +++ b/lib/Controller/PreviewSessionController.php @@ -0,0 +1,109 @@ +, + * previewId? -> { previewId } + * DELETE {basePath}/api/preview-session/{previewId} + * + * This replaces the four-operation protocol v2 (create / assets / revisions / + * delete) that the current editor build no longer speaks. + */ +class PreviewSessionController extends Controller { + public function __construct( + string $appName, + IRequest $request, + private readonly IUserSession $userSession, + private readonly PreviewSnapshotStore $store, + ) { + parent::__construct($appName, $request); + } + + /** + * POST {basePath}/api/preview-session — publish a whole-project snapshot. + * + * `previewId` is absent on the first refresh (mint a capability) and present + * afterwards (replace in place). The store refuses an id that is unknown or + * owned by somebody else, so it cannot be used to claim another author's + * capability. + */ + #[NoAdminRequired] + public function create(): DataResponse { + $userId = $this->currentUserId(); + if ($userId === null) { + return $this->unauthenticated(); + } + $upload = $this->request->getUploadedFile('snapshot'); + if (!is_array($upload) + || (int)($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK + || !is_string($upload['tmp_name'] ?? null) + || $upload['tmp_name'] === '' + || !is_uploaded_file($upload['tmp_name'])) { + return new DataResponse(['error' => 'Missing snapshot upload'], Http::STATUS_BAD_REQUEST); + } + + $previewId = $this->request->getParam('previewId'); + $previewId = is_string($previewId) && $previewId !== '' ? $previewId : null; + + $result = $this->store->replace($userId, $upload['tmp_name'], $previewId); + if (isset($result['error'])) { + return new DataResponse(['error' => $result['error']], (int)$result['status']); + } + // No previewUrl: the client derives it from servingBaseUrl + + // /{previewId}/index.html, which keeps one source of truth for how a + // capability URL is shaped. + return new DataResponse(['previewId' => $result['previewId']], Http::STATUS_OK); + } + + /** + * DELETE {basePath}/api/preview-session/{previewId} + * + * Owner scoping comes from the same store verdict the publish path uses, so + * the two verbs cannot drift: a malformed id is a 400, an unknown capability + * a 404 and somebody else's a 403. + */ + #[NoAdminRequired] + public function delete(string $previewId): DataResponse { + $userId = $this->currentUserId(); + if ($userId === null) { + return $this->unauthenticated(); + } + $refused = $this->store->deleteOwned($previewId, $userId); + if ($refused !== null) { + return new DataResponse(['error' => $refused['error']], (int)$refused['status']); + } + return new DataResponse([], Http::STATUS_OK); + } + + private function currentUserId(): ?string { + $user = $this->userSession->getUser(); + return $user === null ? null : $user->getUID(); + } + + private function unauthenticated(): DataResponse { + return new DataResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } +} diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 3716738..331b307 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -5,8 +5,10 @@ namespace OCA\ExeLearning\Controller; use OCA\ExeLearning\AppInfo\Application; +use OCA\ExeLearning\Service\ContentTokenService; use OCA\ExeLearning\Service\EditorAssetService; use OCA\ExeLearning\Service\ElpxPackageService; +use OCA\ExeLearning\Service\IframeSandbox; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -38,6 +40,8 @@ public function __construct( private readonly IUserSession $userSession, private readonly ElpxPackageService $packageService, private readonly IInitialState $initialState, + private readonly ContentTokenService $contentTokens, + private readonly IframeSandbox $sandbox, private readonly EditorAssetService $editorAssets, ) { parent::__construct($appName, $request); @@ -66,6 +70,7 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode = } } + $secureIframe = $this->sandbox->resolveMode() === IframeSandbox::MODE_SECURE; if ($file !== null) { $this->initialState->provideInitialState('file', [ 'id' => $file->getId(), @@ -75,7 +80,25 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode = 'etag' => $file->getEtag(), 'writable' => $file->isUpdateable(), ]); + // In secure mode the package is served into an opaque-origin iframe + // over the cookieless /content/{token}/… capability route. The user's + // read permission was just checked above (getForUser*), so mint the + // bearer token here. Legacy (same-origin Service Worker) mode leaves + // this null and the viewer keeps its /runtime SW path. + $this->initialState->provideInitialState( + 'contentToken', + $secureIframe ? $this->contentTokens->mint((int)$file->getId(), $user->getUID()) : null, + ); } + $this->initialState->provideInitialState('secureIframe', $secureIframe); + // External-media relay config for the trusted parent (see relay-host.ts): + // 'strict' mode overlays only the maintained provider hosts. + $this->initialState->provideInitialState( + 'embedRelay', + $secureIframe + ? ['mode' => $this->sandbox->embedMode(), 'whitelist' => $this->sandbox->providerWhitelist()] + : null, + ); $this->initialState->provideInitialState( 'editorAvailable', $this->editorAssets->isAvailable(), @@ -97,6 +120,12 @@ public function index(?int $fileId = null, ?string $path = null, ?string $mode = $csp->addAllowedScriptDomain("'self'"); $csp->addAllowedConnectDomain("'self'"); $csp->addAllowedFrameDomain("'self'"); + // Promoted players are cross-origin frames mounted on THIS page by the relay, so + // 'self' alone blocks every one of them. Allowed hosts come from the same list the + // relay is given, never a copy. + foreach ($this->sandbox->playerFrameDomains() as $host) { + $csp->addAllowedFrameDomain('https://' . $host); + } $response->setContentSecurityPolicy($csp); return $response; } diff --git a/lib/Service/ContentTokenService.php b/lib/Service/ContentTokenService.php new file mode 100644 index 0000000..47ec09a --- /dev/null +++ b/lib/Service/ContentTokenService.php @@ -0,0 +1,102 @@ +clock = $clock ?? static fn (): int => time(); + } + + /** + * Mint a capability token binding $fileId + $userId valid for $ttlSeconds. + * The user id is carried so the cookieless serving route can mount that + * user's storage (getUserFolder) — IRootFolder::getById alone resolves + * nothing without a filesystem context on a #[PublicPage]. + */ + public function mint(int $fileId, string $userId, int $ttlSeconds = 3600): string { + $expiry = ($this->clock)() + $ttlSeconds; + $payload = $fileId . '.' . $expiry . '.' . $this->b64($userId); + return $this->b64($payload) . '.' . $this->b64($this->sign($payload)); + } + + /** + * Verify a token: returns `[fileId, userId]` when the signature is valid and + * the token has not expired, otherwise null. + * + * @return array{0: int, 1: string}|null + */ + public function verify(string $token): ?array { + $parts = explode('.', $token); + if (count($parts) !== 2) { + return null; + } + $payload = $this->unb64($parts[0]); + $signature = $this->unb64($parts[1]); + if ($payload === null || $signature === null) { + return null; + } + if (!hash_equals($this->sign($payload), $signature)) { + return null; + } + $segments = explode('.', $payload); + if (count($segments) !== 3) { + return null; + } + [$fileId, $expiry, $encodedUser] = $segments; + if (!ctype_digit($fileId) || !ctype_digit($expiry)) { + return null; + } + if ((int)$expiry <= ($this->clock)()) { + return null; + } + $userId = $this->unb64($encodedUser); + if ($userId === null || $userId === '') { + return null; + } + return [(int)$fileId, $userId]; + } + + private function sign(string $payload): string { + return hash_hmac('sha256', $payload, $this->secret(), true); + } + + private function secret(): string { + return $this->secret . '|exelearning-content-v1'; + } + + private function b64(string $raw): string { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + private function unb64(string $encoded): ?string { + $decoded = base64_decode(strtr($encoded, '-_', '+/'), true); + return $decoded === false ? null : $decoded; + } +} diff --git a/lib/Service/EmbedShimInjector.php b/lib/Service/EmbedShimInjector.php new file mode 100644 index 0000000..847c6e0 --- /dev/null +++ b/lib/Service/EmbedShimInjector.php @@ -0,0 +1,40 @@ + as early as possible: before + * when present (it must run before the package's own media bridge), + * otherwise right after , otherwise prepended. + */ + public function injectIntoHead(string $html, string $scriptSource): string { + $tag = ''; + + $headClose = stripos($html, ''); + if ($headClose !== false) { + return substr($html, 0, $headClose) . $tag . substr($html, $headClose); + } + + if (preg_match('~]*>~i', $html, $matches, PREG_OFFSET_CAPTURE) === 1) { + $at = $matches[0][1] + strlen($matches[0][0]); + return substr($html, 0, $at) . $tag . substr($html, $at); + } + + return $tag . $html; + } +} diff --git a/lib/Service/EmbedShimSource.php b/lib/Service/EmbedShimSource.php new file mode 100644 index 0000000..ff3b25d --- /dev/null +++ b/lib/Service/EmbedShimSource.php @@ -0,0 +1,37 @@ +vendoredDir . '/' . self::CHILD_BUNDLE; + if (!is_file($path)) { + return null; + } + $source = file_get_contents($path); + return $source === false ? null : $source; + } +} diff --git a/lib/Service/IframeSandbox.php b/lib/Service/IframeSandbox.php new file mode 100644 index 0000000..5b3f30d --- /dev/null +++ b/lib/Service/IframeSandbox.php @@ -0,0 +1,173 @@ +envReader = $envReader ?? static function (string $name): ?string { + $value = getenv($name); + return $value === false ? null : $value; + }; + } + + /** + * Always 'secure' unless the dev escape hatch is explicitly on. + */ + public function resolveMode(): string { + $raw = ($this->envReader)(self::LEGACY_ENV); + $on = $raw !== null && in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'on'], true); + return $on ? self::MODE_LEGACY : self::MODE_SECURE; + } + + /** + * External-media relay mode. 'strict' (default, fail-safe) overlays only the + * maintained provider hosts; 'open' (opt-in via EXELEARNING_EMBED_OPEN) + * overlays any cross-origin https player the shim reports. + */ + public function embedMode(): string { + $raw = ($this->envReader)(self::EMBED_OPEN_ENV); + $on = $raw !== null && in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'on'], true); + return $on ? self::EMBED_OPEN : self::EMBED_STRICT; + } + + /** + * The iframe `sandbox` attribute token list for the given (or resolved) mode. + */ + public function sandboxTokens(?string $mode = null): string { + $mode ??= $this->resolveMode(); + return $mode === self::MODE_LEGACY ? self::LEGACY_TOKENS : self::SECURE_TOKENS; + } + + /** + * Response-level CSP for served HTML package documents. + * + * 'strict' (default) keeps script/img/media/frame-src pinned to 'self' + the + * maintained providers so a URL-borne capability token can never be + * exfiltrated to an arbitrary host; the trailing `sandbox` directive keeps + * the document opaque even if opened top-level. 'compatible' re-opens https: + * for deployments that need arbitrary external assets. + */ + public function contentSecurityPolicy(string $profile = 'strict'): string { + $scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval'"; + $imgSrc = "img-src 'self' data: blob:"; + $mediaSrc = "media-src 'self' data: blob:"; + $frameSrc = "frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com " + . 'https://www.dailymotion.com https://mediateca.educa.madrid.org'; + if ($profile === 'compatible') { + $scriptSrc = "script-src 'self' 'unsafe-inline' 'unsafe-eval' https:"; + $imgSrc = "img-src 'self' data: blob: https:"; + $mediaSrc = "media-src 'self' data: blob: https:"; + $frameSrc = "frame-src 'self' https:"; + } + return implode('; ', [ + "default-src 'self'", + $scriptSrc, + // The package's download-source-file button rebuilds the .elpx with + // fflate, which compresses in blob: workers. Without worker-src the + // browser falls back to script-src, which has no blob:, and the + // rebuild hangs. Scripts already run with 'unsafe-inline'/'unsafe-eval', + // so this adds no capability (ADR-68-01). + "worker-src 'self' blob:", + "style-src 'self' 'unsafe-inline'", + $imgSrc, + $mediaSrc, + "font-src 'self' data:", + "connect-src 'self'", + $frameSrc, + "frame-ancestors 'self'", + "form-action 'self'", + "base-uri 'self'", + "object-src 'none'", + 'sandbox ' . self::SECURE_TOKENS, + ]); + } + + /** + * Locked, script-free CSP for SVG/XML documents opened top-level. + */ + public function svgCsp(): string { + return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none'; sandbox"; + } + + /** + * Deny hardware access; deliberately does NOT deny fullscreen (video needs it). + */ + public function permissionsPolicy(): string { + return 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), ' + . 'bluetooth=(), hid=(), magnetometer=(), accelerometer=(), gyroscope=(), ' + . 'midi=(), display-capture=()'; + } + + /** + * @return string[] + */ + public function providerWhitelist(): array { + return self::PROVIDER_WHITELIST; + } + + /** + * Hosts the page CSP must allow as frame sources. + * + * The relay overlays real players for exactly these hosts, on the trusted page. If the + * CSP is narrower the browser blocks the frame and the learner gets a black rectangle + * with nothing in the console to explain it, so this deliberately returns the same + * list rather than a second one that could drift from it. + * + * @return string[] + */ + public function playerFrameDomains(): array { + return $this->providerWhitelist(); + } +} diff --git a/lib/Service/PackageMimeService.php b/lib/Service/PackageMimeService.php new file mode 100644 index 0000000..0245dad --- /dev/null +++ b/lib/Service/PackageMimeService.php @@ -0,0 +1,78 @@ + 'text/html; charset=utf-8', + 'htm' => 'text/html; charset=utf-8', + 'xhtml' => 'application/xhtml+xml; charset=utf-8', + 'css' => 'text/css; charset=utf-8', + 'js' => 'text/javascript; charset=utf-8', + 'mjs' => 'text/javascript; charset=utf-8', + 'json' => 'application/json; charset=utf-8', + 'xml' => 'application/xml; charset=utf-8', + 'txt' => 'text/plain; charset=utf-8', + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'ico' => 'image/x-icon', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'm4a' => 'audio/mp4', + 'm4v' => 'video/mp4', + 'ogg' => 'audio/ogg', + 'oga' => 'audio/ogg', + 'ogv' => 'video/ogg', + 'wav' => 'audio/wav', + 'webm' => 'video/webm', + 'vtt' => 'text/vtt', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'eot' => 'application/vnd.ms-fontobject', + 'pdf' => 'application/pdf', + ]; + + public function detect(string $entry): string { + $extension = strtolower(pathinfo($entry, PATHINFO_EXTENSION)); + return self::MIME_MAP[$extension] ?? 'application/octet-stream'; + } + + /** + * A "page" document that runs the eXe engine — gets the full published CSP + * and the injected media shim. + */ + public function isHtmlDocument(string $mime): bool { + $type = $this->baseType($mime); + return $type === 'text/html' || $type === 'application/xhtml+xml'; + } + + /** + * A data document (SVG/XML) that must be locked script-free when opened + * top-level (an author SVG runs inline ', $out); + } + + public function testPreservesRegexSpecialCharsInSource(): void { + $source = 'var a = "$1"; var b = "\\\\n"; // $0 \\1'; + $out = $this->injector->injectIntoHead('', $source); + self::assertStringContainsString($source, $out); + } + + public function testFallsBackToAfterBodyWhenNoHead(): void { + $out = $this->injector->injectIntoHead('hi', 'Y'); + self::assertStringContainsString('', $out); + } + + public function testPrependsWhenNoHeadNoBody(): void { + $out = $this->injector->injectIntoHead('

bare

', 'Z'); + self::assertStringStartsWith('', $out); + } +} diff --git a/tests/Unit/Service/ExternalMediaArtifactTest.php b/tests/Unit/Service/ExternalMediaArtifactTest.php new file mode 100644 index 0000000..dec2aa6 --- /dev/null +++ b/tests/Unit/Service/ExternalMediaArtifactTest.php @@ -0,0 +1,62 @@ +read(); + + self::assertNotNull($source, 'no shim source was produced'); + self::assertSame( + file_get_contents(self::VENDORED . '/exe-external-media-child.min.js'), + $source, + ); + } + + public function testServedShimPublishesTheSharedBridgeContract(): void { + $source = (new EmbedShimSource(self::VENDORED))->read() ?? ''; + + // The three symbols the host and the interactive-video iDevice rely on. + self::assertStringContainsString('exeExternalMediaChild', $source); + self::assertStringContainsString('exeEmbedShim', $source); + self::assertStringContainsString('exeMediaBridge', $source); + // And the licence notice exelearning/exelearning ADR-2199-09 requires to survive minification. + self::assertStringContainsString('Dual-licensed', $source); + } + + public function testMissingArtifactYieldsNullRatherThanAFatal(): void { + self::assertNull((new EmbedShimSource(__DIR__ . '/does-not-exist'))->read()); + } + + /** + * Byte identity against the manifest core published. Integrity, not provenance — + * the CI step pins the buildHash out of band — but it catches a local edit, which is + * the failure this repo can actually cause on its own. + */ + public function testVendoredBytesMatchTheManifest(): void { + $manifest = json_decode((string)file_get_contents(self::VENDORED . '/exe-external-media.manifest.json'), true); + self::assertIsArray($manifest['files'] ?? null); + + foreach ($manifest['files'] as $key => $record) { + $path = self::VENDORED . '/' . $record['path']; + self::assertFileExists($path, "vendored $key is missing"); + self::assertSame($record['sha256'], hash_file('sha256', $path), "vendored $key was edited locally"); + } + } +} diff --git a/tests/Unit/Service/IframeSandboxTest.php b/tests/Unit/Service/IframeSandboxTest.php new file mode 100644 index 0000000..60062d0 --- /dev/null +++ b/tests/Unit/Service/IframeSandboxTest.php @@ -0,0 +1,142 @@ + $env Environment values keyed by variable name. + */ + private function service(array $env = []): IframeSandbox { + return new IframeSandbox(static fn (string $name): ?string => $env[$name] ?? null); + } + + public function testDefaultEnvironmentReaderHonoursProcessEnvironment(): void { + $name = 'EXELEARNING_UNSAFE_LEGACY_IFRAME'; + $old = getenv($name); + putenv($name . '=1'); + try { + self::assertSame(IframeSandbox::MODE_LEGACY, (new IframeSandbox())->resolveMode()); + } finally { + $old === false ? putenv($name) : putenv($name . '=' . $old); + } + } + + public function testDefaultsToSecureMode(): void { + self::assertSame(IframeSandbox::MODE_SECURE, $this->service()->resolveMode()); + } + + public function testLegacyModeOnlyViaEscapeHatch(): void { + $hatch = 'EXELEARNING_UNSAFE_LEGACY_IFRAME'; + self::assertSame(IframeSandbox::MODE_LEGACY, $this->service([$hatch => '1'])->resolveMode()); + self::assertSame(IframeSandbox::MODE_LEGACY, $this->service([$hatch => 'true'])->resolveMode()); + self::assertSame(IframeSandbox::MODE_SECURE, $this->service([$hatch => '0'])->resolveMode()); + self::assertSame(IframeSandbox::MODE_SECURE, $this->service([$hatch => ''])->resolveMode()); + } + + public function testEmbedModeDefaultsToStrict(): void { + self::assertSame(IframeSandbox::EMBED_STRICT, $this->service()->embedMode()); + } + + public function testEmbedModeOpensOnlyViaEnv(): void { + $env = 'EXELEARNING_EMBED_OPEN'; + self::assertSame(IframeSandbox::EMBED_OPEN, $this->service([$env => '1'])->embedMode()); + self::assertSame(IframeSandbox::EMBED_STRICT, $this->service([$env => 'off'])->embedMode()); + } + + public function testSecureSandboxTokensNeverAllowSameOrigin(): void { + $tokens = $this->service()->sandboxTokens(IframeSandbox::MODE_SECURE); + self::assertSame('allow-scripts allow-popups allow-forms allow-downloads', $tokens); + self::assertStringNotContainsString('allow-same-origin', $tokens); + self::assertStringNotContainsString('allow-popups-to-escape-sandbox', $tokens); + self::assertStringNotContainsString('allow-top-navigation', $tokens); + } + + public function testLegacySandboxTokensReintroduceSameOrigin(): void { + $tokens = $this->service()->sandboxTokens(IframeSandbox::MODE_LEGACY); + self::assertStringContainsString('allow-same-origin', $tokens); + self::assertStringContainsString('allow-downloads', $tokens); + } + + public function testStrictPublishedCspShape(): void { + $csp = $this->service()->contentSecurityPolicy(); + // The opaque sandbox rides in the CSP too (keeps the doc opaque if opened top-level). + self::assertStringContainsString('sandbox allow-scripts allow-popups allow-forms allow-downloads', $csp); + // fflate's blob: workers rebuild the package's own .elpx download (ADR-68-01). + self::assertStringContainsString("worker-src 'self' blob:", $csp); + self::assertStringContainsString("object-src 'none'", $csp); + self::assertStringContainsString("frame-ancestors 'self'", $csp); + // Only the maintained providers, never bare https: in frame-src. + self::assertStringContainsString('https://www.youtube-nocookie.com', $csp); + self::assertStringContainsString('https://mediateca.educa.madrid.org', $csp); + self::assertStringNotContainsString('frame-src \'self\' https:;', $csp); + // No token-exfiltrating bare https: in script/img/media in the strict profile. + self::assertDoesNotMatchRegularExpression('~script-src[^;]*\bhttps:(?!//)~', $csp); + } + + public function testCompatibleProfileReopensHttps(): void { + $csp = $this->service()->contentSecurityPolicy('compatible'); + self::assertStringContainsString("script-src 'self' 'unsafe-inline' 'unsafe-eval' https:", $csp); + self::assertStringContainsString('frame-src \'self\' https:', $csp); + // Sandbox stays even in the weaker profile. + self::assertStringContainsString('sandbox allow-scripts allow-popups allow-forms allow-downloads', $csp); + } + + public function testSvgCspIsScriptFree(): void { + $csp = $this->service()->svgCsp(); + self::assertStringContainsString("script-src 'none'", $csp); + self::assertStringContainsString("default-src 'none'", $csp); + self::assertStringContainsString('sandbox', $csp); + } + + public function testPermissionsPolicyDeniesHardwareButNotFullscreen(): void { + $pp = $this->service()->permissionsPolicy(); + self::assertStringContainsString('camera=()', $pp); + self::assertStringContainsString('microphone=()', $pp); + self::assertStringContainsString('geolocation=()', $pp); + self::assertStringNotContainsString('fullscreen', $pp); + } + + public function testProviderWhitelistIsLowercaseAndDeduped(): void { + $hosts = $this->service()->providerWhitelist(); + self::assertContains('www.youtube-nocookie.com', $hosts); + self::assertContains('player.vimeo.com', $hosts); + self::assertContains('mediateca.educa.madrid.org', $hosts); + self::assertSame(array_values(array_unique($hosts)), $hosts); + self::assertSame(array_map('strtolower', $hosts), $hosts); + } + + /** + * The page CSP must allow exactly the hosts the relay is allowed to promote. + * + * These are two halves of one decision kept in two places, and the failure when they + * drift is silent and ugly: the relay overlays a real player, the browser refuses to + * load a frame the CSP never allowed, and the learner gets a permanent black + * rectangle where the video should be. That is exactly what the viewer shipped — + * `frame-src 'self'` with a ten-host provider whitelist next to it. + */ + public function testPlayerFrameDomainsCoverEveryWhitelistedProvider(): void { + $sandbox = new IframeSandbox(); + + $domains = $sandbox->playerFrameDomains(); + + foreach ($sandbox->providerWhitelist() as $host) { + self::assertContains($host, $domains, "CSP would block a promoted player on $host"); + } + } + + /** And nothing beyond them: the CSP is not a place to widen the gate quietly. */ + public function testPlayerFrameDomainsAddNothingBeyondTheWhitelist(): void { + $sandbox = new IframeSandbox(); + + self::assertSame([], array_diff($sandbox->playerFrameDomains(), $sandbox->providerWhitelist())); + } +} diff --git a/tests/Unit/Service/PackageMimeServiceTest.php b/tests/Unit/Service/PackageMimeServiceTest.php new file mode 100644 index 0000000..850fcfc --- /dev/null +++ b/tests/Unit/Service/PackageMimeServiceTest.php @@ -0,0 +1,42 @@ +service = new PackageMimeService(); + } + + public function testDetectsKnownExtensions(): void { + self::assertSame('text/html; charset=utf-8', $this->service->detect('index.html')); + self::assertSame('image/svg+xml', $this->service->detect('img/logo.svg')); + self::assertSame('text/javascript; charset=utf-8', $this->service->detect('a.js')); + self::assertSame('application/xml; charset=utf-8', $this->service->detect('content.xml')); + } + + public function testUnknownExtensionFallsBack(): void { + self::assertSame('application/octet-stream', $this->service->detect('weird.qux')); + self::assertSame('application/octet-stream', $this->service->detect('README')); + } + + public function testHtmlDocumentsGetTheEnginePolicy(): void { + self::assertTrue($this->service->isHtmlDocument('text/html; charset=utf-8')); + self::assertTrue($this->service->isHtmlDocument('application/xhtml+xml')); + self::assertFalse($this->service->isHtmlDocument('image/svg+xml')); + self::assertFalse($this->service->isHtmlDocument('text/css')); + } + + public function testXmlDocumentsAreLocked(): void { + self::assertTrue($this->service->isLockedXml('image/svg+xml')); + self::assertTrue($this->service->isLockedXml('application/xml; charset=utf-8')); + self::assertFalse($this->service->isLockedXml('text/html')); + self::assertFalse($this->service->isLockedXml('image/png')); + } +} diff --git a/tests/Unit/Service/Preview/PreviewPolicyTest.php b/tests/Unit/Service/Preview/PreviewPolicyTest.php new file mode 100644 index 0000000..d47d349 --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewPolicyTest.php @@ -0,0 +1,153 @@ + + */ + public static function scriptableTypes(): iterable { + yield ['text/html']; + yield ['text/html; charset=utf-8']; + yield ['image/svg+xml']; + yield ['image/svg+xml; charset=utf-8']; + yield ['application/xml']; + yield ['application/xhtml+xml; charset=utf-8']; + } + + /** + * @dataProvider nonScriptableTypes + */ + public function testNonScriptableTypesAreNotScriptable(string $mime): void { + self::assertFalse(PreviewPolicy::isScriptable($mime)); + } + + /** + * @return iterable + */ + public static function nonScriptableTypes(): iterable { + yield ['image/png']; + yield ['text/css; charset=utf-8']; + yield ['application/json; charset=utf-8']; + yield ['video/mp4']; + yield ['application/octet-stream']; + } + + /** + * @dataProvider mimeCases + */ + public function testMimeForPath(string $path, string $expected): void { + self::assertSame($expected, PreviewPolicy::mimeForPath($path)); + } + + /** + * @return iterable + */ + public static function mimeCases(): iterable { + yield 'html carries charset' => ['index.html', 'text/html; charset=utf-8']; + yield 'png is bare' => ['content/resources/photo.png', 'image/png']; + yield 'svg is charset-tagged (scriptable-tab hole)' => ['theme/icon.svg', 'image/svg+xml; charset=utf-8']; + yield 'js carries charset' => ['libs/jquery/jquery.min.js', 'text/javascript; charset=utf-8']; + yield 'json carries charset' => ['data/index.json', 'application/json; charset=utf-8']; + yield 'mp4 is bare' => ['media/clip.mp4', 'video/mp4']; + yield 'unknown falls back to octet-stream' => ['blob.bin', 'application/octet-stream']; + } + + /** + * @dataProvider safePaths + */ + public function testNormalizePathAcceptsSafePaths(string $raw, string $expected): void { + self::assertSame($expected, PreviewPolicy::normalizePath($raw)); + } + + /** + * @return iterable + */ + public static function safePaths(): iterable { + yield 'plain file' => ['index.html', 'index.html']; + yield 'empty defaults to index' => ['', 'index.html']; + yield 'leading slash stripped' => ['/html/page-2.html', 'html/page-2.html']; + yield 'dot segments collapsed' => ['a/./b.css', 'a/b.css']; + yield 'double slash collapsed' => ['a//b.css', 'a/b.css']; + yield 'backslash folded to slash' => ['a\\b.css', 'a/b.css']; + yield 'query stripped' => ['page.html?v=3', 'page.html']; + } + + /** + * @dataProvider unsafePaths + */ + public function testNormalizePathRejectsUnsafePaths(string $raw): void { + self::assertNull(PreviewPolicy::normalizePath($raw)); + } + + /** + * @return iterable + */ + public static function unsafePaths(): iterable { + yield 'literal parent' => ['../secret']; + yield 'percent-encoded parent' => ['%2e%2e%2fsecret']; + yield 'internal parent escape' => ['a/../../secret']; + yield 'bare dotdot' => ['..']; + yield 'trailing parent collapses to empty root' => ['foo/..']; + yield 'nul byte' => ["a\0b"]; + yield 'only dot and empty segments' => ['./']; + } + + public function testPreviewIdValidation(): void { + self::assertTrue(PreviewPolicy::isValidPreviewId('3f2a1b4c-5d6e-4f70-8a90-b1c2d3e4f506')); + self::assertFalse(PreviewPolicy::isValidPreviewId('not-a-uuid')); + self::assertFalse(PreviewPolicy::isValidPreviewId('3F2A1B4C-5D6E-4F70-8A90-B1C2D3E4F506')); + self::assertFalse(PreviewPolicy::isValidPreviewId('../../etc/passwd')); + } + +} diff --git a/tests/Unit/Service/Preview/PreviewServerTest.php b/tests/Unit/Service/Preview/PreviewServerTest.php new file mode 100644 index 0000000..48ea9bd --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -0,0 +1,274 @@ +root = sys_get_temp_dir() . '/exe-srv-' . bin2hex(random_bytes(6)); + $this->store = new PreviewSnapshotStore($this->root, new PreviewSnapshotLimits()); + $this->previewId = $this->store->replace('alice', $this->zip([ + 'index.html' => 'hi', + 'img/inline.svg' => '', + 'content/resources/photo.png' => 'PHOTO-BYTES-v1', + 'media/clip.mp4' => '0123456789', + ]))['previewId']; + } + + protected function tearDown(): void { + $this->removeTree($this->root); + } + + /** + * Build a snapshot ZIP on disk from a path => contents map. + * + * @param array $entries + */ + private function zip(array $entries): string { + $path = sys_get_temp_dir() . '/exe-srv-zip-' . bin2hex(random_bytes(6)) . '.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE); + foreach ($entries as $name => $contents) { + $zip->addFromString($name, $contents); + } + $zip->close(); + return $path; + } + + private function server(): PreviewServer { + return new PreviewServer($this->store); + } + + /** The ETag the store minted for a path (content-independent, so read it back). */ + private function etagFor(string $path): string { + return $this->server()->serve($this->previewId, $path)->headers['ETag']; + } + + private function assertBaseHardening(array $headers): void { + self::assertSame('nosniff', $headers['X-Content-Type-Options']); + self::assertSame('no-referrer', $headers['Referrer-Policy']); + self::assertSame('camera=(), microphone=(), geolocation=(), payment=()', $headers['Permissions-Policy']); + self::assertSame('*', $headers['Access-Control-Allow-Origin']); + } + + public function testServeDocument(): void { + $response = $this->server()->serve($this->previewId, 'index.html'); + self::assertSame(200, $response->status); + self::assertSame('hi', $response->body); + self::assertSame('text/html; charset=utf-8', $response->headers['Content-Type']); + self::assertSame('no-store', $response->headers['Cache-Control']); + self::assertSame(PreviewPolicy::CSP, $response->headers['Content-Security-Policy']); + $this->assertBaseHardening($response->headers); + } + + public function testServeAssetHasEtagNoCacheAndNoCsp(): void { + $response = $this->server()->serve($this->previewId, 'content/resources/photo.png'); + self::assertSame(200, $response->status); + self::assertSame('PHOTO-BYTES-v1', $response->body); + self::assertSame('image/png', $response->headers['Content-Type']); + self::assertSame('no-cache', $response->headers['Cache-Control']); + self::assertSame('bytes', $response->headers['Accept-Ranges']); + self::assertArrayHasKey('ETag', $response->headers); + self::assertArrayNotHasKey('Content-Security-Policy', $response->headers); + } + + public function testConditionalRequestReturns304(): void { + $etag = $this->etagFor('content/resources/photo.png'); + $response = $this->server()->serve($this->previewId, 'content/resources/photo.png', $etag); + self::assertSame(304, $response->status); + self::assertSame('', $response->body); + self::assertSame('nosniff', $response->headers['X-Content-Type-Options']); + self::assertSame($etag, $response->headers['ETag']); + } + + /** + * The ETag is built from identity rather than from hashing the bytes, so it + * has to turn over on a refresh that mtime and size alone cannot see: two + * publishes inside the same second where the file keeps its length. + */ + public function testEtagTurnsOverOnASameSizeRefresh(): void { + $before = $this->etagFor('media/clip.mp4'); + + $this->store->replace('alice', $this->zip([ + 'index.html' => '', + 'media/clip.mp4' => '9876543210', + ]), $this->previewId); + + $response = $this->server()->serve( + $this->previewId, + 'media/clip.mp4', + $before, + ); + self::assertSame(200, $response->status, 'a stale ETag must not win a conditional request'); + self::assertSame('9876543210', $response->body); + self::assertNotSame($before, $response->headers['ETag']); + } + + public function testSuffixRangeReturnsLastBytes(): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=-3'); + + self::assertSame(206, $response->status); + self::assertSame('789', $response->body); + self::assertSame('bytes 7-9/10', $response->headers['Content-Range']); + } + + public function testOpenEndedRangeRunsToEnd(): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=7-'); + + self::assertSame(206, $response->status); + self::assertSame('789', $response->body); + self::assertSame('bytes 7-9/10', $response->headers['Content-Range']); + } + + public function testRangeEndIsClampedToEntitySize(): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=8-999'); + + self::assertSame(206, $response->status); + self::assertSame('89', $response->body); + self::assertSame('bytes 8-9/10', $response->headers['Content-Range']); + } + + public function testSingleRangeReturns206(): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, 'bytes=2-4'); + self::assertSame(206, $response->status); + self::assertSame('234', $response->body); + self::assertSame('bytes 2-4/10', $response->headers['Content-Range']); + self::assertSame('3', $response->headers['Content-Length']); + } + + /** + * 416 is reserved for a valid single range that is unsatisfiable: a + * first-byte-pos at/after EOF (`bytes=99-`) or a zero-length suffix + * (`bytes=-0`). + * + * @dataProvider unsatisfiableRangeProvider + */ + public function testUnsatisfiableRangeReturns416(string $range): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, $range); + self::assertSame(416, $response->status, $range . ' must be 416'); + self::assertSame('bytes */10', $response->headers['Content-Range']); + } + + /** @return array */ + public static function unsatisfiableRangeProvider(): array { + return [ + 'first-byte-pos past EOF' => ['bytes=99-'], + 'zero-length suffix' => ['bytes=-0'], + ]; + } + + /** + * A malformed, multi-range or non-bytes Range header is IGNORED — the server + * serves a normal 200 full body, never 416. + * + * @dataProvider ignoredRangeProvider + */ + public function testIgnoredRangeServesFull200(string $range): void { + $response = $this->server()->serve($this->previewId, 'media/clip.mp4', null, $range); + self::assertSame(200, $response->status, $range . ' must be ignored, not 416'); + self::assertSame('0123456789', $response->body); + self::assertSame('bytes', $response->headers['Accept-Ranges']); + self::assertArrayNotHasKey('Content-Range', $response->headers); + } + + /** @return array */ + public static function ignoredRangeProvider(): array { + return [ + 'non-numeric' => ['bytes=abc'], + 'multi-range' => ['bytes=0-1,2-3'], + 'non-bytes unit' => ['items=0-1'], + 'empty range' => ['bytes=-'], + 'garbage' => ['not-a-range'], + 'double dash' => ['bytes=1-2-3'], + // last-byte-pos < first-byte-pos is an invalid spec (RFC 9110 + // §14.1.2) → ignore the header, not 416. + 'last before first' => ['bytes=5-2'], + ]; + } + + public function testBareRootRedirectsToIndexHtml(): void { + $response = $this->server()->serveRoot($this->previewId); + self::assertSame(302, $response->status); + // Relative Location so it stays correct under any webroot: it resolves + // against the request URI `.../preview/{previewId}`. + self::assertSame($this->previewId . '/index.html', $response->headers['Location']); + self::assertSame('no-store', $response->headers['Cache-Control']); + $this->assertBaseHardening($response->headers); + } + + public function testBareRootInvalidPreviewIdIs404(): void { + $response = $this->server()->serveRoot('not-a-uuid'); + self::assertSame(404, $response->status); + self::assertArrayNotHasKey('Location', $response->headers); + $this->assertBaseHardening($response->headers); + } + + /** + * An author-supplied SVG runs its inline