diff --git a/src/protect/engine/request.js b/src/protect/engine/request.js index 604578b..c0d8055 100644 --- a/src/protect/engine/request.js +++ b/src/protect/engine/request.js @@ -253,7 +253,14 @@ export class RequestResolver { default: { if (key.startsWith('HTTP_')) { const headerName = key.substring(5).toLowerCase().replace(/_/g, '-'); - return req.headers?.[headerName] ? [req.headers[headerName]] : []; + const headers = req.headers; + // Presence, not truthiness. A header sent with an empty value IS present, and an `isset` + // rule authored against it must see it — some bypasses are carried by the header existing + // at all, so treating `Header:` as absent would make the rule quietly miss the shape it + // was written for. (The named cases above keep value semantics: for host/origin/referer an + // empty string and an absent header mean the same thing to the matchers that read them.) + if (headers === null || typeof headers !== 'object') return []; + return Object.prototype.hasOwnProperty.call(headers, headerName) ? [headers[headerName]] : []; } return []; } diff --git a/tests/protect/request.test.ts b/tests/protect/request.test.ts index 03c343a..9d27a07 100644 --- a/tests/protect/request.test.ts +++ b/tests/protect/request.test.ts @@ -137,6 +137,26 @@ describe('RequestResolver', () => { assert.deepStrictEqual(resolver.resolve('server.HTTP_X_FORWARDED_FOR'), ['1.2.3.4']); }); + it('should treat an empty header value as present, not absent', () => { + // A header sent with no value IS present, and some bypasses are carried by a header existing at + // all rather than by what it contains — so an `isset` rule authored against one must see it. + // Resolving by truthiness returned [] here, making the rule silently miss that shape. + const resolver = new RequestResolver(createReq({ headers: { 'x-internal-marker': '' } })); + assert.deepStrictEqual(resolver.resolve('server.HTTP_X_INTERNAL_MARKER'), ['']); + }); + + it('should still resolve an absent header to nothing', () => { + const resolver = new RequestResolver(createReq({ headers: {} })); + assert.deepStrictEqual(resolver.resolve('server.HTTP_X_INTERNAL_MARKER'), []); + }); + + it('should not treat an inherited property name as a header', () => { + // `headers.constructor` exists on every object; reading presence off the prototype chain would + // make every request appear to carry a `constructor` header. + const resolver = new RequestResolver(createReq({ headers: {} })); + assert.deepStrictEqual(resolver.resolve('server.HTTP_CONSTRUCTOR'), []); + }); + it('should resolve REMOTE_ADDR from ip', () => { const resolver = new RequestResolver(createReq({ ip: '10.0.0.1' })); assert.deepStrictEqual(resolver.resolve('server.REMOTE_ADDR'), ['10.0.0.1']);