Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/express/src/honeytoken-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html><body>');
res.write('<h1>streamed</h1>');
res.end('</body></html>');
});

const { url, close } = await serve(app);
await settle();
const res = await get(`${url}/`);
close();

expect(res.body).toContain('<h1>streamed</h1>');
expect(res.body).toMatch(/<a href="\/__wd\/[0-9a-f]{12}"/);
expect(res.body.indexOf('/__wd/')).toBeLessThan(res.body.indexOf('</body>'));
});

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 = '<html><body>fixed</body></html>';
// 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('<html><body>fixed</body></html>');
expect(res.body).not.toContain('__wd');
});
})
21 changes: 19 additions & 2 deletions packages/express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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 {
Expand Down