diff --git a/.distignore b/.distignore index fdc801e..b5e982c 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/.eslintrc.cjs b/.eslintrc.cjs index c41d06b..5305279 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -18,6 +18,11 @@ module.exports = { 'js/', 'node_modules/', 'vendor/', + // The vendored eXe-core external-media bundle: built artifacts owned by + // upstream (eXe ADR-0021), verified byte for byte against the manifest core + // published. They are minified output, not source, so our lint rules do not + // apply — and a local "fix" here would be overwritten on the next re-vendor. + 'src/embed/exe_external_media/', ], rules: { // `void promise` is the canonical TypeScript way of marking a diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5b69e0..c90de45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,19 @@ jobs: - name: Install dependencies run: npm ci + - name: Verify the vendored external-media artifact + # eXeLearning core is canonical (eXe ADR-0021): 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 @@ -177,3 +190,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@v6 + with: + node-version: 22 + 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 41a9aaa..08e9f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,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 907f9c5..1f8a1d5 100644 --- a/Makefile +++ b/Makefile @@ -346,7 +346,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 9e5c180..13d3f5d 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,24 @@ playground link (posted as a PR comment) built from that branch — see When a user clicks a `.elpx` file in Nextcloud Files: 1. A Nextcloud Viewer modal opens. -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. Optional features: @@ -84,8 +96,9 @@ maintenance ends mid-2026; NC 32 and 33 are the reference targets. | 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 791c955..d5c65f8 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -6,11 +6,18 @@ Preview and edit eXeLearning .elpx packages in Nextcloud + + + OCA\ExeLearning\BackgroundJob\PreviewCleanupJob + diff --git a/appinfo/routes.php b/appinfo/routes.php index ded2661..00032f7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -21,6 +21,42 @@ 'verb' => 'GET', 'requirements' => ['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 48e4ede..09f3bef 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 c7c8346..7798b25 100644 --- a/blueprint.json +++ b/blueprint.json @@ -30,6 +30,13 @@ "url": "https://github-proxy.exelearning.dev/?repo=exelearning/exelearning&release=v4.0.2&asset=exelearning-static-v4.0.2.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/preview-serving-contract.md b/docs/preview-serving-contract.md new file mode 100644 index 0000000..3502d8b --- /dev/null +++ b/docs/preview-serving-contract.md @@ -0,0 +1,162 @@ +# 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 must stay **byte-identical** to eXe core's +`previewCspHeader()`; core is authoritative. + +## 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 `'; // The resilience shim must be installed before any editor script @@ -292,6 +298,74 @@ public function iframe(): DataDisplayResponse|DataResponse { return $response; } + /** + * The `previewSnapshot` block handed to the embedded editor so it can publish + * opaque previews: `{ managementUrl, servingBaseUrl, deleteUrlTemplate, + * managementHeaders }`. + * + * - Both URLs are generated server-side through the router so they carry the + * correct webroot and front-controller prefix under a sub-path install, + * mirroring the client's `@nextcloud/router` `generateUrl`. The serving + * routes are parameterised, so the base is derived by generating the bare + * capability-root URL for a placeholder id and stripping that id segment. + * - `managementHeaders.requesttoken` is the current Nextcloud CSRF token — + * the same encrypted value the standard template layer exposes as + * `data-requesttoken`. It is required because the management routes keep + * CSRF ON (they are NOT `#[NoCSRFRequired]`); the editor replays it on every + * management request. + * + * Token lifetime (audited — the injected-token approach is durable, no + * refresh path needed now): a Nextcloud CSRF token is bound to a per-session + * secret. `getEncryptedValue()` returns a per-call randomized encoding, but + * every value minted from the same session validates against that one secret + * (`isTokenValid()` decrypts and compares), so the injected value stays valid + * for the whole editing session — hours — not just one request. The secret is + * regenerated only when the session id itself is regenerated (login / logout / + * re-auth), never on ordinary navigation; and whenever that happens the parent + * Nextcloud page hosting this iframe is itself invalidated and reloads, which + * re-runs iframe() and injects a fresh token. The parent page also keeps the + * session (and `OC.requestToken`) alive with its keepalive heartbeat. The only + * residual staleness — the parent's token rotating without a reload — cannot + * happen in practice, so a postMessage CONFIGURE refresh into the iframe is a + * future nicety, not a correctness requirement. + * + * @return array{managementUrl:string,servingBaseUrl:string,deleteUrlTemplate:string,managementHeaders:object} + */ + private function previewSnapshotConfig(): array { + $sampleId = '00000000-0000-4000-8000-000000000000'; + + // The serving routes take a `previewId` (and `path`); generate the bare + // capability-root URL for a placeholder id, then strip the trailing + // `/{id}` so the client can append `/{previewId}/index.html` itself. + $sampleUrl = $this->urlGenerator->linkToRoute( + Application::APP_ID . '.preview.serveRoot', + ['previewId' => $sampleId], + ); + $servingBaseUrl = str_ends_with($sampleUrl, '/' . $sampleId) + ? substr($sampleUrl, 0, -(strlen($sampleId) + 1)) + : $sampleUrl; + + // The delete route is templated rather than derived by concatenation, so + // the client never has to know how this app spells a capability URL. + $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(), + ], + ]; + } + /** * Resilience shim injected into the editor before any editor * script runs. The static eXeLearning editor's ResourceFetcher rejects 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 87ac8a1..ec2f2f1 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -5,7 +5,9 @@ namespace OCA\ExeLearning\Controller; use OCA\ExeLearning\AppInfo\Application; +use OCA\ExeLearning\Service\ContentTokenService; use OCA\ExeLearning\Service\ElpxPackageService; +use OCA\ExeLearning\Service\IframeSandbox; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -39,6 +41,8 @@ public function __construct( private readonly ElpxPackageService $packageService, private readonly IInitialState $initialState, private readonly IURLGenerator $urlGenerator, + private readonly ContentTokenService $contentTokens, + private readonly IframeSandbox $sandbox, ) { 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', is_file(__DIR__ . '/../../js/editor/index.html'), @@ -101,6 +124,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..e24f7f1 --- /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..0936a23 --- /dev/null +++ b/lib/Service/IframeSandbox.php @@ -0,0 +1,164 @@ +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, + "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..06fd84e --- /dev/null +++ b/lib/Service/PackageMimeService.php @@ -0,0 +1,69 @@ + 'text/html; charset=utf-8', + 'htm' => 'text/html; charset=utf-8', + 'xhtml' => 'application/xhtml+xml; charset=utf-8', + 'xml' => 'application/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', + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'webm' => 'video/webm', + 'vtt' => 'text/vtt', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + ]; + + 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..30db421 --- /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 ADR-0018 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..5ea0451 --- /dev/null +++ b/tests/Unit/Service/IframeSandboxTest.php @@ -0,0 +1,128 @@ + $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 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', $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); + } + + 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', $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', $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..cea0843 --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewPolicyTest.php @@ -0,0 +1,149 @@ + + */ + 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"]; + } + + 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..e80edc0 --- /dev/null +++ b/tests/Unit/Service/Preview/PreviewServerTest.php @@ -0,0 +1,250 @@ +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 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