Skip to content

Self-hosting

Production security

The security checklist before opening O3O Office Online to real users, with commands to check it from outside.

On this page

The list below must be complete BEFORE the system is opened to real users. Each item comes with a way to check it.

Pre-production checklist#

#ItemHow to check
1O3O_DEV_MODE=0 and O3O_EMBED_ALLOW_UNSIGNED=0./o3o/status: dev_mode = false, embed.jwt_required = true; /o3o/demo returns 404.
2O3O_PUBLIC_URL, O3O_ONLINE_SERVER_NAME, O3O_ONLINE_SSL_TERMINATION=true, O3O_ONLINE_FRAME_ANCESTORS are set.Paths in /hosting/discovery start with https:// and the right domain.
3O3O_COOLWSD_ADMIN_PASSWORD, O3O_EMBED_JWT_SECRET, O3O_EMBED_CALLBACK_SECRET, O3O_DOCBUILDER_API_KEYS are changed.grep CHANGE_ME .env finds nothing; upstream.coolwsd = "ok". The editing server admin password is enforced at the source: compose refuses to start while O3O_COOLWSD_ADMIN_PASSWORD is empty or unset, and the gate with O3O_DEV_MODE=0 refuses to start while the password is empty or still CHANGE_ME.
4O3O_EMBED_ALLOWED_ORIGINS is no longer *.A session request from an unknown origin returns 403 origin_not_allowed.
5O3O_ONLINE_IMAGE points at the source-built O3O image; the DEV image must not be delivered to customers./o3o/status: dev_image = false. O3O image: coming soon, see Building from source.
6Callbacks are signed./o3o/status: embed.callback_signing = true.
7O3O_GATE_IMAGE and O3O_DOCBUILDER_IMAGE point at the release images, not the locally built :dev tags.python tools/release.py env prints the same two values as .env; docker compose ... config --images lists those images.
8The inner proxy is o3o-proxy, or your own configuration passes the whole inner proxy checklist: every WebSocket upgrade to the editing server goes through auth_request; admin and metrics are blocked, trailing-slash forms included.Commands below: every closed path returns 404; unknown WebSocket handshakes return 403 or 404, never 101.
BashChecking from outside
BASE=https://office.example.com

# The editing server's version hash, taken from discovery
HASH=$(curl -s "$BASE/hosting/discovery" | grep -o 'browser/[^/"]*/cool.html' | head -n1 | cut -d/ -f2)

# Every path below must return 404, trailing-slash forms included
for path in /browser/dist/admin/admin.html "/browser/$HASH/admin/admin.html" /cool/adminws /cool/adminws/ \
            /cool/getMetrics /cool/getMetrics/ /cool/getMetrics/x /cool/convert-to /lool/convert-to \
            /o3o/auth /o3o/wopi/files/x /o3o/demo; do
  printf '%-42s %s\n' "$path" "$(curl -s -o /dev/null --path-as-is -w '%{http_code}' "$BASE$path")"
done

# Unknown WebSocket handshakes: 403, 403, 404; no line may return 101
for path in /cool/not-a-url/ws /cool/not-a-url/ws/ /cool/adminws/; do
  printf '%-42s %s\n' "$path" "$(curl -s -o /dev/null --path-as-is --http1.1 --max-time 5 -w '%{http_code}' \
    -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
    -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' "$BASE$path")"
done

# Expected flags: false, false, true, true
curl -s "$BASE/o3o/status" | jq '{dev_mode, dev_image, callback_signing: .embed.callback_signing, jwt_required: .embed.jwt_required}'

# An unknown origin must be refused: "origin_not_allowed"
curl -s -X POST "$BASE/o3o/embed/session" -H 'Origin: https://not-allowed.example' \
  -H 'Content-Type: application/json' -d '{}' | jq .error.code

# No CHANGE_ME may remain
grep -n CHANGE_ME online/.env

