From 175705ce805412939a385dc256ebd68b9cb0a686 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Thu, 6 Aug 2026 12:12:05 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(collaboration)=20reject?= =?UTF-8?q?=20admin=20jwts=20not=20issued=20for=20the=20yhub=20audience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yhub verified Django's RS256 admin JWT without checking "aud", so the y-converter token Django hands to the converter process was replayable here — and admin: true short-circuits getAccessType to "rw" on every document, plus the backend-internal reset-connections purpose and the X-User-Id attribution override. Require aud: "yhub", as y-provider already does for its own audience. Nothing in the backend calls yhub's admin endpoints yet, so no caller is affected. Signed-off-by: Kevin Jahns --- CHANGELOG.md | 9 ++++++--- src/yhub-server/server.js | 11 ++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55206086f..de365508a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -223,12 +223,15 @@ and this project adheres to so the Django backend can create documents without speaking yhub's lib0 wire encoding. Strict create (409 when the document already has content), initial content attributed to the optional `X-User-Id` header; guarded by - standard document write access (admin JWT or user session) + standard document write access (the `aud: "yhub"` admin JWT, or a user + session with update ability) - ✨(collaboration) add an admin reset-connections endpoint on yhub: `POST /collaboration/reset-connections/v1/docs/{id}` re-checks the authorization of the document's connected clients and disconnects (close - code 4401) only those whose access changed. Authenticated with the admin - JWT verified against the backend JWKS; not yet triggered by the backend on + code 4401) only those whose access changed. Authenticated with an admin JWT + verified against the backend JWKS and required to carry `aud: "yhub"`, so + an admin token Django issued for another service (e.g. the `y-converter` + one) cannot be replayed here; not yet triggered by the backend on permission changes (follow-up) - ⬆️(collaboration) upgrade yhub to 0.4.0 and serve all its routes under the `/collaboration/` prefix (`server.apiPrefix`): the websocket moves to diff --git a/src/yhub-server/server.js b/src/yhub-server/server.js index 79d2cca02..ac782202d 100644 --- a/src/yhub-server/server.js +++ b/src/yhub-server/server.js @@ -22,6 +22,12 @@ const allowedOrigins = ( ).split(','); const Y_PROVIDER_API_KEY = secret('Y_PROVIDER_API_KEY', 'yprovider-api-key'); const ORG = process.env.YHUB_ORG || 'docs'; +// Requiring this audience stops a valid admin JWT that Django issued for +// another service (today: the y-converter token in converter_services.py, +// which is handed to the converter process) from being replayed against yhub. +// Hardcoded, like y-provider's Y_CONVERTER_AUDIENCE: both ends of a two-party +// contract, so an env var would only add a way to misconfigure it into a 401. +const YHUB_AUDIENCE = 'yhub'; // lowercase only (no /i): Django serializes UUIDs lowercase, while yhub rooms // and S3 keys are case-sensitive strings — accepting case variants would let a // client open a parallel room for the same document (and, with soft migration, @@ -407,6 +413,7 @@ const auth = createAuthPlugin({ // silently dropped as a 401. const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'], + audience: YHUB_AUDIENCE, clockTolerance: 5, }); // admin tokens act as the "system" user (no per-user admin identities yet) @@ -414,7 +421,9 @@ const auth = createAuthPlugin({ ? { userid: 'system', admin: true } : null; } catch { - return null; // bad signature / expired / JWKS unreachable — fail closed + // bad signature / expired / wrong (or missing) audience / JWKS + // unreachable — fail closed + return null; } } if (gcOff) return null; // full-history connections: not for Docs users