Skip to content

document editing web UI, hardening, and CODE 26.04 - #5

Open
cyberb wants to merge 1 commit into
masterfrom
feat/wopi-editor-spa-and-hardening
Open

document editing web UI, hardening, and CODE 26.04#5
cyberb wants to merge 1 commit into
masterfrom
feat/wopi-editor-spa-and-hardening

Conversation

@cyberb

@cyberb cyberb commented Aug 29, 2026

Copy link
Copy Markdown
Member

Covers points 2, 3 and 4 from the review of #4, plus the CODE 26.04 migration that #4 had to drop.

CI: build 105 green on amd64 (22/22) and arm64 (16/16), including the full upgrade chain.

Why a WOPI host at all

Collabora Online has no storage of its own, so the app is currently only usable through Nextcloud Office. Upstream cannot help: its built-in WOPI host and documents.html picker are compiled out of release builds —

// wsd/FileServer.cpp
#if ENABLE_DEBUG
    if (relPath.starts_with("/wopi/files"))     { handleWopiRequest(...);      return true; }
    if (relPath.starts_with("/wopi/documents")) { handleDocumentsRequest(...); return true; }
#endif

— and local filesystem storage is gated the same way (ENABLE_LOCAL_FILESYSTEM is 0 unless MOBILEAPP || ENABLE_DEBUG, config.h.in), which also means the storage.filesystem entry we already shipped never did anything. So the app becomes the WOPI host.

Security model

Surface Listener Authorised by
SPA, /api/*, /oidc/* unix socket -> nginx Authelia OIDC session cookie
/wopi/files/* 127.0.0.1:9981 HMAC token binding file id + user + expiry
/browser, /hosting/*, /cool/* via nginx unauthenticated, unchanged for Nextcloud
/browser/dist/admin, /cool/adminws via nginx Authelia auth_request, unchanged
/wopi/ through the public proxy nginx explicit return 404

Separate muxes, so WOPI is unreachable from outside. 07-security.spec.ts asserts all of it, including that 9980/9981 are loopback-IPv4 only and that the real WOPI host rejects a forged token.

WOPISrc points at a loopback port because coolwsd is the one dialling: devices often cannot resolve their own subdomains (NAT loopback) and coolwsd will not trust the platform CA. coolwsd has no unix-socket support.

Hardening (point 2)

  • net.listen: any -> loopback + net.proto: all -> IPv4. coolwsd bound 0.0.0.0:9980 with the admin console at admin/admin, reachable from the LAN bypassing nginx and Authelia. The password is now generated at install. (proto=IPv4 is required: with all+loopback coolwsd binds [::1] only, which upstream warns about in a comment directly above the setting — it caused a 502 in build 96.)
  • alias_groups first -> groups. ⚠️ first pins whichever WOPI host connects first, which breaks with two hosts in play. Groups now allow our loopback host and any host on this device's domain — an off-device Nextcloud is no longer accepted by default.
  • home_mode.enable = true. ⚠️ Suppresses the first-run Welcome dialog and feedback popups (it was covering the document and blocking editing), at the cost of capping concurrent connections at 20 and open documents at 10. Traced to upstream configure.ac; CODE is built --with-welcome-url, which makes home_mode the applicable switch.
  • Mutable state moved out of $SNAP_COMMON into $SNAP_DATA, leaving only the platform-contract web.socket.
  • always_save_on_exit = true, so closing the editor flushes through the WOPI host rather than waiting on idle/auto-save.

CODE 26.04 (why #4 could not ship it)

collabora/code:26.04.x is a Nix-built distroless image with no /bin/sh — Drone cannot use it as a step image (build 92: exec: "/bin/sh": ... no such file or directory). Its /usr and /lib are a symlink farm into absolute /nix/store paths that dangle anywhere else. app/Dockerfile resolves them in a debian stage with /nix mounted at the same path; app/build.sh extracts over dind. Launchers gained a shared loader.sh because the amd64 loader moved to /lib64.

Guide gaps (point 3)

  • every step calls a committed script (cli/build.sh, cli/test.sh, ci/test.sh, web/build.sh); redundant top-level build.sh removed
  • nginx test and cli test on bookworm and buster
  • snap.yaml: dropped confinement/plugs, added restart-delay: 10s, cli entry, backup/restore hooks
  • package.sh uses apt.sh
  • test/upgrade.py had its whole body commented out and ended in pass — the green step asserted nothing. Replaced with a real test-upgrade-prev -> e2e-before-upgrade -> test-upgrade -> e2e-after-upgrade chain, all passing.
  • added a coolwsd readiness gate; the old check raced a slow first boot on arm64+buster

Review notes

  • samples/blank.{docx,xlsx,pptx} are binary blanks copied from the onlyoffice app repo, needed to create documents from templates. Verified valid OOXML.
  • No secrets committed: config/backend.env is all {{ }} placeholders; OIDC secret, WOPI key and admin password are generated at install into $SNAP_DATA/secret/.
  • The pre-upgrade spec seeds over ssh because the currently released collabora has no SPA to drive. Once this ships, switch 08-pre-upgrade.spec.ts to the UI path.
  • Screenshots for every screen (desktop + mobile) land in artifact/e2e*/playwright/<project>/screenshot/.

