Skip to content

fix(security): authenticate the OnlyOffice save callback - #2016

Merged
Asi0Flammeus merged 5 commits into
devfrom
fix/onlyoffice-callback
Oct 25, 2026
Merged

Asi0Flammeus merged 5 commits into
devfrom
fix/onlyoffice-callback

Conversation

@Asi0Flammeus

Copy link
Copy Markdown
Collaborator

Vulnerability

POST /api/translation-downloads/pptx-callback is the save callback of the OnlyOffice editor used by the translation tool. It had no authentication: the Document Server calls it without a session. It took the slide (courseId, language, partId, chapterId, slideId) from the query string and the document url from the body, then downloaded that document from the OnlyOffice host and wrote it to S3 under contribute/<courseId>/<language>/<partId>/<chapterId>/<slideId>/pptx/<name>-proofread.pptx.

  • Anyone could post a callback for any slide and overwrite its proofread presentation, with a document taken from the OnlyOffice cache.
  • partId was never checked, so the S3 key could be steered to another location under contribute/.

In docker/compose.yml, the Document Server also runs with ALLOW_PRIVATE_IP_ADDRESS=true and ALLOW_META_IP_ADDRESS=true, which lets anyone who can reach it make it fetch internal addresses of the Docker network and the cloud metadata address.

Fix

The callback is authenticated by a per-slide token issued by the API.

  • New route POST /api/translation-downloads/pptx-callback-token (logged-in users, same guard as the other editor routes). It checks that each id is a single path segment ([A-Za-z0-9_-]+), that the slide exists in content.course_translation_slides, and returns a random 256-bit token. The token is kept in memory for 24 hours with the slide it was issued for, like the existing pptx-token download tokens.
  • The editor (apps/contribute/.../onlyoffice-slide-editor.tsx) asks for a token before opening and gives OnlyOffice …/pptx-callback?token=<token> as callbackUrl.
  • pptx-callback answers 403 {"error":1} without a known, unexpired token. The slide and the S3 key come from the token only; query parameters are ignored. The callback still writes only the -proofread.pptx copy of that slide, never the original.
  • The callback no longer logs its query string (it now holds the token).
  • The cleanup timer of the token maps is unref()'d so it does not keep a process alive by itself.

OnlyOffice no longer fetches private or metadata addresses: ALLOW_PRIVATE_IP_ADDRESS=false and ALLOW_META_IP_ADDRESS=false in docker/compose.yml. The local development compose.yml keeps its settings, because it reaches the API through host.docker.internal.

OnlyOffice's own JWT (JWT_ENABLED) would be the stronger option, but it requires the API to sign the whole editor configuration server-side, which is a larger change of the editor flow. The token gives the callback a credential without that.

Tests

apps/api gets a mocha setup like shared and service-user (.mocharc.json + test script, pnpm --filter @blms/api test).

apps/api/src/routers/rest/translation-callback.test.ts (express on 127.0.0.1:0, fake database, fake S3, outbound fetch stubbed):

  • refuses a callback without a token, whatever its query says: on dev, 200 == 403 and the forged document is written to S3. Passes here (403, no S3 write, no fetch to OnlyOffice).
  • refuses a callback with an unknown token: on dev, 200 == 403. Passes here.
  • saves only the proofread copy of the slide the token was issued for (extra partId/courseId in the query are ignored): fails on dev (the token route does not exist), passes here.
  • refuses to issue a token for ids that are not single path segments: fails on dev (404 == 400), passes here.

turbo run type-check --filter=@blms/api --filter=@blms/contribute passes and biome check is clean on the changed files (one pre-existing useExhaustiveDependencies info in the editor).

Behaviour changes and deploy notes

  • No new environment variable.
  • Deploy the API and the contribute app together: an editor opened with the old front-end sends no token, so its saves are refused.
  • Tokens live in the API process memory. If the API restarts while someone is editing, that session's save is refused; reopening the slide gives a new token.
  • After deploying docker/compose.yml, the Document Server must reach the document and callback URLs through public addresses (they are built from the browser's host, which is public in production).

POST /api/translation-downloads/pptx-callback had no authentication and
took the slide from its query string: anyone could overwrite the
proofread presentation of any slide, and the unchecked partId steered
the S3 key.

The editor now asks the API for a per-slide token
(POST /api/translation-downloads/pptx-callback-token, ids restricted to
single path segments, slide checked in the database) and passes it in
the callback URL. The callback refuses requests without a known,
unexpired token and takes the slide from the token only.

The Document Server in docker/compose.yml no longer fetches private or
cloud metadata addresses.
The REST translation routes (uploads, start/retry, jobs, text-to-speech,
proofread copies, PNG generation, editor downloads, PNG viewer) only
required a session, so any student could delete and replace a course's
source files, start paid translations or generate audio with a
professor's voice. Upload and job routes now require the admin role,
the slide editing routes the contributor role (createExpressRoleMiddleware:
401 without session, 403 when the role is not enough).

The translated PNG viewer turned the client-supplied ppt_resource_path
into an S3 key, so any object of the bucket could be read. The key
builders now refuse anything but contribute/.../pptx/<name>.pptx.
fix(security): restrict translation tooling to contributors and admins
// the builders replace the first occurrence of each
pptResourcePath.indexOf('/pptx/') ===
pptResourcePath.lastIndexOf('/pptx/') &&
pptResourcePath.indexOf('.pptx') === pptResourcePath.length - 5;
@Asi0Flammeus
Asi0Flammeus merged commit 0437d31 into dev Oct 25, 2026
12 of 13 checks passed
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.

2 participants