@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/client-sync.js +11 -11
- package/out/graph.js +1 -1
- package/out/view.js +52 -49
- package/package.json +2 -2
- package/server/bus-receiver.ts +7 -7
- package/server/config.ts +4 -4
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +22 -26
- package/server/objstore/blob-fs.ts +6 -8
- package/server/objstore/blob-vercel.ts +56 -44
- package/server/objstore/blob.ts +23 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/reaper.ts +30 -10
- package/server/objstore/rest.ts +91 -75
- package/server/objstore/store-neon.ts +4 -4
- package/server/objstore/store.ts +92 -112
- package/server/objstore/tokens.ts +9 -12
- package/server/peer.ts +7 -9
- package/server/pubsub.ts +17 -27
- package/server/sign.ts +8 -10
- package/server/sse-server.ts +10 -12
- package/server/sse-session.ts +2 -8
- package/server/static.ts +8 -12
- package/server/sync-handlers.ts +32 -39
- package/server/util.ts +9 -0
- package/server/ws-server.ts +9 -14
|
@@ -112,16 +112,14 @@ function isValidPayload(v: unknown): v is TokenPayload {
|
|
|
112
112
|
const o = v as Record<string, unknown>
|
|
113
113
|
if (typeof o['tag'] !== 'string' || typeof o['res'] !== 'string') return false
|
|
114
114
|
// `Number.isSafeInteger` over `Number.isInteger`: `exp` is compared
|
|
115
|
-
// to `Date.now()` (
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// codebase's safe-int gates (server/objstore/sign.ts,
|
|
124
|
-
// server/objstore/rest.ts, server/objstore/handlers.ts).
|
|
115
|
+
// to `Date.now()` (a safe int), `len` against the Content-Length the
|
|
116
|
+
// REST layer parsed (itself `isSafeInteger`-gated), and `ver` against
|
|
117
|
+
// SQLite's INTEGER (capacity 2^63). An unsafe-but-integer value in the
|
|
118
|
+
// token round-trips through IEEE-754 and could spoof equality with a
|
|
119
|
+
// different actual value, or — for `exp` — claim a
|
|
120
|
+
// `Number.MAX_SAFE_INTEGER + 1` expiry that compares ambiguously near
|
|
121
|
+
// the IEEE-754 boundary. Matches the codebase's other safe-int gates
|
|
122
|
+
// (server/objstore/sign.ts, rest.ts, handlers.ts).
|
|
125
123
|
if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
|
|
126
124
|
if (o['op'] === 'put') {
|
|
127
125
|
return typeof o['sid'] === 'string'
|
|
@@ -138,8 +136,7 @@ function isValidPayload(v: unknown): v is TokenPayload {
|
|
|
138
136
|
return false
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
//
|
|
142
|
-
// the payload shape inline.
|
|
139
|
+
// Constructors so the WS handler doesn't assemble the payload inline.
|
|
143
140
|
export function mintPutToken(
|
|
144
141
|
secret: TokenSecret,
|
|
145
142
|
tag: string, res: string, sid: string, len: number,
|
package/server/peer.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// Per-connection server state. One `Peer` per accepted WebSocket
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// few cross-function call sites look it up via `peers.get(socket)`.
|
|
1
|
+
// Per-connection server state. One `Peer` per accepted WebSocket. The
|
|
2
|
+
// connection handler constructs one on accept and holds it in a
|
|
3
|
+
// closure, so the hot paths (message dispatch, pong, close) touch
|
|
4
|
+
// fields directly; the few cross-function call sites look it up via
|
|
5
|
+
// `peers.get(socket)`.
|
|
7
6
|
//
|
|
8
7
|
// Held in a `WeakMap<WebSocket, Peer>` so a closed socket's state GCs
|
|
9
8
|
// with the socket; the close handler also `delete`s it explicitly
|
|
@@ -17,9 +16,8 @@ export class Peer {
|
|
|
17
16
|
// `workspace-subscribe` signature — blocks cross-connection replay
|
|
18
17
|
// of a captured subscribe frame.
|
|
19
18
|
readonly challenge: string
|
|
20
|
-
// Password-gate
|
|
21
|
-
//
|
|
22
|
-
// bypasses the new-workspace gate. `false` until then.
|
|
19
|
+
// Password-gate flag. Once the `authenticate` handshake succeeds,
|
|
20
|
+
// first-actions on this socket bypass the new-workspace gate.
|
|
23
21
|
authorized = false
|
|
24
22
|
// Heartbeat liveness. The sweep flips it `false` after each `ping()`;
|
|
25
23
|
// the `pong` listener flips it back. A socket still `false` on the
|
package/server/pubsub.ts
CHANGED
|
@@ -169,9 +169,8 @@ type NeonState = {
|
|
|
169
169
|
pendingHandlers: Set<Promise<void>>
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
// Notification dispatch
|
|
173
|
-
//
|
|
174
|
-
// publish round-trip (Postgres NOTIFY delivers to publishers too).
|
|
172
|
+
// Notification dispatch. Filters foreign channels (defensive) and our
|
|
173
|
+
// own publish round-trip (Postgres NOTIFY delivers to publishers too).
|
|
175
174
|
function dispatchNotification(state: NeonState, n: { channel: string; payload?: string }): void {
|
|
176
175
|
if (n.channel !== CHANNEL) return
|
|
177
176
|
if (typeof n.payload !== 'string') return
|
|
@@ -187,24 +186,19 @@ function dispatchNotification(state: NeonState, n: { channel: string; payload?:
|
|
|
187
186
|
// notification dispatch (which would queue further notifications
|
|
188
187
|
// behind it). Errors are logged but don't kill the loop — a missed
|
|
189
188
|
// broadcast surfaces to clients on reconnect via the chain re-pull.
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// the DB handle. Without the tracking, `onBusMessage`'s DB queries
|
|
194
|
-
// (`handle.revisionById.get` / `getLive`) could race
|
|
195
|
-
// `handle.close()` and throw inside a half-settled handler.
|
|
189
|
+
// Tracked in `state.pendingHandlers` so `stop()` drains in-flight
|
|
190
|
+
// handlers before the lifecycle closes the DB handle (see that
|
|
191
|
+
// field's doc).
|
|
196
192
|
const promise: Promise<void> = fn(msg).catch((err) => {
|
|
197
193
|
console.warn('pubsub: handler error:', errStack(err))
|
|
198
194
|
}).finally(() => { state.pendingHandlers.delete(promise) })
|
|
199
195
|
state.pendingHandlers.add(promise)
|
|
200
196
|
}
|
|
201
197
|
|
|
202
|
-
// Single connect attempt. Resolves once LISTEN is registered,
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// must work mid-handshake, and `stop()` must be able to abort a hung
|
|
207
|
-
// connect by reading `state.client?.end()`.
|
|
198
|
+
// Single connect attempt. Resolves once LISTEN is registered, rejects
|
|
199
|
+
// on transport / LISTEN failure. Assigns `state.client = c` EAGERLY
|
|
200
|
+
// (before awaiting `c.connect()`) for the invariants on
|
|
201
|
+
// `NeonState.client`.
|
|
208
202
|
async function tryConnect(state: NeonState): Promise<void> {
|
|
209
203
|
const c = state.newClient()
|
|
210
204
|
state.client = c
|
|
@@ -221,13 +215,11 @@ async function tryConnect(state: NeonState): Promise<void> {
|
|
|
221
215
|
})
|
|
222
216
|
await c.query(`LISTEN ${CHANNEL}`)
|
|
223
217
|
} catch (err) {
|
|
224
|
-
// Clear `state.client` only if it still points at OUR client
|
|
218
|
+
// Clear `state.client` only if it still points at OUR client — a
|
|
225
219
|
// racing `stop()` may have already null'd it and ended the
|
|
226
|
-
// half-connected socket
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// call is a harmless no-op. The try/catch additionally absorbs
|
|
230
|
-
// any rejection from end() itself.
|
|
220
|
+
// half-connected socket; don't clobber that. The `c.end()` below
|
|
221
|
+
// may then be a redundant second call, but pg-style Client.end()
|
|
222
|
+
// is idempotent (and the try/catch absorbs any rejection).
|
|
231
223
|
if (state.client === c) state.client = null
|
|
232
224
|
try { await c.end() } catch {}
|
|
233
225
|
throw err
|
|
@@ -236,8 +228,7 @@ async function tryConnect(state: NeonState): Promise<void> {
|
|
|
236
228
|
|
|
237
229
|
// Outer (re)connect loop. Exits silently on `state.stopped`; otherwise
|
|
238
230
|
// retries with exponential backoff after a connect / LISTEN failure.
|
|
239
|
-
// `tryConnect`
|
|
240
|
-
// rationale on `NeonState.client`), so this loop only counts attempts
|
|
231
|
+
// `tryConnect` owns `state.client`, so this loop only counts attempts
|
|
241
232
|
// and runs the backoff sleep.
|
|
242
233
|
async function connectAndListen(state: NeonState): Promise<void> {
|
|
243
234
|
// oxlint-disable-next-line no-unmodified-loop-condition
|
|
@@ -262,10 +253,9 @@ async function connectAndListen(state: NeonState): Promise<void> {
|
|
|
262
253
|
// default) waiting on the timer. We stash the cancel callback on the
|
|
263
254
|
// shared state object so `stop()` can fire it; the loop's
|
|
264
255
|
// `if (state.stopped) return` then exits on the next turn. The
|
|
265
|
-
// `settled` flag guards
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// stricter and the guard documents the mutual exclusion explicitly).
|
|
256
|
+
// `settled` flag guards the timer-vs-`stop()` wake-up race: resolves
|
|
257
|
+
// are idempotent at runtime, but it also satisfies oxlint's stricter
|
|
258
|
+
// `no-multiple-resolved` and documents the mutual exclusion.
|
|
269
259
|
function cancellableSleep(state: NeonState, ms: number): Promise<void> {
|
|
270
260
|
return new Promise((resolve) => {
|
|
271
261
|
let settled = false
|
package/server/sign.ts
CHANGED
|
@@ -55,15 +55,6 @@ export type SubscribeMsg = {
|
|
|
55
55
|
// `Uint8Array<ArrayBuffer>` (not `<ArrayBufferLike>`) so the bytes
|
|
56
56
|
// thread directly into `crypto.subtle.digest` — `BufferSource`
|
|
57
57
|
// rejects SharedArrayBuffer-backed views.
|
|
58
|
-
//
|
|
59
|
-
// NOTE: `verifySaveSigAndCanonical` is a test-friendly composition
|
|
60
|
-
// helper. Production `handleSave` in `server/index.ts` does NOT
|
|
61
|
-
// call it — it composes `canonicalSave` +
|
|
62
|
-
// `computeRevisionIdFromCanonical` + `verifyEd25519` separately so
|
|
63
|
-
// the dup-precheck (`revisionExists`) can fire BEFORE the Ed25519
|
|
64
|
-
// verify, closing the round-9 H1 CPU-DoS vector where a passive
|
|
65
|
-
// observer floods captured saves. The wrapper exists for unit
|
|
66
|
-
// tests that don't need the precheck ordering.
|
|
67
58
|
export type VerifyResult =
|
|
68
59
|
| { ok: true; canonical: Uint8Array<ArrayBuffer> }
|
|
69
60
|
| { ok: false; canonical: null }
|
|
@@ -120,7 +111,14 @@ function canonicalSubscribe(
|
|
|
120
111
|
{ workspaceTag, from }: SubscribeMsg,
|
|
121
112
|
connectionNonce: string,
|
|
122
113
|
): Uint8Array<ArrayBuffer> {
|
|
123
|
-
|
|
114
|
+
// Strict `from` typing, mirroring canonicalSave's `base` check: `from`
|
|
115
|
+
// is `string | null`. A non-string non-null value would otherwise
|
|
116
|
+
// coerce via `String(...)` to canonical bytes the client could never
|
|
117
|
+
// reproduce (123 → "123", {} → "[object Object]"), so a signature
|
|
118
|
+
// computed over that coercion would verify against a malformed wire
|
|
119
|
+
// shape. verifySubscribeSig wraps this call in try/catch → clean drop.
|
|
120
|
+
if (from != null && typeof from !== 'string') throw new TypeError('canonicalSubscribe: from must be string or null')
|
|
121
|
+
const fromStr = from == null ? '' : from
|
|
124
122
|
return encodeUtf8([SUBSCRIBE_DOMAIN, workspaceTag as string, fromStr, connectionNonce].join('\n'))
|
|
125
123
|
}
|
|
126
124
|
|
package/server/sse-server.ts
CHANGED
|
@@ -168,9 +168,8 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
168
168
|
session.writeEvent('session', sid)
|
|
169
169
|
// Hand the session to the shared WS connection setup so it joins
|
|
170
170
|
// the same Peer / dispatcher / hub lifecycle as a real WebSocket.
|
|
171
|
-
// `setupPeerConnection`
|
|
172
|
-
//
|
|
173
|
-
// message channel.
|
|
171
|
+
// `setupPeerConnection`'s first action is the protocol `challenge`
|
|
172
|
+
// frame, on the default-named SSE channel.
|
|
174
173
|
setupPeerConnection(session as unknown as WebSocket, req, peerDeps)
|
|
175
174
|
return { sid, session }
|
|
176
175
|
}
|
|
@@ -329,15 +328,14 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
329
328
|
|
|
330
329
|
// Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
|
|
331
330
|
// (which decodes percent-escapes) — `randomId()` mints a 22-char
|
|
332
|
-
// base64url string
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// token. Returns null on missing / malformed.
|
|
331
|
+
// base64url string echoed back unchanged, so no escapes are possible
|
|
332
|
+
// on the legitimate path. The {1,64} bound is a deliberately lenient
|
|
333
|
+
// sanity gate: anything outside the base64url alphabet is rejected; an
|
|
334
|
+
// unrecognised sid just gets a fresh session from createSession (no
|
|
335
|
+
// failure mode), and the wide length window keeps a future
|
|
336
|
+
// randomId-length change from silently breaking old clients that
|
|
337
|
+
// round-trip a longer/shorter token. Returns null on missing /
|
|
338
|
+
// malformed.
|
|
341
339
|
function parseSidQuery(query: string | undefined): string | null {
|
|
342
340
|
if (typeof query !== 'string') return null
|
|
343
341
|
for (const part of query.split('&')) {
|
package/server/sse-session.ts
CHANGED
|
@@ -63,20 +63,14 @@ export class SseSession extends EventEmitter {
|
|
|
63
63
|
// failure on a healthy session.
|
|
64
64
|
private wireResponse(res: ServerResponse): void {
|
|
65
65
|
res.on('close', () => {
|
|
66
|
-
|
|
67
|
-
// swapped-out previous response closes naturally during takeover
|
|
68
|
-
// and must not knock the session offline.
|
|
69
|
-
if (res !== this.currentRes) return
|
|
66
|
+
if (res !== this.currentRes) return // current-response guard (see above)
|
|
70
67
|
if (this.readyState === SseSession.CLOSED) return
|
|
71
68
|
this.readyState = SseSession.CLOSED
|
|
72
69
|
this.currentRes = null
|
|
73
70
|
this.emit('close')
|
|
74
71
|
})
|
|
75
72
|
res.on('error', (err: Error) => {
|
|
76
|
-
|
|
77
|
-
// may emit RST/EPIPE during flush and we don't want those to
|
|
78
|
-
// pseudo-fail the healthy session that's now on a new response.
|
|
79
|
-
if (res !== this.currentRes) return
|
|
73
|
+
if (res !== this.currentRes) return // current-response guard (see above)
|
|
80
74
|
this.emit('error', err)
|
|
81
75
|
})
|
|
82
76
|
}
|
package/server/static.ts
CHANGED
|
@@ -169,11 +169,9 @@ function buildEntry(staticDir: string, name: string): StaticEntry {
|
|
|
169
169
|
const ext = extname(name)
|
|
170
170
|
const raw = readFileSync(join(staticDir, name))
|
|
171
171
|
const type = CONTENT_TYPE[ext] ?? 'application/octet-stream'
|
|
172
|
-
// HTML: lift `<link rel="(module)preload" …>` into a Link header
|
|
173
|
-
//
|
|
174
|
-
// the
|
|
175
|
-
// the stripped body — the on-disk file and the served body diverge
|
|
176
|
-
// by exactly the lifted tags.
|
|
172
|
+
// HTML: lift `<link rel="(module)preload" …>` into a Link header and
|
|
173
|
+
// drop the tags from the served body. ETag + compression run against
|
|
174
|
+
// the stripped body (see the header note on the divergence).
|
|
177
175
|
let identity = raw
|
|
178
176
|
let link: string | null = null
|
|
179
177
|
if (ext === '.html') {
|
|
@@ -273,13 +271,11 @@ function isUnsafeAttr(s: string): boolean {
|
|
|
273
271
|
return /[\r\n",;<>]/u.test(s)
|
|
274
272
|
}
|
|
275
273
|
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// defence against the build (or a hand edit) accidentally mentioning
|
|
282
|
-
// `<link rel="preload">` somewhere it isn't meant to fire.
|
|
274
|
+
// Char ranges in `html` whose contents are NOT real HTML content:
|
|
275
|
+
// comment bodies and script / noscript raw-text bodies. A `<link>`
|
|
276
|
+
// whose match offset falls in any range is preserved verbatim (no
|
|
277
|
+
// header lift — see the call site for why). Heuristic, not a real
|
|
278
|
+
// parser: the build emits clean, well-formed markup.
|
|
283
279
|
function findSkipRanges(html: string): Array<[number, number]> {
|
|
284
280
|
const ranges: Array<[number, number]> = []
|
|
285
281
|
const patterns = [
|
package/server/sync-handlers.ts
CHANGED
|
@@ -13,9 +13,8 @@ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, va
|
|
|
13
13
|
import { debugTag } from './util.ts'
|
|
14
14
|
import type { UnauthorizedContext } from './auth.ts'
|
|
15
15
|
|
|
16
|
-
// `chainForWire`
|
|
17
|
-
// `keyframe`
|
|
18
|
-
// with `keyframe` normalised to a strict boolean for the wire.
|
|
16
|
+
// Wire shape `chainForWire` produces from a `chainFrom` row, with
|
|
17
|
+
// `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
|
|
19
18
|
type WireRevision = {
|
|
20
19
|
base: string | null
|
|
21
20
|
id: string
|
|
@@ -88,14 +87,12 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
88
87
|
send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
// Normalise `keyframe` on outbound chain entries
|
|
90
|
+
// Normalise `keyframe` to a strict boolean on outbound chain entries.
|
|
92
91
|
// SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
|
|
93
|
-
// raw rows; the wire contract (and
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// fragile if a future client (or test harness) ever strict-compares.
|
|
98
|
-
// Convert once on the send side.
|
|
92
|
+
// raw rows; the wire contract (and canonical signing payload) uses
|
|
93
|
+
// strict `=== true`. Convert once on the send side — forwarding the
|
|
94
|
+
// integer relies on clients coercing via `Boolean()` before rebuilding
|
|
95
|
+
// canonical bytes, fragile against any client that strict-compares.
|
|
99
96
|
function chainForWire(revisions: RevisionRow[]): WireRevision[] {
|
|
100
97
|
return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
|
|
101
98
|
}
|
|
@@ -174,19 +171,14 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
174
171
|
sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
|
|
175
172
|
return
|
|
176
173
|
}
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// The legitimate client always sends an explicit
|
|
186
|
-
// `workspace-subscribe` (see `trySendSubscribe` in
|
|
187
|
-
// `client/triage-sync.js` — fires on key derivation, on socket
|
|
188
|
-
// open, on continuity-break recovery, on dismissError). The
|
|
189
|
-
// subscribe path remains the only way to attach as a subscriber.
|
|
174
|
+
// Save does NOT subscribe the sending socket — that would be a
|
|
175
|
+
// replay vector: a passive observer who captured one valid
|
|
176
|
+
// `workspace-save` frame could replay it from any TCP connection to
|
|
177
|
+
// attach as a subscriber and mirror every future encrypted
|
|
178
|
+
// broadcast, without holding the seed (the duplicate-id path returns
|
|
179
|
+
// ack-only, doesn't reject the socket). Explicit `workspace-subscribe`
|
|
180
|
+
// (signs the per-connection challenge nonce) is the ONLY attach path.
|
|
181
|
+
// Audit round-9 H1.
|
|
190
182
|
const baseNorm = msg.base ?? null
|
|
191
183
|
// `keyframe === true` is what canonicalSave bound the signature to
|
|
192
184
|
// (strict equality); the signer's intent is unambiguous here.
|
|
@@ -230,13 +222,10 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
230
222
|
base: baseNorm,
|
|
231
223
|
id,
|
|
232
224
|
})
|
|
233
|
-
// Carry `keyframe` as a strict boolean on the broadcast wire —
|
|
234
|
-
//
|
|
235
|
-
// contract).
|
|
236
|
-
//
|
|
237
|
-
// keyframe look like a regular delta on broadcast paths even though
|
|
238
|
-
// the chain-fetch path (chainFrom → SQLite integer) DID round-trip
|
|
239
|
-
// correctly.
|
|
225
|
+
// Carry `keyframe` as a strict boolean on the broadcast wire — peers
|
|
226
|
+
// strict-compare `=== true` (matching the canonical-payload
|
|
227
|
+
// contract). An integer 0/1 here would make a keyframe look like a
|
|
228
|
+
// regular delta on broadcast paths.
|
|
240
229
|
broadcast(tag, {
|
|
241
230
|
type: 'workspace-state',
|
|
242
231
|
workspaceTag: tag,
|
|
@@ -261,15 +250,19 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
261
250
|
}
|
|
262
251
|
|
|
263
252
|
async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
253
|
+
// Mirror handleSave's wire gate: length-cap + base64url-alphabet on
|
|
254
|
+
// workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
|
|
255
|
+
// encodes them and verifyEd25519 base64-decodes the signature.
|
|
256
|
+
// Without it a peer can submit up-to-maxPayload strings and force
|
|
257
|
+
// O(n) encode + decode per subscribe before the 32/64-byte length
|
|
258
|
+
// gates reject — a CPU-DoS surface handleSave is already hardened
|
|
259
|
+
// against. `from` keeps the `string | null` contract (null = full-
|
|
260
|
+
// chain catch-up); a legit signer's `from` is a base64url revision
|
|
261
|
+
// id, so any non-string / over-long / wrong-alphabet value can't be
|
|
262
|
+
// one the signature was computed over, and the chain-lookup path
|
|
263
|
+
// (`typeof msg.from === 'string' ? msg.from : null`) still treats a
|
|
264
|
+
// null / absent `from` as the keyframe-fallback.
|
|
265
|
+
if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
|
|
273
266
|
// The challenge nonce we issued on this socket is bound into the
|
|
274
267
|
// signed canonical, blocking cross-connection replay of a captured
|
|
275
268
|
// subscribe frame. A subscribe arriving before we sent the
|
package/server/util.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { randomBytes } from 'node:crypto'
|
|
|
8
8
|
// is an Ed25519 public key; operator logs shouldn't carry it verbatim.
|
|
9
9
|
export function debugTag(s: string): string { return `${s.slice(0, 12)}…` }
|
|
10
10
|
|
|
11
|
+
// Truncated view of a content hash / staging id for logs (objstore GC +
|
|
12
|
+
// 503 diagnostics). Same 12-char prefix convention as `debugTag`; named
|
|
13
|
+
// separately so call sites read as "this is a blob id, not a workspace
|
|
14
|
+
// tag". Tolerates a non-string (logs a placeholder) so a diagnostic
|
|
15
|
+
// path can't itself throw on bad input.
|
|
16
|
+
export function debugId(s: unknown): string {
|
|
17
|
+
return typeof s === 'string' ? `${s.slice(0, 12)}…` : '<no-id>'
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
// 16 random bytes → 22 base64url chars (no padding). The shared shape
|
|
12
21
|
// for per-socket challenge nonces and staging ids — collision is
|
|
13
22
|
// 1/2^128. `isValidStagingId` in objstore/store.ts validates exactly
|
package/server/ws-server.ts
CHANGED
|
@@ -55,23 +55,19 @@ export type WsServerDeps = PeerConnectionDeps & {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// Per-connection setup: create Peer, send challenge, wire message /
|
|
58
|
-
// close / error / pong listeners.
|
|
59
|
-
//
|
|
60
|
-
// `
|
|
61
|
-
// `WebSocket`, or an `SseSession` cast through `as unknown as
|
|
62
|
-
// WebSocket`); the function only uses the subset both expose.
|
|
58
|
+
// close / error / pong listeners. `socket` is the WebSocket-shaped
|
|
59
|
+
// surface — a real `ws` `WebSocket`, or an `SseSession` cast through
|
|
60
|
+
// `as unknown as WebSocket`; only the subset both expose is used.
|
|
63
61
|
export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
|
|
64
62
|
const {
|
|
65
63
|
peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
|
|
66
64
|
sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
|
|
67
65
|
} = deps
|
|
68
66
|
if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
|
|
69
|
-
// One Peer holds this connection's state
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// flips it back, and a socket still false on the next sweep is
|
|
74
|
-
// terminated — the only thing closing FDs for an idle peer.
|
|
67
|
+
// One Peer holds this connection's state, created before any client
|
|
68
|
+
// frame can arrive (`socket.on('message')` is wired below). The
|
|
69
|
+
// heartbeat sweep's two-tick liveness check (see below) is the only
|
|
70
|
+
// thing closing FDs for an otherwise-idle peer.
|
|
75
71
|
const peer = new Peer(randomId())
|
|
76
72
|
peers.set(socket, peer)
|
|
77
73
|
socket.on('pong', () => { peer.alive = true })
|
|
@@ -228,9 +224,8 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
|
|
|
228
224
|
// Surface socket-level errors instead of swallowing — these are
|
|
229
225
|
// the signals operators want under abuse / network flakiness
|
|
230
226
|
// (TLS handshake failures, frame-decode errors, ws-protocol
|
|
231
|
-
// violations).
|
|
232
|
-
//
|
|
233
|
-
// unsubscribe cleanup, so logging here doesn't risk leaking.
|
|
227
|
+
// violations). `close` fires after `error` and runs the unsubscribe
|
|
228
|
+
// cleanup, so logging here doesn't risk leaking.
|
|
234
229
|
socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
|
|
235
230
|
}
|
|
236
231
|
|