Skip to content

Commit ca06fd2

Browse files
committed
fix: clean up deps / architechture
1 parent 4f9675c commit ca06fd2

6 files changed

Lines changed: 215 additions & 36 deletions

File tree

packages/core/src/api/http.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,18 +151,37 @@ export function createHttpRouter(opts: CreateHttpRouterOptions): Router {
151151
const iterable = isAsyncIterable(result) ? result : await result
152152
setResponseHeader(event, 'Content-Type', 'application/x-ndjson')
153153
const res = event.node.res
154-
try {
155-
if (isAsyncIterable(iterable)) {
156-
for await (const chunk of iterable)
157-
res.write(`${JSON.stringify(chunk)}\n`)
154+
const req = event.node.req
155+
if (isAsyncIterable(iterable)) {
156+
// Manual iteration so a client disconnect closes the iterator (its
157+
// `return()` unsubscribes and unblocks a pending next()) — a `for
158+
// await` alone would keep pulling live events and hold the slot open
159+
// for the whole scan after the client is gone.
160+
const iterator = iterable[Symbol.asyncIterator]()
161+
const onClose = () => { void iterator.return?.() }
162+
req.on('close', onClose)
163+
try {
164+
while (true) {
165+
const { value, done } = await iterator.next()
166+
if (done)
167+
break
168+
res.write(`${JSON.stringify(value)}\n`)
169+
}
158170
}
159-
else {
160-
// Handler resolved a single value instead of an iterable — emit one line.
161-
res.write(`${JSON.stringify(iterable)}\n`)
171+
finally {
172+
req.off('close', onClose)
173+
await iterator.return?.()
174+
res.end()
162175
}
163176
}
164-
finally {
165-
res.end()
177+
else {
178+
// Handler resolved a single value instead of an iterable — emit one line.
179+
try {
180+
res.write(`${JSON.stringify(iterable)}\n`)
181+
}
182+
finally {
183+
res.end()
184+
}
166185
}
167186
return
168187
}

packages/core/src/core.ts

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,15 @@ function createSession(deps: SessionDeps): CrawlSession {
223223

224224
// ── HookEvent fan-out ──────────────────────────────────────────────────
225225
//
226-
// Internal queue + iterator that resolves either a buffered event or the
227-
// next emit. Keeping a single queue means hook subscribers and iter
228-
// consumers stay in sync: every stable event is pushed exactly once.
229-
const queue: HookEvent[] = []
230-
let resolveNext: ((v: IteratorResult<HookEvent>) => void) | null = null
231-
let iterDone = false
226+
// Every stable event fans out to all registered `handlers` (multicast). Both
227+
// WS broadcast and each `events` async-iterator consumer register here, so a
228+
// second consumer (a second dashboard tab, a CLI tail) sees the full stream
229+
// instead of stealing events from the first — the iterator is per-consumer,
230+
// not a single shared queue.
232231
const handlers = new Set<(event: HookEvent) => void>()
232+
// Live `events` iterators, closed when the scan ends so their `for await`
233+
// loops terminate.
234+
const iterClosers = new Set<() => void>()
233235

234236
// In-memory ring buffer (cap 10k) for `events.subscribe.replay`.
235237
const RING_CAP = 10_000
@@ -248,39 +250,64 @@ function createSession(deps: SessionDeps): CrawlSession {
248250
logOperationalWarn('core.scan_event_subscriber_failed', err, { scanId }, deps.logger)
249251
}
250252
}
251-
if (iterDone)
252-
return
253-
if (resolveNext) {
254-
const r = resolveNext
255-
resolveNext = null
256-
r({ value: event, done: false })
257-
}
258-
else {
259-
queue.push(event)
260-
}
261253
}
262254

263255
function closeIter(): void {
264-
iterDone = true
265-
if (resolveNext) {
266-
const r = resolveNext
267-
resolveNext = null
268-
r({ value: undefined, done: true })
269-
}
256+
for (const close of [...iterClosers])
257+
close()
270258
}
271259

272260
const events: AsyncIterable<HookEvent> = {
273-
[Symbol.asyncIterator]() {
261+
[Symbol.asyncIterator](): AsyncIterator<HookEvent> {
262+
// Per-consumer buffer + waiter, fed by its own subscription. Cleaned up
263+
// on scan end (iterClosers) or when the consumer stops (`return()` — the
264+
// HTTP layer calls it on client disconnect so an abandoned tail unsubs
265+
// and stops holding a slot).
266+
const localQueue: HookEvent[] = []
267+
let localResolve: ((v: IteratorResult<HookEvent>) => void) | null = null
268+
let done = false
269+
270+
const unsub = subscribe((event) => {
271+
if (done)
272+
return
273+
if (localResolve) {
274+
const r = localResolve
275+
localResolve = null
276+
r({ value: event, done: false })
277+
}
278+
else {
279+
localQueue.push(event)
280+
}
281+
})
282+
283+
function finish(): void {
284+
if (done)
285+
return
286+
done = true
287+
unsub()
288+
iterClosers.delete(finish)
289+
if (localResolve) {
290+
const r = localResolve
291+
localResolve = null
292+
r({ value: undefined, done: true })
293+
}
294+
}
295+
iterClosers.add(finish)
296+
274297
return {
275298
next(): Promise<IteratorResult<HookEvent>> {
276-
if (queue.length)
277-
return Promise.resolve({ value: queue.shift()!, done: false })
278-
if (iterDone)
299+
if (localQueue.length)
300+
return Promise.resolve({ value: localQueue.shift()!, done: false })
301+
if (done)
279302
return Promise.resolve({ value: undefined, done: true })
280303
return new Promise((r) => {
281-
resolveNext = r
304+
localResolve = r
282305
})
283306
},
307+
return(): Promise<IteratorResult<HookEvent>> {
308+
finish()
309+
return Promise.resolve({ value: undefined, done: true })
310+
},
284311
}
285312
},
286313
}

packages/unlighthouse/src/host.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { initStorage } from './cli/storage-init'
3737
import { resolveConfig } from './config/resolve'
3838
import { historySubscriber } from './data/history/tracking'
3939
import { mountServer } from './server'
40+
import { checkWsUpgrade, isExposedHost, normaliseOrigin } from './server-guards'
4041
import { createServerHooks } from './server-hooks'
4142
import { computeConfigCacheKey, normaliseHost } from './util'
4243

@@ -470,7 +471,33 @@ export async function createUnlighthouseHost(opts: CreateUnlighthouseHostOptions
470471
await mountServer(mountDeps, app, { handlerCtx })
471472

472473
if (ws) {
474+
// The WS handshake arrives as a Node `'upgrade'` event on the raw server,
475+
// so it bypasses the h3 pipeline and its origin gate entirely. Apply the
476+
// same D-043 gate here: restrict to the `/api/ws` path and run the
477+
// Origin/Host check, so a cross-origin page cannot open the scan-event
478+
// stream and a rebinding Host cannot reach it. Same posture inputs as the
479+
// HTTP gate (server.ts).
480+
const wsPath = joinURL(apiPath, 'ws')
481+
const wsSiteOrigin = normaliseOrigin(typeof resolvedConfig.site === 'string' ? resolvedConfig.site : null)
482+
const wsExposed = isExposedHost((resolvedConfig.server as { hostname?: string } | undefined)?.hostname)
483+
const wsTrustLoopbackOrigin = !process.env.UNLIGHTHOUSE_CORS_ORIGINS && !process.env.UNLIGHTHOUSE_API_TOKEN
473484
server.on('upgrade', (request: IncomingMessage, socket: Socket) => {
485+
const decision = checkWsUpgrade({
486+
reqPath: (request.url ?? '').split('?')[0] ?? '',
487+
wsPath,
488+
host: request.headers.host ?? null,
489+
origin: request.headers.origin ?? null,
490+
referer: request.headers.referer ?? null,
491+
siteOrigin: wsSiteOrigin,
492+
exposed: wsExposed,
493+
trustLoopbackOrigin: wsTrustLoopbackOrigin,
494+
})
495+
if (decision._tag === 'reject') {
496+
logger.warn?.(`ws: upgrade rejected — ${decision.reason}`)
497+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
498+
socket.destroy()
499+
return
500+
}
474501
ws.handleUpgrade(request, socket)
475502
})
476503
}

packages/unlighthouse/src/server-guards.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,27 @@ export function checkApiOrigin(input: ApiOriginCheckInput): GuardDecision {
173173
return { _tag: 'reject', reason: `cross-origin request from ${requestOrigin}` }
174174
}
175175

176+
export interface WsUpgradeCheckInput extends ApiOriginCheckInput {
177+
/** Request path (no query), e.g. `/api/ws`. */
178+
reqPath: string
179+
/** The only path the WS server accepts an upgrade on. */
180+
wsPath: string
181+
}
182+
183+
/**
184+
* Decide whether a WebSocket upgrade may proceed. The handshake arrives as a
185+
* Node `'upgrade'` event on the raw server, bypassing the h3 pipeline and its
186+
* origin gate, so this applies the same D-043 protection to the WS surface:
187+
* the upgrade must target the WS path exactly, and its Origin/Host must pass
188+
* `checkApiOrigin`. Closes a cross-origin page opening the scan-event stream
189+
* and a rebinding Host reaching it.
190+
*/
191+
export function checkWsUpgrade(input: WsUpgradeCheckInput): GuardDecision {
192+
if (input.reqPath !== input.wsPath)
193+
return { _tag: 'reject', reason: `path ${input.reqPath}` }
194+
return checkApiOrigin(input)
195+
}
196+
176197
// ── /__launch path constraint ────────────────────────────────────────────────
177198

178199
export type LaunchPathResult

test/core.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,60 @@ describe('createUnlighthouseCore orchestration', () => {
189189
expect(session.state()).toBe('complete')
190190
})
191191

192+
it('session.events is per-consumer multicast: two concurrent tails both see the full stream', async () => {
193+
const urls = ['https://example.com/a', 'https://example.com/b', 'https://example.com/c']
194+
const storage: Storage = memoryStorage()
195+
const core = createUnlighthouseCore({
196+
config: baseConfig,
197+
auditor: passingAuditor(),
198+
seeds: emptySeeds,
199+
crawler: discoveryCrawler(urls),
200+
storage,
201+
})
202+
203+
const session = core.run()
204+
const collect = async () => {
205+
const names: string[] = []
206+
for await (const e of session.events as AsyncIterable<HookEvent>)
207+
names.push(e.event)
208+
return names
209+
}
210+
// Two consumers started before the scan finishes. Previously they shared a
211+
// single queue and stole events from each other; now each gets its own.
212+
const [a, b] = await Promise.all([collect(), collect()])
213+
214+
// Both terminate (the scan-end close fans out to every live iterator)...
215+
expect(a.at(-1)).toBe('scan:complete')
216+
expect(b.at(-1)).toBe('scan:complete')
217+
// ...and both see every route-complete, none stolen by the other.
218+
expect(a.filter(n => n === 'scan:route-complete')).toHaveLength(3)
219+
expect(b.filter(n => n === 'scan:route-complete')).toHaveLength(3)
220+
expect(a).toEqual(b)
221+
})
222+
223+
it('abandoning a session.events iterator (return) unsubscribes it', async () => {
224+
const urls = ['https://example.com/a', 'https://example.com/b']
225+
const storage: Storage = memoryStorage()
226+
const core = createUnlighthouseCore({
227+
config: baseConfig,
228+
auditor: passingAuditor(),
229+
seeds: emptySeeds,
230+
crawler: discoveryCrawler(urls),
231+
storage,
232+
})
233+
const session = core.run()
234+
const it = (session.events as AsyncIterable<HookEvent>)[Symbol.asyncIterator]()
235+
const first = await it.next()
236+
expect(first.done).toBe(false)
237+
// Simulate a client disconnect: the HTTP layer calls return() on close.
238+
const closed = await it.return!()
239+
expect(closed.done).toBe(true)
240+
// A subsequent next() stays done and the scan still completes cleanly.
241+
expect((await it.next()).done).toBe(true)
242+
await session.done
243+
expect(session.state()).toBe('complete')
244+
})
245+
192246
it('scanner.include scopes the audited routes (config was previously ignored)', async () => {
193247
const urls = [
194248
'https://example.com/',

test/server-guards.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import {
88
checkApiOrigin,
9+
checkWsUpgrade,
910
isExposedHost,
1011
isLoopbackHostname,
1112
normaliseOrigin,
@@ -156,6 +157,36 @@ describe('host / origin classification helpers', () => {
156157
})
157158
})
158159

160+
describe('checkWsUpgrade — WS handshake gate', () => {
161+
const base = { wsPath: '/api/ws', siteOrigin: null, exposed: false, trustLoopbackOrigin: true }
162+
163+
it('allows the WS path with a same-origin handshake', () => {
164+
const d = checkWsUpgrade({ ...base, reqPath: '/api/ws', host: 'localhost:5678', origin: LOCAL, referer: null })
165+
expect(d._tag).toBe('allow')
166+
})
167+
168+
it('allows the loopback UI dev server in the default posture', () => {
169+
const d = checkWsUpgrade({ ...base, reqPath: '/api/ws', host: 'localhost:5678', origin: 'http://localhost:3002', referer: null })
170+
expect(d._tag).toBe('allow')
171+
})
172+
173+
it('rejects an upgrade on any other path', () => {
174+
const d = checkWsUpgrade({ ...base, reqPath: '/api/scan/start', host: 'localhost:5678', origin: LOCAL, referer: null })
175+
expect(d._tag).toBe('reject')
176+
expect(d.reason).toContain('path')
177+
})
178+
179+
it('rejects a cross-origin handshake (a remote page opening the event stream)', () => {
180+
const d = checkWsUpgrade({ ...base, reqPath: '/api/ws', host: 'localhost:5678', origin: 'http://evil.com', referer: null })
181+
expect(d._tag).toBe('reject')
182+
})
183+
184+
it('rejects a loopback handshake once the deployment is locked down', () => {
185+
const d = checkWsUpgrade({ ...base, trustLoopbackOrigin: false, reqPath: '/api/ws', host: 'localhost:5678', origin: 'http://localhost:3002', referer: null })
186+
expect(d._tag).toBe('reject')
187+
})
188+
})
189+
159190
describe('resolveLaunchPath — traversal constraint', () => {
160191
const ROOT = '/home/user/project'
161192

0 commit comments

Comments
 (0)