From 0c85485f181136a84f6dfacccc8c93fb77185373 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 30 Jul 2026 19:23:17 -0500 Subject: [PATCH] fix(express): honeytoken injection was silently off for streaming SSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interception required !res.headersSent. Angular SSR — and Nuxt, and anything else that calls res.writeHead() then pipes — commits headers before the first byte of body, so that guard disabled injection for exactly the apps most likely to be serving server-rendered HTML. The failure was silent in the worst way: the page rendered correctly, no error was logged, and nothing was injected. A trap that is not there looks identical to a trap nothing has walked into. headersSent was the wrong question. What matters is whether a Content-Length has been committed that can no longer be corrected — growing the body past a declared length truncates it at the client. Under chunked encoding none is declared, so the body is free to change, which is precisely the streaming case. FOUND BY INSTALLING IT, NOT BY TESTING IT All eight existing tests passed, because every one of them used res.send(), which buffers and sets Content-Length. The suite agreed with itself and was wrong about production. It took wiring the adapter into a real Angular SSR app and grepping the rendered HTML for the link to see it. Two tests added for the paths that were missing: writeHead + chunked injects, and a response with a committed Content-Length is left alone. --- .../express/src/honeytoken-injection.test.ts | 59 +++++++++++++++++++ packages/express/src/middleware.ts | 21 ++++++- 2 files changed, 78 insertions(+), 2 deletions(-) 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 {