Network and ports#

  • Only publish o3o-proxy's port, and only to the TLS outer proxy; block port 8080 from the Internet with a firewall when the outer proxy runs on the same host.
  • Never publish 9980, 8070, 8060 or database ports; never point the outer proxy directly at the editing server.
  • Every WebSocket upgrade to the editing server must go through a location whose auth_request asks the gate, whatever the URL form (/cool/<doc>/ws, /cool/ws?WOPISrc=…, a trailing / or segments after ws). The editing server's other locations drop the Upgrade header. A WebSocket request the gate cannot parse is unknown and refused (403), not failed open.
  • /cool/getMetrics and /cool/adminws are blocked at the proxy by prefix, so /cool/getMetrics/, /cool/getMetrics/x and /cool/adminws/ all return 404 as well.
  • Fail-open only happens when the gate does not answer, fails, or cannot read session figures from the editing server; connections.enforcing is then false. A gate that refuses to start (for example because the admin password is still CHANGE_ME) also makes the proxy fail open: check docker compose ps and the gate logs right after every start.
  • DocBuilder is a server-to-server API: restrict /v1/ to your application servers' IP addresses at the outer proxy where possible.

Secrets#

  • .env never goes into git; make it readable only by the operating account (for example chmod 600).
  • One distinct value per secret, at least 32 random characters; never reuse one string for both JWT and HMAC.
  • Rotate keys regularly as described in Authentication.
  • Bind the license token to the instance (mid) so a leaked token is useless elsewhere.

Fetching external URLs (SSRF)#

The gate and DocBuilder fetch document.url, url, template_url and o3oscript images, and call callbackUrl and callback_url. All of those URLs follow the same rules:

  • Only http and https; no user:pass@; at most 2048 characters.
  • Host names are resolved ONCE before connecting; EVERY returned address must be public (loopback, private ranges, link-local, multicast and reserved ranges are excluded, IPv6 and IPv4 embedded in IPv6 included).
  • DNS rebinding protection (in v1, in both the gate and DocBuilder): after the check, the service connects STRAIGHT to the exact IP addresses it has just checked and never resolves the host name again, so the name cannot switch to an internal address between the check and the connection. The Host header, SNI and TLS certificate verification still use the original host name. This applies to every file download, every redirect hop and every callback delivery; a target that has not been checked is refused.
  • The only exception is O3O_FETCH_ALLOW_HOSTS. Entries given by host NAME (host or host:port) are trusted by name: they skip the address range check and are resolved normally at connect time, so only list host names whose DNS you control. CIDR entries are still pinned: the resolved address must fall in that range or be public, and the connection goes straight to the checked address. In production keep the list as short as possible and never add whole ranges such as 10.0.0.0/8.
  • Downloads follow at most 3 redirects, checking and pinning every hop again; callbacks never follow redirects.

Documents and customer data#

  • DocBuilder disables macros on every file load through LibreOffice; the image ships without the document engine's scripting packages. Template filling does not load the template through LibreOffice, so a template with macros (a vbaProject.bin part, a macroEnabled content type, a Basic/ or Scripts/ folder) is rejected with 422 macro_not_allowed.
  • Every job works in its own folder; client-supplied file names are never used as paths on disk.
  • DocBuilder runs as a non-root user with no-new-privileges, memory and process limits; workers are rebuilt after every timeout or crash.
  • The o3o-gate-data volume holds working copies of embedded documents (latest 5 versions plus the original, deleted after O3O_EMBED_RETAIN_HOURS). Protect and encrypt its disk and backups as customer data.
  • Download URLs carry an HMAC signature and an expiry; /o3o/status contains no file or user names; logs contain no document content, tokens or keys.

Callbacks#

  • The receiver verifies X-O3O-Signature over the raw body, compares in constant time and rejects timestamps more than 300 seconds off.
  • Use https for callbackUrl and callback_url; de-duplicate with X-O3O-Delivery. Details: Callbacks and webhooks.

Security updates#

Follow the Changelog and versions.json. Rebuild the DocBuilder image regularly to pick up security fixes of the base operating system; follow Upgrading and rolling back.