Skip to content

Storage integration

WOPI security

access_token and lifetime, allow lists on the editor server, Nextcloud, gate and proxy, transport, and the status of proof keys.

On this page

With WOPI, the editor server calls into your file store with the user's rights. Security rests on four layers: a token per open, the token lifetime, allow lists on both sides, and an encrypted channel. A fifth layer, proof keys, is coming soon.

access_token#

  • Random, at least 128 bits, unguessable. Never use a user id or file id as the token.
  • Bound to exactly one file, one user and one permission (read or edit). Every WOPI call must re-check all three.
  • The browser only receives the token in the form POST body sent to the iframe. Never put it in a page URL.
  • The editor server sends the token in the query string when it calls the WOPI host. Do not log query strings of /wopi/ paths.
PythonIssue and check tokens
# Issue an access_token: random, bound to exactly one file, one user, one permission, with an expiry
import secrets
import time

TOKENS = {}


def issue_token(file_id: str, user_id: str, can_write: bool, hours: int = 8) -> tuple[str, int]:
    token = secrets.token_urlsafe(32)                       # 256 random bits
    ttl_ms = int((time.time() + hours * 3600) * 1000)       # access_token_ttl: Unix milliseconds
    TOKENS[token] = {"file": file_id, "user": user_id, "write": can_write, "exp_ms": ttl_ms}
    return token, ttl_ms


def check_token(token: str, file_id: str, need_write: bool = False) -> dict | None:
    g = TOKENS.get(token)
    if not g or g["file"] != file_id or g["exp_ms"] < time.time() * 1000:
        return None                                          # answer 401 to the editor server
    if need_write and not g["write"]:
        return None
    return g

Lifetime#

access_token_ttl is the token expiry in Unix milliseconds, sent with the token in the form. Pick a lifetime that covers a working session (a few hours). If it expires mid-way, the user has to reopen the document. For PutFile specifically: still accept the final save of an editing session whose token just expired, so no data is lost.

WhereLifetime in v1
O3O.Editor embed session (the gate is the WOPI host)O3O_EMBED_SESSION_TTL_MINUTES, default 720 minutes
Nextcloudrichdocuments manages its own tokens
Your WOPI hostYour choice

Allow lists on both sides#

LayerSettingWhat it blocks
Editor serverO3O_ONLINE_ALIASGROUP1, O3O_ONLINE_ALIASGROUP2 (and extra groups)Only opens documents whose WOPISrc belongs to these groups. Group 1 is always the gate. Others get Unauthorized WOPI host.
Nextcloudwopi_allowlistOnly accepts WOPI calls from the editor server's IP/CIDR.
Your WOPI hostfirewall, proxy allow/denyOnly the editor server can reach /wopi/.
o3o-gateO3O_WOPI_ALLOWED_HOSTSThe gate only calls CheckFileInfo (once the cap is reached) on the listed origins. Empty means it never calls out.
o3o-proxyfixed configurationReturns 404 from outside for /o3o/wopi/, /o3o/auth, and the editor server's admin console and metrics.
nginxExample nginx in front of your WOPI host
# The editor server sends access_token in the query string. Do not log query strings of /wopi/.
log_format wopi_safe '$remote_addr [$time_local] "$request_method $uri" $status $body_bytes_sent';

location /wopi/ {
    access_log /var/log/nginx/wopi.log wopi_safe;
    allow 10.20.0.15;      # IP of the editor server
    deny all;
    proxy_pass http://127.0.0.1:5000;
}

Transport#

  • Users arrive over https: terminate TLS at the front proxy and set O3O_ONLINE_SSL_TERMINATION=true.
  • WOPI calls between the editor server and the host must travel over a trusted network. Across data centres, use https.
  • Embedding page on another origin: set O3O_ONLINE_FRAME_ANCESTORS so only your pages can frame the editor.
BashVariables to set in production
# online/.env — WOPI-related settings for production
O3O_ONLINE_ALIASGROUP1=http://o3o-gate:8070                  # always the gate, never change
O3O_ONLINE_ALIASGROUP2=https://cloud.example.com             # allowed WOPI host; extra aliases after a comma are regular expressions
O3O_WOPI_ALLOWED_HOSTS=https://cloud.example.com             # the gate only calls CheckFileInfo here
O3O_ONLINE_SSL_TERMINATION=true
O3O_ONLINE_FRAME_ANCESTORS=https://cloud.example.com
O3O_COOLWSD_ADMIN_PASSWORD=<32 random characters>

# Check from outside: admin and internal WOPI paths must return 404, trailing-slash forms included
# curl -s -o /dev/null --path-as-is -w "%{http_code}\n" https://office.example.com/cool/adminws
# curl -s -o /dev/null --path-as-is -w "%{http_code}\n" https://office.example.com/cool/adminws/
# curl -s -o /dev/null --path-as-is -w "%{http_code}\n" https://office.example.com/cool/getMetrics/
# curl -s -o /dev/null --path-as-is -w "%{http_code}\n" https://office.example.com/o3o/wopi/files/x

Proof keys#

A proof key lets the WOPI host verify that a call really comes from the editor server (X-WOPI-Proof header, public key published in discovery). v1 does not mount such a key, so discovery has no proof-key element. Nextcloud 30 (richdocuments 8.5.17) does not check proof signatures, so it is unaffected; oCIS requires them, which is why oCIS does not work with v1 yet.

BashCheck whether discovery publishes a proof key
# Count proof-key elements in discovery. v1 returns 0: the editor server has no proof key yet.
curl -s http://localhost:8080/hosting/discovery | grep -c "proof-key"
Coming soon

A proof key for the editor server, key sync across several editor servers, and sample code to verify X-WOPI-Proof in your own WOPI host.