diff --git a/packages/express/src/honeytoken-injection.test.ts b/packages/express/src/honeytoken-injection.test.ts index 0aa4323..92190f0 100644 --- a/packages/express/src/honeytoken-injection.test.ts +++ b/packages/express/src/honeytoken-injection.test.ts @@ -158,3 +158,62 @@ describe('the injected link is actually armed', () => { expect(JSON.parse(normal.body).wouldBlock).toBe(false); }); }); + +/** + * Streaming SSR (#482 follow-up). + * + * The first implementation required `!res.headersSent` before intercepting. + * Angular SSR — and Nuxt, and anything else that calls `res.writeHead()` then + * pipes — commits headers before the first byte of body, so that guard silently + * disabled injection for precisely the apps most likely to be serving + * server-rendered HTML. The page rendered, nothing was injected, and nothing + * errored. Found on a real Angular SSR site, not in a test. + * + * What actually matters is whether a Content-Length has been committed that can + * no longer be corrected. + */ +describe('responses that commit headers before the body', () => { + it('injects into a writeHead + chunked response', async () => { + const app = express(); + app.use(webdecoy({ apiKey: 'sk_live_test_secret', skipLocalAnalysis: true })); + app.get('/', (_req, res) => { + // No Content-Length: chunked, so the body is still free to change. + res.writeHead(200, { 'Content-Type': 'text/html;charset=UTF-8' }); + res.write(''); + res.write('

streamed

'); + res.end(''); + }); + + const { url, close } = await serve(app); + await settle(); + const res = await get(`${url}/`); + close(); + + expect(res.body).toContain('

streamed

'); + expect(res.body).toMatch(/')); + }); + + it('leaves a response alone once a Content-Length is committed', async () => { + const app = express(); + app.use(webdecoy({ apiKey: 'sk_live_test_secret', skipLocalAnalysis: true })); + app.get('/', (_req, res) => { + const body = 'fixed'; + // A declared length we can no longer correct — growing the body here would + // truncate it at the client, which is worse than a missed detection. + res.writeHead(200, { + 'Content-Type': 'text/html', + 'Content-Length': String(Buffer.byteLength(body)), + }); + res.end(body); + }); + + const { url, close } = await serve(app); + await settle(); + const res = await get(`${url}/`); + close(); + + expect(res.body).toBe('fixed'); + expect(res.body).not.toContain('__wd'); + }); +}) diff --git a/packages/express/src/middleware.ts b/packages/express/src/middleware.ts index 5843691..5a4bff6 100644 --- a/packages/express/src/middleware.ts +++ b/packages/express/src/middleware.ts @@ -248,10 +248,25 @@ export function webdecoy( const chunks: Buffer[] = []; let intercepting: boolean | null = null; + // Whether the body can still be rewritten. + // + // `headersSent` is deliberately NOT a blocker. Streaming frameworks — + // Angular SSR, Nuxt, anything that calls res.writeHead() then pipes — + // commit headers before the first byte of body, so requiring + // !headersSent silently disabled injection for exactly the apps most + // likely to be serving server-rendered HTML. Found on a real Angular SSR + // site: page rendered, nothing injected, no error. + // + // What actually matters is whether a Content-Length has been committed + // that we can no longer correct. Under chunked encoding none is declared, + // so the body is free to change; with a committed length, growing the + // body would truncate it at the client, so leave it alone. const shouldIntercept = (): boolean => { if (intercepting === null) { - intercepting = - !res.headersSent && isInjectableHtml(res.getHeader('content-type') as string); + const isHtml = isInjectableHtml(res.getHeader('content-type') as string); + const lengthCommitted = + res.headersSent && res.getHeader('content-length') !== undefined; + intercepting = isHtml && !lengthCommitted; } return intercepting; }; @@ -271,6 +286,8 @@ export function webdecoy( const body = injectHoneytokenLink(Buffer.concat(chunks).toString('utf8'), ht.linkHtml); const out = Buffer.from(body, 'utf8'); // The body grew; a stale Content-Length truncates it at the client. + // Only settable while headers are still open — under chunked encoding + // there is nothing to correct, which is why that path is allowed. if (!res.headersSent) res.setHeader('Content-Length', String(out.length)); return originalEnd(out); } catch {