You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 369961d
Browse filesBrowse the repository at this point in the historyBrowse files
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]>
Copy file name to clipboardExpand all lines: apps/hocuspocus.server/API.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -207,6 +207,8 @@ Two further fields are accepted **only** under the service-role key: `content` (
207
207
208
208
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.
209
209
210
+
A `docId` outside `[A-Za-z0-9_-]`, 1 to 100 characters, answers `400 VALIDATION_ERROR` with the house envelope.
211
+
210
212
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`.
211
213
212
214
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.
Copy file name to clipboardExpand all lines: apps/hocuspocus.server/CLAUDE.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -141,6 +141,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md). The `### Supab
141
141
- 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).
142
142
-**`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.
143
143
-**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.
144
145
-**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.
145
146
-`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.
146
147
-**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
160
161
-**`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).
161
162
-**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.
162
163
-**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.
163
165
- 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.
164
166
-`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.
165
167
- 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.
0 commit comments