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 {