Secure opaque-origin viewer and HTTP editor preview v2#68
Draft
erseco wants to merge 41 commits into
Draft
Conversation
…rence) Mirror of the eXeLearning core canonical contract (exelearning/doc/development/preview-serving-contract.md): serve the editor preview of untrusted author content over an authless capability URL in an opaque origin, via this host's own cookieless serving primitive, so the preview gets real per-page URLs (working navigation + open-in-new-tab) instead of the srcdoc fallback. The sandbox-first CSP is emitted verbatim from core's previewCspHeader() on every scriptable document type (text/html, image/svg+xml, application/xml, application/xhtml+xml). Reference endpoint + docs; the session store, management API and tests are follow-up per repo.
Contributor
Preview this PR in the Nextcloud PlaygroundA fresh Nextcloud boots in your browser with this branch's eXeLearning editor: |
Single source of truth for the secure opaque-iframe policy shared with the other
plugins: secure sandbox tokens ('allow-scripts allow-popups allow-forms', never
allow-same-origin), the strict/compatible published CSP, the SVG/XML locked CSP,
Permissions-Policy, provider whitelist, and the dev-only legacy escape hatch.
Stateless HMAC-SHA256 token binding a fileId + expiry under the instance secret, minted at view-open (authenticated) and verified by the cookieless opaque content route. Mirrors Moodle's tokenpluginfile capability model. Secret injected as a string to keep the service OCP-free and unit-testable.
…SP + shim) ContentController serves published .elpx entries into an opaque-origin iframe over a cookieless capability URL: verify the fileId-bound token, resolve the file system-wide, read the entry, emit the strict published CSP on HTML (svgCsp on SVG/XML) + hardening headers, and inline the eXe embed shim into HTML documents. Adds EmbedShimInjector + PackageMimeService (OCP-free, tested) and refactors AssetController onto the shared MIME service (single source of truth).
When a package resolves and secure mode is active, provide a fileId-bound
capability token (contentToken) + the secureIframe flag as initial state, so the
Vue viewer can load the package from the opaque /content/{token}/… route. The
read permission was already checked by getForUser*; legacy mode leaves the token
null and keeps the Service-Worker path.
…ilder
iframe-renderer now defaults to the secure opaque sandbox
('allow-scripts allow-popups allow-forms', no allow-same-origin) and adds
createContentIframe for the /content/{token} route; the legacy same-origin
Service-Worker path (createPackageIframe) keeps allow-same-origin + link rewiring
behind opaque=false. paths.ts gains buildContentUrl (token-addressed).
Mirror the eXe-core embed/media bridge (exe_embed_shim/relay + exe_media_policy/host)
into src/embed/ and drive them from the Viewer: the opaque content iframe's shim
promotes cross-origin/PDF players to placeholders, and relay-host.ts overlays real
players (Channel A) + hosts the interactive-video bridge (Channel B), pinging on
load and clearing/reflowing overlays on hide/resize. ElpxViewer now defaults to the
opaque /content/{token} path when a token is present, keeping the Service-Worker
path only under the legacy escape hatch. Mirror JS excluded from eslint/biome (kept
byte-synced with core).
Add IframeSandbox::embedMode() (strict default, open opt-in via
EXELEARNING_EMBED_OPEN) and pass {mode, whitelist} to the relay via initial
state, so the published viewer overlays only maintained providers unless opened
up. relay-host.startRelay now accepts the config (defaults to open).
erseco
marked this pull request as draft
July 6, 2026 23:08
The cookieless #[PublicPage] content route has no session, so IRootFolder::getById
resolved nothing and every /content/{token}/... returned 404. Bind the user id into
the token and resolve via getForUserById(uid, fileId) at serve time, which mounts
the user's storage and re-checks the read permission.
…he Playground The php-wasm Playground has no real HTTP server (a Service Worker can't serve an opaque iframe), so its published-viewer demo needs the same-origin path. The nextcloud-playground blueprint schema can only reach PHP via config:app:set, so wire IframeSandbox's injected envReader (in Application.php, keeping IframeSandbox OCP-free) to fall back to a Nextcloud app-config value when the process env is unset — process env still wins on real hosts. blueprint.json sets it with a setConfig step (loud DEV-ONLY comment). ApplicationTest (7 tests) proves the precedence + secure-by-default.
Implement the eXeLearning editor-preview serving contract v2 as a Nextcloud-native host: an authless, cookieless capability URL that serves untrusted author HTML/JS from an opaque origin, plus an authenticated, owner-scoped management API. Protocol logic lives in OCP-free seams under lib/Service/Preview so every rule is unit-testable without a server: - PreviewPolicy: single source of truth for the sandbox-first CSP (byte-identical to core previewCspHeader), the scriptable-document set, the Permissions-Policy, served MIME resolution and traversal-safe path normalization. - PreviewSessionStore: file-backed three-layer store (generated documents, session assets, fixed refs) with atomic revisions (staging + pointer swap), asset immutability, per-session/per-user/global budgets with LRU eviction, and a 30-min idle TTL. - FixedResourceManifest: exact-key lookup into the installed editor distribution's bundles/preview-fixed-resources.json with distribution-root containment; absent manifest disables the layer (422, client demotes). - PreviewServer: serving HTTP policy (hardening headers on every response incl. 404, tiered Cache-Control, ETag/304, single-range 206/416, sandbox CSP on every scriptable type from any layer). - PreviewSessionApi: management HTTP policy (ownership gate, 409/422/413 wire bodies, protocol-version negotiation). Thin Nextcloud adapters on top: PreviewController (#[PublicPage] serving), PreviewSessionController (#[NoAdminRequired], CSRF on, mirroring editor#save), and PreviewCleanupJob (TimedJob sweeping expired sessions). Routes and the background job are registered; the store root is resolved from the Nextcloud data directory (the only OCP seam). The editor is intentionally not wired to select the http transport yet.
Cover the serving-contract v2 seams without a Nextcloud server: - PreviewPolicyTest: CSP byte-identity with core, scriptable classification, MIME resolution, path normalization (literal/encoded traversal, backslash, NUL), id/key validation. - PreviewSessionStoreTest: lifecycle, per-user LRU cap, asset immutability (original bytes keep serving), atomic revision ordering, the full validation ladder (409/400/422/413), incremental deltas/deletes, three-layer resolution, and idle-TTL drop-on-access + sweep. - FixedResourceManifestTest: manifest lookup, containment, and graceful disable when the manifest is absent/malformed. - PreviewServerTest and PreviewSessionApiTest: header/CSP/cache/ETag/Range policy and the management status/body mapping. - PreviewContractConformanceTest: replays tests/fixtures/preview-contract/ vectors.json (vendored verbatim from eXe core) against the API + server, porting the harness interpretation so protocol semantics stay aligned with every other host.
Replace the v1 (manifest + content-addressed blob) host-side companion with the v2 three-layer model: management API, authless serving route, tiered Cache-Control, the byte-identical sandbox CSP, the fixed-resource manifest, file-backed storage and cleanup. Document the app's two-CSP situation (PreviewPolicy preview CSP vs IframeSandbox published CSP) and why they are deliberately not unified.
Sync the byte-synced eXe-core embed bridge mirrors with core commit 054abfd0 (external-media relay stayed frozen when the exported page's nav drawer / a class-flip reflow moved the content iframe with no scroll/resize event). exe_embed_shim.js init(): report(force) with a JSON dirty-check (force on initial run, load and parent 'request' pings; observer-driven reports skip identical geometry so attribute-noisy pages cannot spam the parent). The MutationObserver now also watches attributes (class/style/hidden/open), and transitionend/animationend + a documentElement/body ResizeObserver feed the reflow schedule. exe_embed_relay.js: positionOverlay records entry.lastRect; a new checkDrift() re-pins any overlay whose content-iframe box moved without an event and returns the moved count; it is exposed on the relay and run from a 300ms interval in init(). Changed regions are byte-identical to core (only the export wrapper differs). Port core's checkDrift unit test (tests/js/exe-embed-relay.test.ts) driving the raw mirror via createRelay, kept deterministic/offline by disabling happy-dom child-frame navigation and file loading.
Authenticated disk-fill DoS: publishRevision wrote each revision's unique content-addressed document blobs under the session tree but never pruned superseded revisions, while the per-session (200 MiB) and global (2 GiB) budgets count only the ACTIVE revision's documentBytes + assetBytes. An authenticated user looping publishRevision with ~199 MiB of unique-content documents per revision passed every budget check yet left each revision's bytes on disk, so N revisions = N x ~199 MiB physical, unbounded — filling the Nextcloud data volume. Fix: after the atomic `current` pointer swap, prune to a single active revision — delete superseded revision manifests and any document blob no longer referenced by the active revision. Pruning runs under the same exclusive publish flock (single writer) and AFTER the swap, so new readers already resolve the new revision; a GET still pinned to the just-superseded revision may 404 and re-sync (the designed recovery). Assets live in a separate directory and are never pruned — they are shared and immutable across revisions. A failed publish returns before any blob is written (validation precedes the publish block), so it leaves no staging behind. Physical document disk is now bounded by the active-revision byte budget. TDD: new store test publishes several large unique-content revisions and asserts only the active revision's manifest + single blob remain, the asset survives every revision, and on-disk bytes stay bounded (not N x docSize).
…adyStored storeAssets() reported a blob-write failure (disk full, unwritable assets dir, link/rename failure) as `alreadyStored`. Per the contract's recovery flow `alreadyStored` means "the server already holds these bytes", so the client marked the key uploaded — the asset then 404s forever and the missing-assets recovery loop could never repair it. Distinguish the two false returns of writeBlobAtomic(): when the target now exists the key appeared concurrently (bytes present → alreadyStored); when it does not, the write genuinely failed (bytes absent → rejected with reason 'write-failed'). A rejected key is left un-uploaded, so a later revision referencing it returns 422 missing-assets and the client re-uploads it. TDD: new store test forces a deterministic, root-independent write failure (replaces assets/ with a regular file → ENOTDIR) and asserts the key is rejected 'write-failed' (not stored/alreadyStored), the blob is absent, and a revision referencing it returns 422 missing-assets.
Sync exe_embed_relay.js with core: the 300ms checkDrift interval and the window listeners installed by init() were left running for the page lifetime with no teardown, and a second init() on the same relay stacked a duplicate interval + duplicate listeners. createRelay() now tracks driftTimer + started; dispose() runs clear() then clears the drift interval and removes the message/resize/scroll/load listeners (idempotent, safe before init or called twice), exposed on the relay; init() early-returns when already started and stores the interval handle in driftTimer. Changed region is byte-identical to core (only the export wrapper differs). Port core's dispose unit test into tests/js/exe-embed-relay.test.ts (tears down overlays like clear(), safe before init and when called twice).
Inject a previewHttp block into window.__EXE_EMBEDDING_CONFIG__ so the embedded editor can drive the HTTP editor-preview transport (serving contract v2): protocolVersion 2, a management base URL, a serving base URL, and the current Nextcloud CSRF token as the requesttoken header. Both URLs are generated server-side through IURLGenerator::linkToRoute so they carry the correct webroot and front-controller prefix under a sub-path install (mirroring @nextcloud/router generateUrl on the client); the serving base is derived by generating the bare capability-root URL for a placeholder id and stripping the id segment. The requesttoken is the same encrypted value the standard template layer exposes as data-requesttoken (CsrfTokenManager::getToken()->getEncryptedValue()), which the management routes require because they keep CSRF on. The bundled v4.0.2 editor predates the HTTP preview client and simply ignores the block; a capable build activates it with no server change.
Two serving-policy corrections mandated by the normalized contract (v2.1
section 4), both in the OCP-free PreviewServer so they stay unit-testable
and vector-replayable:
- Range: a malformed, multi-range or non-bytes header is now IGNORED and
the server returns a normal 200 full body. 416 is reserved for a
syntactically valid single range that is unsatisfiable (e.g. bytes=99-
past EOF). parseRange now distinguishes 'ignore' (null) from
'unsatisfiable' so the caller can tell the two apart; the old behaviour
of 416-on-any-parse-failure was wrong.
- Bare capability root: GET {servingBase}/{previewId} now 302-redirects to
{previewId}/index.html instead of serving index.html bytes inline. Served
inline, the document's relative subresource references resolve against
preview/ (dropping the id segment) and every asset 404s. The Location is
relative so it is correct under any webroot.
A missing/unreadable .accessed marker made isExpired() return false, so the session became immortal: it counted against the global byte budget forever and sweepExpired could never reclaim it. Fall back to meta.json createdAt as the age clock when the marker is gone; if meta.json is missing/corrupt too, the directory is unusable and is treated as expired so the sweep reclaims it. globalBytes() is recomputed from the surviving session directories on every call, so removing the directory reconciles the accounting automatically. Tests: a session whose .accessed was deleted is swept after TTL via the createdAt fallback; a directory with neither marker nor meta.json is reclaimed.
A dropped/failed files[] part became a silently-empty document (sha1(''),
size 0) and the revision published anyway. The controller now aligns every
declared write with a healthy uploaded part (present, UPLOAD_ERR_OK,
readable); any missing/failed/unreadable part — or a part/write count
mismatch — rejects the whole batch with 400 before any staging, so the
revision pointer never advances. uploadedFileParts() carries per-part upload
health (a genuinely empty but successfully uploaded file stays valid);
uploadedFileBytes() keeps the asset path unchanged by delegating to it.
The store mirrors this rigor: a genuine document blob-write failure aborts
applyRevision with 500 before the manifest write and pointer swap, so the
active revision is never advanced onto a missing blob (an 'already present'
content-addressed blob is not a failure). PreviewSessionApi maps 500 through.
Test: a document write failure returns 500 and leaves the previous revision
active.
…iptions - docs/preview-serving-contract.md: replace the dead previewTransport / previewBasePath vocabulary with the normalized previewHttp two-URL model, document that editor activation is now wired plus its editor-build dependency, and note the bare-root 302 and malformed-Range->200 CHANGES with a vectors re-vendor-pending caveat (vectors are not forked here). - README.md + appinfo/info.xml: the viewer is served over the opaque /content HTTP route by default (a sandbox without allow-same-origin), not purely the Service Worker; note the HTTP editor-preview path and that only the legacy opt-in viewer needs Service Workers. - CHANGELOG.md: correct the stale claim that CI 'verifies the Service Worker route responds' to what CI actually does.
Add a CI job that boots a real Nextcloud, enables the app, serves it over HTTP and drives the editor-preview management and serving routes end to end (tests/e2e/preview-api-e2e.sh). Cookie/session auth is used on purpose so the CSRF middleware is genuinely exercised. It asserts: management rejects a create without a requesttoken (412, CSRF proof); create returns 201 with protocolVersion 2; asset upload + revision publish round-trip; the authless serving response is 200 with the opaque sandbox CSP and no allow-same-origin; the bare root 302-redirects; a second user's management call is 403; a dropped multipart part is rejected (400) without advancing the revision; and after DELETE the serving route 404s. Full editor-iframe browser E2E stays blocked on a capable editor build and is documented as such rather than faked.
The built-in PHP server's document root is the Nextcloud tree, so the top-level status.php is served directly; /index.php/status.php routes through the front controller and 404s. Probe /status.php (and assert installed:true) so the readiness gate reflects a genuinely ready server.
Per the frozen contract (v2.1 section 4 / RFC 9110 section 14.1.2), a range whose last-byte-pos is less than its first-byte-pos (bytes=5-2) is an INVALID spec and must be ignored (200 full body), not answered 416. parseRange now returns null for that case; 416 stays reserved for a valid but unsatisfiable single range — first-byte-pos at/after EOF (bytes=99-) or a zero-length suffix (bytes=-0). Adds bytes=5-2 (ignored) and bytes=-0 (416) tests.
Record the CSRF-TTL audit conclusion: a Nextcloud request token is bound to a per-session secret, so the value injected into previewHttp.managementHeaders stays valid for the whole editing session (every getEncryptedValue() encoding validates against the same secret). The secret regenerates only on session-id regeneration (login/logout/re-auth), which reloads the parent page and re-injects a fresh token; the parent's keepalive heartbeat keeps the session alive meanwhile. The injected-token approach is durable — no postMessage refresh path is required now.
The login helper died silently before the first assertion: under set -euo pipefail, scrape_token's grep returns non-zero on no match, which tripped set -e inside the RT1=$(login ...) command substitution and aborted with no output. Guard scrape_token with '|| true' so an empty result is handled by the caller's check instead of killing the script, capture the login pages to files and dump a diagnostic (status + snippet) when the requesttoken is absent, and follow post-login redirects with -L. Also tail data/nextcloud.log on failure (occ has no log:tail command).
The post-login step scraped /apps/files/ HTML for data-requesttoken, which came back empty under php -S (the failure the last run surfaced). Replace it with Nextcloud's canonical /csrftoken JSON endpoint — the same source OC.requestToken refreshes from — parsed with jq. It requires a live authenticated session, so it doubles as a login check: an unauthenticated hit returns a login redirect (not JSON) and yields an empty token with a diagnostic dump, instead of masking a login failure with a guest token.
The management create returned 401 (unauthenticated), not 412: the cookie session was never authenticated. Root cause is almost certainly Secure session cookies — behind CI's proxy Nextcloud defaults to https, marks the session cookie Secure, and curl refuses to send it over the plain-http php -S server, so every authenticated request falls through to 401 (while /csrftoken still hands a guest token back, masking it). Force overwriteprotocol=http so the session cookie is not Secure. Also make login() verify authentication explicitly via the OCS cloud/user endpoint (which returns the authenticated user id, unlike /csrftoken) and dump the login POST status + cookie-jar names on failure, and dump the create body (asserting it mentions CSRF) when the 412 assertion misses — so any remaining auth issue is diagnosable in one run instead of opaque.
The login POST returns 200 (not the 303 of a successful login) and the session stays unauthenticated: the login form's own strict-cookie/CSRF check fails because the SameSite/session cookies aren't round-tripping over php -S plain HTTP. Complete the plain-HTTP config: overwrite.cli.url without the /index.php suffix (so cookies get Path=/), overwriteprotocol=http, and delete forcessl/overwritehost so nothing forces https (Secure cookies). Make the failure fully diagnosable in one run: dump the login flow's Set-Cookie headers, the raw cookie jar, and the login POST body (which carries NC's error) so the exact cookie attribute or login error is visible instead of inferred.
The browser login form's SameSite/session cookies do not round-trip under the built-in php -S server (the raw cookie jar stayed empty and the login POST re-rendered the form), so a cookie session can't be established there. Switch the E2E to HTTP Basic auth, which authenticates per request via the Authorization header with no cookies. CSRF is still exercised honestly using Nextcloud's own rule (Request::passesCSRFCheck): the positive calls send the OCS-APIRequest header (which makes passesCSRFCheck pass without a session/token), and an AUTHENTICATED management POST WITHOUT that header is rejected 412 'CSRF check failed' — the real proof that the management routes are not #[NoCSRFRequired]. The full round-trip, authless opaque serving (200 + sandbox CSP, no allow-same-origin), bare-root 302, ownership 403, dropped-part 400 (revision unchanged) and post-delete 404 all run cookielessly against real Nextcloud. No product code changes; harness only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch moves both published eXeLearning content and editor preview onto browser-enforced opaque-origin transports.
It delivers:
.elpxcontent with the shared external-media bridge.Production iframes omit
allow-same-origin. Author HTML and JavaScript cannot reach the Nextcloud DOM, cookies, storage, or APIs.Published-content viewer
The previous same-origin Service Worker path is replaced by a public capability route:
The token is minted after the authenticated controller verifies file access. Content responses include the sandbox CSP and hardening headers. The Service Worker path remains only behind the development-only published-viewer escape hatch.
External YouTube, Vimeo, PDF, and interactive-video content uses synchronized eXeLearning shim/relay/media-host code with strict provider policy by default.
HTTP editor preview: protocol v2
The app exposes:
Management requires a normal Nextcloud session,
requesttoken, and per-session ownership. The management routes are not markedNoCSRFRequired.Serving is an authless, cookieless capability URL identified by an unguessable UUID and bounded by TTL and quotas.
Protocol v2 separates:
Editor configuration
EditorControllerinjects:{ "previewHttp": { "protocolVersion": 2, "managementBaseUrl": "/apps/exelearning/api/preview-session", "servingBaseUrl": "/apps/exelearning/preview", "managementHeaders": { "requesttoken": "..." } } }Management requests keep same-origin credentials and the request token. Serving requests use
credentials: "omit"and never receive CSRF material.Session store
The store is file-backed and private. It implements:
PreviewCleanupJob;A failed upload part or blob write is rejected before the active pointer moves.
Serving behavior
text/xml, and XHTML.302from the bare capability root to{previewId}/index.html.206for a valid satisfiable single range.416for a valid unsatisfiable range, includingbytes=-0.200when Range is malformed, multi-range, non-bytes, or inverted.Playground policy
An embedded Nextcloud editor must never use the static Service Worker transport in production.
A development-only environment may opt into the same-origin compatibility transport only with both fields:
{ "previewTransport": "static-service-worker", "allowUnsafeEmbeddedPreview": true }The transport name alone is rejected. The mode is visibly warned and is not a security sandbox.
Activation status
The currently bundled released editor predates
HttpPreviewProvider. The Nextcloud routes, private store, request-token wiring, CSP, and contract tests are implemented, but the released client does not yet drive the HTTP preview.docs/testing-http-preview-core-artifact.mddocuments the final integration gate: build one static-editor artifact from the target core commit, record its SHA-256, install it in the Nextcloud test environment, and demonstrate create → assets → revision → opaque capability iframe → incremental update → cleanup using a real cookie session.Production activation depends on a core release containing:
API-level Basic-auth tests are valuable but are not presented as proof of browser activation or browser-cookie CSRF behavior.
Tests
Canonical source
eXeLearning core is the canonical source for the serving contract, CSP, scriptable MIME policy, bridge files, and conformance vectors. Nextcloud implements a framework-native adapter and synchronized mirrors.
Known limitations