Skip to content

Commit 369961d

Browse files
HMarzbanclaude
andcommitted
fix(hocuspocus): refuse bad document ids and close sockets at token exp
A local media purge joined the document id onto the storage root. An id such as `a/../victim`, `..` or `.` could therefore delete another document's media, or a folder outside the root. `deleteByPrefix` now refuses an id that is not one path segment. It logs a warning and deletes nothing. The WebSocket room name and `PUT /api/documents/:docId` now refuse an id outside `[A-Za-z0-9_-]`, 1 to 100 characters. They use the same rule as the upload route, so no new row can hold such an id. The WebSocket refusal counts as `ws_auth_rejections_total{reason="invalid-document-id"}`. A signed-in WebSocket kept its identity after its access token expired. The new `TokenExpiryExtension` closes the socket with code 4408 at the token's `exp`. The webapp already treats 4408 as self-healing, so it reconnects with a fresh token, and `onAuthenticate` checks the identity again. `onAuthenticate` refuses a token already past `exp`, because the verify cache can return one, and the reconnect would then loop. A new unit test covers the purge guard. With the guard removed, the test fails. Refs #426 Refs #430 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 6b41856 commit 369961d

11 files changed

Lines changed: 141 additions & 15 deletions

File tree

‎apps/hocuspocus.server/API.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ Two further fields are accepted **only** under the service-role key: `content` (
207207

208208
Upsert document metadata by `documentId`. All fields optional: `title`, `description`, `keywords` (`string[]`), `readOnly` (`boolean`), `isPrivate` (`boolean`), `slug` (`string`). A stored `title` is plain text. The schema strips HTML tags. `slug` sets the row's slug only when this call creates the row. It is ignored on every update and never renames a live document.
209209

210+
A `docId` outside `[A-Za-z0-9_-]`, 1 to 100 characters, answers `400 VALIDATION_ERROR` with the house envelope.
211+
210212
Access follows ownership. An **owned** document accepts writes only from its owner; every other caller gets `403`, private or not. An **ownerless** document is open: anyone, signed in or not, may set `title` / `description` / `keywords`. But its locks cannot move, because a document with no owner has nobody to be private for. Those changes are ignored and logged. Creating the row through this route makes a signed-in caller its owner, so they may set the locks in the same request. A soft-deleted document is `404`.
211213

212214
A signed-in rename that changes Pad title posts a workspace-chat notice after the write. The PUT does not wait for that call. A missing RPC, a signed-out rename, a create, an unchanged title, or an empty title posts nothing. The Prisma write still succeeds.

‎apps/hocuspocus.server/CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,20 @@ SMTP_PASS=...
577577
deploy this server, and run `scripts/backfill-document-access.ts` (a dry run
578578
first, then `--apply`). Until the backfill runs, Private documents' chat
579579
stays open ([#396](https://github.com/docs-plus/docs.plus/issues/396)).
580+
- **A local media purge refuses an id that is not one path segment.** On a
581+
server with `PERSIST_TO_LOCAL_STORAGE=true`, a crafted document id could
582+
reach another document's media folder, or a folder outside the storage root.
583+
The purge now logs a warning and deletes nothing for such an id. The
584+
WebSocket room name and `PUT /api/documents/:docId` now refuse an id outside
585+
`[A-Za-z0-9_-]`, 1 to 100 characters. The PUT answers `400`, and the
586+
WebSocket counts `ws_auth_rejections_total{reason="invalid-document-id"}`
587+
([#426](https://github.com/docs-plus/docs.plus/issues/426)).
588+
- **A signed-in WebSocket closes when its access token expires.** At the
589+
token's `exp`, the server closes the socket with code `4408`. The webapp
590+
reconnects with a fresh token, so `onAuthenticate` checks the identity again.
591+
A token already past `exp` is refused like an invalid one. Each signed-in tab
592+
reconnects about once per token lifetime, which is 1 hour by default
593+
([#430](https://github.com/docs-plus/docs.plus/issues/430)).
580594

581595
### Removed
582596

‎apps/hocuspocus.server/CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md). The `### Supab
141141
- Compose gives shell env vars precedence over `--env-file`, and a running container keeps its baked value until recreated. A corrected env therefore only takes effect on `up -d --force-recreate rest-api` (or the next deploy).
142142
- **`config` is frozen at first import, so a script cannot force an env flag from its own module scope.** `src/config/env.ts` validates and freezes on evaluation, and ES modules evaluate every `import` before any top-level statement. A `process.env.X = '…'` line in a script therefore lands _after_ every module that read `config` has already decided. `scripts/e2e-duplicate-media.ts` forced `PERSIST_TO_LOCAL_STORAGE=true` that way to stop its purge deleting from the live bucket; when the storage pick moved from a call-time `process.env` read to `config`, the guard silently became inert while the comment still claimed it worked. Measured 2026-09-02: with the env saying `false` and the script assigning `true`, `getMediaStore()` still returned `s3`. **Force such a flag in the `package.json` script instead**, where it lands before the process starts. Anything reading `process.env` directly at call time is the exception that hid this for as long as it did.
143143
- **One adapter answers for the media backend** (`src/lib/storage/mediaStore.ts`). `getMediaStore()` picks local or S3 once from `config.storage` and exposes `get`, `upload`, `copyObject` and `deleteByPrefix`. Before it, four functions each re-read the environment and disagreed about an unconfigured S3 — one proceeded, one warned, two threw. Do not reintroduce a per-function pick, and do not read `PERSIST_TO_LOCAL_STORAGE` or `DO_STORAGE_ENDPOINT` outside that file.
144+
- **Local `deleteByPrefix` refuses an id that is not one path segment** (`src/lib/storage/storage.local.ts`, #426). A containment check alone passes `a/../<id>`, which names another document's folder. The refusal warns and returns, because the purge RPC has already run. Keep this check in the adapter, never in `media.service.ts` or the purge service. `onAuthenticate` and `PUT /api/documents/:docId` refuse such ids too, through `documentIdField` (`src/schemas/hypermultimedia.schema.ts`). The upload route enforces the same rule, so all three agree on which ids can own media. Do not narrow it to the 19-character `documentIdSchema`, because nobody has counted the stored ids that rule would lock out.
144145
- **The media Trash gate lives in two places, never in `lib/storage/*` (#408).** `getMedia` (`src/api/controllers/documents.controller.ts`) gates the public route. The adapters only move bytes. New S3 objects are private, so every public read goes through the route. The duplicate copy reads with credentials, so `duplicateDocument` filters Trash itself, before the media cap. Both read the document that the URL prefix names. A missing metadata row still serves: a purge already deleted its objects, and older media sits under ids with no row.
145146
- `media.service.ts` `resolveMediaMaxFileSize` **floors any value under 1 MB** (`MIN_PLAUSIBLE_MEDIA_MAX_FILE_SIZE`) to the 10 MB default with a startup warn. A mis-set env can no longer brick uploads. `env.schema.ts` still coerces `0`/invalid as the outer layer.
146147
- **The Database `store()` hook never decodes the snapshot.** It reads `isDraft`/`commitMessage` O(1) from the live `data.document` metadata map; the Yjs decode + metadata strip (`stripSnapshotMetadata`) runs in the worker. Do not reintroduce `Y.applyUpdate`/`Y.encodeStateAsUpdate` on the WS store path — it blocks the event loop for every connection (the 2026-07-08 persist-p99 rework).
@@ -160,6 +161,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md). The `### Supab
160161
- **`get_cron_job_health` is a single-pass aggregate** (`scripts/16-cron-jobs.sql`) and the worker samples it every 180 s (`CRON_HEALTH_SAMPLE_EVERY_TICKS` in `workerMetricsSampler.ts`), not on the 15 s queue tick. Do not go past 180 s: the >15m stale alert covers a */10 job. Do not revert to per-job correlated subqueries or per-tick sampling — 26 seq scans of `cron.job_run_details` per call every 15 s was prod's #1 self-inflicted DB load (2026-07-17 audit).
161162
- **BullMQ custom jobIds are colon-free, always.** `':'` is BullMQ's Redis key separator and `validateOptions` throws on it. This silently disabled the entire store queue once bullmq ≥5.79 landed: every save fell back to the inline WS-loop write. `buildStoreJobId` in `lib/queue.ts` owns the store format; e2e must use it, never a hand-rolled id.
162163
- **Hocuspocus v3 `onAuthenticate` payload field is `connectionConfig`** (the v2 `connection` was renamed; destructuring `connection` is silently `undefined`). Server-side readOnly enforcement sets `connectionConfig.readOnly = true`; keep the payload typed as `onAuthenticatePayload`, never `any`.
164+
- **A signed-in socket closes with `4408` at its token `exp`** (`TokenExpiryExtension`, `src/extensions/token-expiry.extension.ts`, #430). It closes the socket, never `connection.close()`. That call sends a document Close, and the provider then keeps the socket open and never reconnects. The reconnect re-runs `onAuthenticate`. A delete or a sign-out elsewhere should then take effect within one token lifetime plus the 60 s verify cache. A ban can take close to two lifetimes, because a tab may hold a fresh token minted just before the ban. Neither case is verified on the local stack yet. `onAuthenticate` refuses a token already past `exp`, because the 60 s verify cache can return one, and admitting it would loop the reconnect. The timer rides `context`, and `onDisconnect` clears it. A `DirectConnection` never fires `connected`, so it gets no timer. If Supabase Auth is down at `exp`, the reconnect degrades to anonymous, and a Private document's owner is denied until Auth recovers. That trade is accepted.
163165
- Both version-append paths (the worker INSERT and the queue-down inline fallback in `store()`) merge the snapshot with the locked head row (`Y.mergeUpdates`). An out-of-order commit or a divergent concurrent fallback can therefore never clobber newer edits into the latest version. Keep them in lockstep — do not let one merge and the other plain-insert.
164166
- `stopOnSignals: false` stays in the Hocuspocus config — the app's `shutdown()` owns SIGTERM ordering; the library's own handler `process.exit(0)`s before the queue/DB/observability cleanup tail.
165167
- The `ENABLE_SCHEMA_MIGRATION` fetch path must persist the rebuilt snapshot (`persistMigratedSnapshot`, FOR UPDATE + advance/P2002-aware). Serving an unpersisted rebuild lets IndexedDB-cached clients merge two independent CRDTs and duplicate the document.

‎apps/hocuspocus.server/src/api/routers/documents.router.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { houseEnvelopeHook } from '../../http/envelope'
55
import { contentBodyLimit } from '../../modules/document-content'
66
import {
77
createDocumentSchema,
8+
docIdParamSchema,
89
documentQuerySchema,
910
setDocumentFavoriteSchema,
1011
trashPurgeSchema,
@@ -57,6 +58,7 @@ export const createDocumentsRouter = (deps: DocumentsRouterDeps): Hono => {
5758
documents.put(
5859
'/:docId',
5960
deps.optionalUser,
61+
zValidator('param', docIdParamSchema, houseEnvelopeHook),
6062
zValidator('json', updateDocumentMetadataSchema, houseEnvelopeHook),
6163
controller.updateDocument
6264
)

‎apps/hocuspocus.server/src/config/hocuspocus.config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { buildHealthReport, healthRoutes, redisSyncHealth } from '../lib/health'
2121
import { RedisSubscriberExtension } from '../extensions/redis-subscriber.extension'
2222
import { DocumentOccupancyExtension } from '../extensions/document-occupancy.extension'
2323
import { DocumentViewsExtension } from '../extensions/document-views.extension'
24+
import { TokenExpiryExtension } from '../extensions/token-expiry.extension'
2425
import { checkDatabaseHealth, prisma } from '../lib/prisma'
2526
import { captureDegraded, captureUnknown } from '../lib/instrument'
2627
import { dbLogger } from '../lib/logger'
@@ -376,6 +377,8 @@ const configureExtensions = (): { extensions: Extension[]; redisWired: boolean }
376377
extensions.push(new RedisSubscriberExtension())
377378
}
378379

380+
extensions.push(new TokenExpiryExtension())
381+
379382
if (config.supabase.url && config.supabase.serviceRoleKey) {
380383
extensions.push(new DocumentViewsExtension())
381384
dbLogger.info('Document view tracking enabled')
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { connectedPayload, Extension, onDisconnectPayload } from '@hocuspocus/server'
2+
3+
/** Hocuspocus's ConnectionTimeout code, which the webapp already treats as self-healing. */
4+
const TOKEN_EXPIRED_CLOSE_CODE = 4408
5+
6+
// Guards an issuer whose token lifetime exceeds the setTimeout maximum. A longer delay
7+
// overflows, fires at once and loops the reconnect. The Supabase CLI caps it at one week.
8+
const MAX_TIMER_MS = 2 ** 31 - 1
9+
10+
interface TokenExpiryContext {
11+
tokenExp?: number
12+
tokenExpiryTimer?: ReturnType<typeof setTimeout>
13+
}
14+
15+
export class TokenExpiryExtension implements Extension {
16+
// Close the socket, never connection.close(): that sends a document Close, and the
17+
// provider then keeps the socket open and never reconnects. A socket close makes it
18+
// reconnect with a fresh token, so onAuthenticate decides the identity again.
19+
async connected({ context, connection }: connectedPayload) {
20+
const ctx = context as TokenExpiryContext
21+
if (typeof ctx.tokenExp !== 'number') return
22+
23+
const delay = Math.min(Math.max(0, ctx.tokenExp * 1000 - Date.now()), MAX_TIMER_MS)
24+
ctx.tokenExpiryTimer = setTimeout(() => {
25+
connection.webSocket.close(TOKEN_EXPIRED_CLOSE_CODE, 'Access token expired')
26+
}, delay)
27+
}
28+
29+
async onDisconnect({ context }: onDisconnectPayload) {
30+
clearTimeout((context as TokenExpiryContext).tokenExpiryTimer)
31+
}
32+
}
33+
34+
export default TokenExpiryExtension

‎apps/hocuspocus.server/src/hocuspocus.server.ts‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { type SupabaseUser, verifyServiceRole, verifySupabaseTokenOutcome } from
2020
import { countActiveConnections } from './lib/health'
2121
import { handleHistoryStateless } from './lib/history-stateless'
2222
import { captureUnknown, flushObservability } from './lib/instrument'
23-
import { isConnectedAppToken } from './lib/jwtClaims'
23+
import { decodeJwtClaims, isConnectedAppToken } from './lib/jwtClaims'
2424
import { wsLogger } from './lib/logger'
2525
import {
2626
documentLoadDuration,
@@ -49,6 +49,7 @@ import * as documentContent from './modules/document-content'
4949
import type { RevertOutcome, VersionFailureReason, VersionOps } from './modules/document-versions'
5050
import * as documentVersions from './modules/document-versions'
5151
import { MAX_VERSION_NUMBER } from './modules/document-versions/types'
52+
import { documentIdField } from './schemas/hypermultimedia.schema'
5253
import type { HistoryPayload } from './types/document.types'
5354
import type { StoreDocumentContext } from './types/queue.types'
5455

@@ -462,16 +463,29 @@ const serverConfig = {
462463
},
463464

464465
async onAuthenticate({ token, documentName, connectionConfig }: onAuthenticatePayload) {
466+
// The first edit writes a row under this name, and a purge later deletes media by it.
467+
if (!documentIdField.safeParse(documentName).success) {
468+
wsAuthRejectionsTotal.inc({ reason: 'invalid-document-id' })
469+
throw new Error('Invalid document ID')
470+
}
471+
465472
// The room id is the bare documentId. Resolve the user first, then deny
466473
// anonymous access to admin-set private documents at one choke point.
467474
const isProd = process.env.NODE_ENV === 'production'
468475
// Expired/invalid Supabase tokens on (re)connect are expected and high-volume:
469476
// reject them so the client refreshes, but don't capture them as errors.
470-
const INVALID_TOKEN_MESSAGE = 'Invalid authentication token'
477+
// Outside production the caller falls back to anonymous instead.
478+
const refuseInvalidToken = (): void => {
479+
if (!isProd) return
480+
wsLogger.info({ documentName }, 'Rejecting invalid/expired token')
481+
wsAuthRejectionsTotal.inc({ reason: 'invalid-token' })
482+
throw new Error('Invalid authentication token')
483+
}
471484

472485
let user: SupabaseUser | null = null
473486
let slug = ''
474487
let deviceType = 'desktop'
488+
let tokenExp: number | undefined
475489

476490
if (!token) {
477491
wsLogger.debug({ documentName }, 'No token provided - anonymous')
@@ -494,15 +508,26 @@ const serverConfig = {
494508
if (tokenData.accessToken) {
495509
const outcome = await verifySupabaseTokenOutcome(tokenData.accessToken)
496510
switch (outcome.kind) {
497-
case 'user':
511+
case 'user': {
498512
// In every environment: the dev fallback below would admit it as anonymous.
499513
if (isConnectedAppToken(tokenData.accessToken)) {
500514
wsAuthRejectionsTotal.inc({ reason: 'connected-app' })
501515
throw new Error('Connected apps cannot open documents over WebSocket')
502516
}
517+
// The verify cache can return a token past its exp. Admitting it would arm
518+
// a zero-delay expiry close, and the tab would reconnect in a loop.
519+
const claimedExp = decodeJwtClaims(tokenData.accessToken)?.exp
520+
const exp = typeof claimedExp === 'number' ? claimedExp : undefined
521+
if (exp !== undefined && exp * 1000 <= Date.now()) {
522+
refuseInvalidToken()
523+
wsLogger.warn({ documentName }, 'Token already expired - allowing in dev')
524+
break
525+
}
503526
user = outcome.user
527+
tokenExp = exp
504528
wsLogger.debug({ userId: user.sub, documentName }, 'Token verified')
505529
break
530+
}
506531
case 'unavailable':
507532
// Keep slug/deviceType — only identity degrades to anonymous so a
508533
// transient auth outage cannot mint the wrong first-save slug.
@@ -511,11 +536,7 @@ const serverConfig = {
511536
break
512537
case 'invalid':
513538
user = null
514-
if (isProd) {
515-
wsLogger.info({ documentName }, 'Rejecting invalid/expired token')
516-
wsAuthRejectionsTotal.inc({ reason: 'invalid-token' })
517-
throw new Error(INVALID_TOKEN_MESSAGE)
518-
}
539+
refuseInvalidToken()
519540
wsLogger.warn({ documentName }, 'Token verification failed - allowing in dev')
520541
break
521542
default: {
@@ -588,7 +609,7 @@ const serverConfig = {
588609
connectionConfig.readOnly = true
589610
}
590611

591-
return { user, slug, documentId: documentName, deviceType }
612+
return { user, slug, documentId: documentName, deviceType, tokenExp }
592613
}
593614
}
594615

‎apps/hocuspocus.server/src/lib/storage/storage.local.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,20 @@ export const copyObject = async (
129129
return true
130130
}
131131

132-
// `force` makes a missing dir a no-op (the reaper retries); the falsy guard stops
133-
// an empty id from nuking the whole hypermultimedia tree.
132+
// A containment check is not enough: `a/../b` stays inside the root and names another
133+
// document. So the id must be exactly one segment. A refusal returns, because the purge
134+
// RPC has already run, and upload's id regex means such an id owns no media.
134135
export const deleteByPrefix = async (documentId: string): Promise<void> => {
135-
if (!documentId) return
136-
const dirPath = path.join(storageRoot(), documentId)
136+
const root = storageRoot()
137+
const dirPath = path.resolve(root, documentId)
138+
if (!documentId || path.dirname(dirPath) !== root || path.basename(dirPath) !== documentId) {
139+
storageLocalLogger.warn(
140+
{ documentId },
141+
'Refusing a media purge for an id that is not one path segment'
142+
)
143+
return
144+
}
145+
// `force` makes a missing dir a no-op, so a reaper retry is safe.
137146
await rm(dirPath, { recursive: true, force: true })
138147
storageLocalLogger.info(
139148
{ documentId, dirPath },

‎apps/hocuspocus.server/src/schemas/document.schema.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from 'zod'
33
// Deep path on purpose: the module's `http/schema` is zod-only, so importing it
44
// here never drags Tiptap or the transformer into this file's consumers.
55
import { tiptapDocSchema } from '../modules/document-content/http/schema'
6+
import { documentIdField } from './hypermultimedia.schema'
67

78
const plainTitle = (title: string): string => title.replace(/<[^>]*>/g, '')
89

@@ -16,6 +17,8 @@ export const createDocumentSchema = z.object({
1617
ownerId: z.string().optional()
1718
})
1819

20+
export const docIdParamSchema = z.object({ docId: documentIdField })
21+
1922
export const updateDocumentMetadataSchema = z.object({
2023
title: z.string().transform(plainTitle).optional(),
2124
description: z.string().optional(),

‎apps/hocuspocus.server/src/schemas/hypermultimedia.schema.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { z } from 'zod'
22

33
// Strict charset blocks path-separator / traversal sequences before the id
44
// ever reaches the filesystem (defense-in-depth with the storage-root check).
5-
const documentIdField = z
5+
export const documentIdField = z
66
.string()
77
.min(1, 'Document ID is required')
88
.max(100)

0 commit comments

Comments
 (0)