diff --git a/apps/docs/content/docs/integrations/elysia.mdx b/apps/docs/content/docs/integrations/elysia.mdx index dd71ff35..ec47bc50 100644 --- a/apps/docs/content/docs/integrations/elysia.mdx +++ b/apps/docs/content/docs/integrations/elysia.mdx @@ -85,6 +85,13 @@ const app = new Elysia() }); ``` +By default the terminal `error` frame carries a generic `data: error` token — the raw +`event.message` is **not** echoed, since it can disclose internal network topology (a +transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the +upstream's status (`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream +messages are known safe (`errorData: (e) => e.message`); `onError` still receives the real +failure server-side for logging. + **Anti-pattern:** return the `streamStitchSse` `Response` as the handler's result — don't also set `set.status` / `set.headers` or return a second diff --git a/packages/elysia/README.md b/packages/elysia/README.md index bca92395..6942b622 100644 --- a/packages/elysia/README.md +++ b/packages/elysia/README.md @@ -85,6 +85,13 @@ disconnect cancels the body and aborts the upstream stitch stream rather than leaving it running. Control events (`start`/`progress`/`result`/`done`/…) are not forwarded. +By default the `error` frame carries a generic `data: error` token, **not** the raw +error message — echoing it can disclose internal network topology (a transport failure +reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status +(`HTTP 401`) to the client. Pass `errorData` to opt in when the upstream messages are +known safe (`errorData: (e) => e.message`); `onError` still receives the real failure +server-side. + ## Error handling The plugin registers an `.onError` that maps a `StitchError` to an HTTP response diff --git a/packages/elysia/src/sse.ts b/packages/elysia/src/sse.ts index 0c92dcfb..63ed587d 100644 --- a/packages/elysia/src/sse.ts +++ b/packages/elysia/src/sse.ts @@ -2,7 +2,8 @@ // (mirrors @stitchapi/hono's sse.ts, adapted to Elysia's Web-standard return model). A streaming/SSE // stitch's `.stream()` is an `AsyncIterable`; this forwards each `delta` chunk as one // SSE message, ends the stream cleanly on `done`, and turns an `error` event into a final -// `event: error` message. When the client disconnects the `ReadableStream` is cancelled — the helper +// `event: error` message — a generic `data: error` by default, the raw message withheld (opt in via +// `errorData`). When the client disconnects the `ReadableStream` is cancelled — the helper // calls the iterator's `return()` so the upstream stitch stream is torn down rather than left running. // // Web-standard: returns a plain `Response` whose body is a `ReadableStream` — no `node:*`, so it runs @@ -14,6 +15,9 @@ export type StitchEventSource = | AsyncIterable> | AsyncGenerator, void>; +/** The terminal `error` event a stitch stream emits — carries `message`, `status`, `attempts`. */ +type StitchErrorEvent = Extract; + export interface StreamStitchSseOptions { /** * Map a `delta` chunk to the SSE message `data` string. The default JSON-stringifies the chunk @@ -27,8 +31,21 @@ export interface StreamStitchSseOptions { */ event?: string; /** - * Called once if the underlying stream errors (a stitch `error` event, or a throw). After it - * runs, an `error`-typed SSE message carrying the error text is written and the stream closes. + * Shape the SSE `data` written for a terminal `error` event (or an uncaught throw mid-stream, + * normalised to an error event). **Default: a generic token (`data: error`)** — the raw + * `event.message` is deliberately *not* echoed, because it can disclose internal network + * topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or + * the upstream's status (`HTTP 401`) to an untrusted client. Opt in with `(e) => e.message` + * when the upstream messages are known safe, or return your own payload + * (e.g. `() => JSON.stringify({ error: 'stream failed' })`). A multi-line return gets one + * `data:` line each (SSE spec); the `event: error` name is fixed. + */ + errorData?: (event: StitchErrorEvent) => string; + /** + * Called once, server-side, if the underlying stream errors (a stitch `error` event, or a + * throw) — use it to observe/log the real failure. It does **not** shape the client-facing + * frame: the SSE `data` sent to the client is controlled by `errorData` (a generic token by + * default), so the raw message reaches your logs here but not the client. */ onError?: (err: unknown) => void; } @@ -46,6 +63,25 @@ function frame(data: string, event?: string): string { return `${lines.join('\n')}\n\n`; } +// The generic token written as an `error` message's `data` by default: the raw upstream message is +// withheld so an internal hostname (`getaddrinfo ENOTFOUND …`) or the upstream's status (`HTTP 401`) +// never reaches the client. Override with `options.errorData`. +const DEFAULT_ERROR_DATA = 'error'; + +// Normalise a thrown value into the terminal `error` event shape, so an `errorData` opt-in sees a +// consistent argument whether the failure arrived as a surfaced `error` event or an unexpected +// throw. `attempts`/`at` are best-effort placeholders — an `errorData` hook keys off `name`/`message`. +function toErrorEvent(reason: unknown): StitchErrorEvent { + const e = reason instanceof Error ? reason : new Error(String(reason)); + return { + type: 'error', + name: e.name, + message: e.message, + attempts: 0, + at: 0, + }; +} + /** * Stream a stitch's events to the client as SSE. Returns a Web-standard {@link Response} with a * `text/event-stream` body, so an Elysia handler is one line: @@ -62,9 +98,10 @@ function frame(data: string, event?: string): string { * ``` * * Each `delta` becomes a `data:` message; a terminal `error` event (or a throw) becomes a final - * `event: error` message and ends the stream; every control event (`start`/`progress`/`result`/ - * `done`/…) is consumed but not forwarded. On client disconnect the stream is cancelled and the - * upstream iterator is `return()`-ed so the stitch stream is aborted. + * `event: error` message and ends the stream — a generic `data: error` by default, the raw message + * withheld to avoid disclosing internal topology (opt in via `errorData`); every control event + * (`start`/`progress`/`result`/`done`/…) is consumed but not forwarded. On client disconnect the + * stream is cancelled and the upstream iterator is `return()`-ed so the stitch stream is aborted. */ export function streamStitchSse( source: StitchEventSource, @@ -92,9 +129,19 @@ export function streamStitchSse( return; // one frame per pull → precise back-pressure } if (event.type === 'error') { + // `onError` gets the real failure server-side; the client frame is a + // generic token by default (never the raw `event.message`) — `errorData` + // opts in. options.onError?.(new Error(event.message)); controller.enqueue( - enc.encode(frame(event.message, 'error')), + enc.encode( + frame( + options.errorData + ? options.errorData(event) + : DEFAULT_ERROR_DATA, + 'error', + ), + ), ); controller.close(); await iterator.return?.(undefined); @@ -104,11 +151,15 @@ export function streamStitchSse( // loop on to the next event without emitting a frame. } } catch (err) { + // A throw (not a surfaced `error` event): still withhold the raw message by + // default — normalise it to an error event so `errorData` sees a consistent shape. options.onError?.(err); controller.enqueue( enc.encode( frame( - err instanceof Error ? err.message : String(err), + options.errorData + ? options.errorData(toErrorEvent(err)) + : DEFAULT_ERROR_DATA, 'error', ), ), diff --git a/packages/elysia/test/sse.spec.ts b/packages/elysia/test/sse.spec.ts index 05b0715c..bbd2ca01 100644 --- a/packages/elysia/test/sse.spec.ts +++ b/packages/elysia/test/sse.spec.ts @@ -138,8 +138,11 @@ describe('streamStitchSse — error paths', () => { const body = await res.text(); expect(body).toContain('data: a'); expect(body).toContain('event: error'); - expect(body).toContain('upstream failed'); + // the raw upstream message is withheld from the client by default (leak-regression below) + expect(body).not.toContain('upstream failed'); + expect(body).toContain('data: error'); expect(body).not.toContain('never'); + // onError still sees the real failure server-side expect((captured as Error).message).toBe('upstream failed'); }); @@ -159,11 +162,71 @@ describe('streamStitchSse — error paths', () => { const body = await res.text(); expect(body).toContain('data: a'); expect(body).toContain('event: error'); - expect(body).toContain('stream blew up'); + // the raw throw message is withheld from the client by default + expect(body).not.toContain('stream blew up'); + expect(body).toContain('data: error'); + // onError still sees the real failure server-side expect((captured as Error).message).toBe('stream blew up'); }); }); +describe('streamStitchSse — the error frame does not leak the raw message', () => { + // Regression: the default error frame must not echo the raw upstream/transport message, which + // can disclose internal network topology (a transport error names the host it failed to reach) + // or the upstream's status to an untrusted client — bringing @stitchapi/elysia in line with the + // other hosts (express/fastify/hono/nest/next), whose SSE helpers already withhold it. + test('an internal hostname in the error message is not disclosed by default', async () => { + const res = streamStitchSse( + gen([ + { + type: 'error', + name: 'StitchError', + message: 'getaddrinfo ENOTFOUND payments.internal.corp', + status: 502, + attempts: 1, + at: 0, + }, + ]), + ); + const body = await res.text(); + expect(body).toContain('event: error'); + expect(body).toContain('data: error'); + expect(body).not.toContain('payments.internal.corp'); + expect(body).not.toContain('ENOTFOUND'); + }); + + test('errorData opts in to shaping the client-facing error payload', async () => { + const res = streamStitchSse( + gen([ + { + type: 'error', + name: 'StitchError', + message: 'upstream blew up', + attempts: 1, + at: 0, + }, + ]), + { errorData: (e) => e.message }, + ); + const body = await res.text(); + expect(body).toContain('event: error'); + expect(body).toContain('data: upstream blew up'); + }); + + test('errorData shapes the throw path too', async () => { + async function* boom(): AsyncGenerator { + yield { type: 'delta', chunk: 'a', at: 0 }; + throw new Error('stream blew up'); + } + const res = streamStitchSse(boom(), { + errorData: (e) => JSON.stringify({ error: e.message }), + }); + const body = await res.text(); + expect(body).toContain('event: error'); + expect(body).toContain('data: {"error":"stream blew up"}'); + }); +}); + describe('streamStitchSse — response', () => { test('returns a text/event-stream Response', async () => { const res = streamStitchSse(