@cyberb
cyberb force-pushed the feat/wopi-editor-spa-and-hardening branch 9 times, most recently from 96ed840 to d1d30c5 Compare August 30, 2026 03:40
Collabora Online is a WOPI client with no storage of its own, so today this
app is only usable through Nextcloud Office. Upstream has nothing to change
that: the built-in WOPI host and its documents.html picker
(browser/html/documents.html, /wopi/files, /wopi/documents) are compiled out
of release builds behind `#if ENABLE_DEBUG` in wsd/FileServer.cpp, and local
filesystem storage is gated the same way (`ENABLE_LOCAL_FILESYSTEM` is 0
unless MOBILEAPP || ENABLE_DEBUG, config.h.in). The `storage.filesystem`
setting we already ship is therefore dead config.

So the app becomes the WOPI host, mirroring the shape of the onlyoffice
Syncloud app but with WOPI in place of the ONLYOFFICE JS config.

Backend (cli/backend, bin/backend)

Two listeners with different trust levels:

- a unix socket for nginx, serving /api/* and /oidc/* behind an Authelia
  OIDC session (auth-code + PKCE, nonce, opaque session cookie);
- 127.0.0.1:9981 for coolwsd, serving the WOPI host (CheckFileInfo, GetFile,
  PutFile, LOCK/UNLOCK/REFRESH_LOCK/GET_LOCK), authorised only by an
  HMAC-SHA256 access token whose payload binds the file id, the user and an
  expiry. The two muxes never share a route, so the WOPI endpoints are not
  reachable from the public proxy at all.

coolwsd cannot call back over the public hostname: plenty of devices cannot
resolve their own subdomains (NAT loopback), and the platform CA is not in
any trust store coolwsd uses. Pointing WOPISrc at a loopback port sidesteps
both, and coolwsd has no unix socket support to use instead.

OIDC discovery is non-fatal and retried in the background, over the platform
authelia socket first with X-Forwarded-Proto/Host set so the advertised
issuer matches the public one, falling back to HTTPS with the platform CA.
A failed discovery must never take the whole app down with it.

Editor URLs come from coolwsd's own /hosting/discovery rather than a guessed
/browser/<hash>/cool.html path, and are rebased onto the public app URL so
the scheme and host are right regardless of what coolwsd derives from a
loopback request.

Frontend (web/)

Vue 3 + Element Plus + Vite SPA: document list, create from blank docx/xlsx/
pptx templates, upload, delete, and an editor view that POSTs the access
token into the iframe as a form rather than putting it in the URL. nginx
serves the built dist; the Nextcloud paths (/browser, /hosting/*, /cool/*)
stay unauthenticated exactly as before.

Hardening

- coolwsd bound 0.0.0.0:9980 (`net.listen = any`) with the admin console
  credentials hardcoded to admin/admin, so anyone on the LAN could reach the
  admin console and the REST API directly, bypassing nginx and Authelia.
  net.listen is now loopback and the password is generated at install time
  into both coolwsd.xml and the nginx Authorization header.
- storage.wopi.alias_groups moves from mode="first" to mode="groups". "first"
  pins whichever WOPI host connects first, which with two hosts in play
  (ours and Nextcloud's) would break whichever came second. The groups allow
  our loopback host and any host on this device's domain. An off-device
  Nextcloud is no longer accepted by default; that is a deliberate narrowing.
- mutable state moves out of $SNAP_COMMON (nginx pid, temp paths) into
  $SNAP_DATA, leaving only the web.socket the platform contract requires.

CODE 26.04

collabora/code:26.04.x is a Nix-built distroless image: no /bin/sh, so Drone
cannot use it as a step image at all (`exec: "/bin/sh": stat /bin/sh: no such
file or directory`) and the in-image `cp -r /usr /lib` build cannot run. Its
/usr and /lib are also a symlink farm pointing at absolute /nix/store paths,
which would be dangling anywhere but that image. app/Dockerfile therefore
resolves those symlinks in a debian stage that has /nix mounted at the same
path, and app/build.sh extracts the result over dind. The bundled-loader
launchers gain a shared loader.sh because the amd64 loader moved to /lib64
and would no longer be found by the old glob.

CI and tests

- every step now calls a committed script (cli/build.sh, cli/test.sh,
  ci/test.sh, web/build.sh); the redundant top-level build.sh is gone
- nginx and cli binaries are smoke-tested on bookworm and buster
- test/upgrade.py had its entire body commented out and ended in `pass`, so
  the green test-upgrade step asserted nothing. It now seeds a document
  before the refresh and checks it survives, with e2e specs on both sides
- new Playwright specs cover login, the file list, editing docx/xlsx/pptx, a
  type/save/reopen round trip that unzips the docx and greps for the phrase,
  and the security boundaries; every screen is screenshotted into artifact/
@cyberb
cyberb force-pushed the feat/wopi-editor-spa-and-hardening branch from d1d30c5 to a5d80cf Compare August 30, 2026 04:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant