diff --git a/packages/dd-trace/src/plugins/util/web.js b/packages/dd-trace/src/plugins/util/web.js index 12c666681a..1cf1b7c4ed 100644 --- a/packages/dd-trace/src/plugins/util/web.js +++ b/packages/dd-trace/src/plugins/util/web.js @@ -333,15 +333,31 @@ const web = { // GET / POST / etc. case. Node's http module passes `req.method` // through unchanged, so all standard methods are uppercase; the // `toLowerCase` fallback covers any non-standard caller. + let headersModified = false + const hasStatusMessage = (statusMessage && typeof statusMessage === 'string') if (req.method === 'OPTIONS' || req.method.toLowerCase() === 'options') { - headers = typeof statusMessage === 'string' ? headers : statusMessage - headers = { ...res.getHeaders(), ...headers } - - if (isOriginAllowed(req, headers)) { - addAllowHeaders(req, res, headers) + headers = hasStatusMessage ? headers : statusMessage + const headersAreArray = Array.isArray(headers) + const headersLookup = normalizeHeaderLookup(headers) + const mergedHeaders = { ...res.getHeaders(), ...headersLookup } + + if (isOriginAllowed(req, mergedHeaders)) { + const allowedHeaders = computeAllowedHeaders(req, mergedHeaders) + if (allowedHeaders) { + headers = headersAreArray + ? setFlatHeader(headers, 'access-control-allow-headers', allowedHeaders) + : setObjectHeader(headers, 'access-control-allow-headers', allowedHeaders) + headersModified = true + } } } + if (headersModified) { + if (hasStatusMessage) { + return writeHead.call(this, statusCode, statusMessage, headers) + } + return writeHead.call(this, statusCode, headers) + } return writeHead.apply(this, arguments) } }, @@ -372,7 +388,7 @@ function normalizeHeadersCarrier (headers) { return carrier } -function addAllowHeaders (req, res, headers) { +function computeAllowedHeaders (req, headers) { const allowHeaders = splitHeader(headers['access-control-allow-headers']) const requestHeaders = splitHeader(req.headers['access-control-request-headers']) const contextHeaders = [ @@ -393,9 +409,7 @@ function addAllowHeaders (req, res, headers) { } } - if (allowHeaders.length > 0) { - res.setHeader('access-control-allow-headers', uniq(allowHeaders).join(',')) - } + return uniq(allowHeaders).join(',') } function isOriginAllowed (req, headers) { @@ -409,6 +423,62 @@ function splitHeader (str) { return typeof str === 'string' ? str.split(',').map((header) => header.trim()) : [] } +function normalizeHeaderLookup (headers) { + const result = {} + + if (Array.isArray(headers)) { + for (let i = 0; i < headers.length; i += 2) { + result[headers[i].toLowerCase()] = headers[i + 1] + } + } else if (headers) { + for (const key of Object.keys(headers)) { + result[key.toLowerCase()] = headers[key] + } + } + + return result +} + +function setFlatHeader (headers, name, value) { + const result = [...headers] + let headerFound = false + + for (let i = 0; i < result.length; i += 2) { + if (result[i].toLowerCase() === name) { + result[i + 1] = value + headerFound = true + } + } + + if (!headerFound) { + result.push(name, value) + } + + return result +} + +function setObjectHeader (headers, name, value) { + const result = {} + let headerFound = false + + if (headers) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name) { + result[key] = value + headerFound = true + } else { + result[key] = headers[key] + } + } + } + + if (!headerFound) { + result[name] = value + } + + return result +} + function addRequestTags (context, spanType) { const { req, span, inferredProxySpan, config } = context const spanContext = span.context() diff --git a/packages/dd-trace/test/plugins/util/web.spec.js b/packages/dd-trace/test/plugins/util/web.spec.js index 0d740a33a3..58e3a46833 100644 --- a/packages/dd-trace/test/plugins/util/web.spec.js +++ b/packages/dd-trace/test/plugins/util/web.spec.js @@ -826,11 +826,13 @@ describe('plugins/util/web', () => { req.headers.origin = 'https://example.com' req.headers['access-control-request-headers'] = 'x-datadog-trace-id' res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.notCalled) + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual(res.writeHead.firstCall.args, [200]) }) it('skips allow-header tagging on OPTIONS when the origin is not allowed', () => { @@ -838,11 +840,13 @@ describe('plugins/util/web', () => { req.headers.origin = 'https://evil.example.com' req.headers['access-control-request-headers'] = 'x-datadog-trace-id' res.getHeaders.returns({ [ALLOW_ORIGIN]: 'https://good.example.com' }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.notCalled) + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual(res.writeHead.firstCall.args, [200]) }) it('merges datadog allow-headers on OPTIONS when allow-origin is *', () => { @@ -851,14 +855,15 @@ describe('plugins/util/web', () => { req.headers['access-control-request-headers'] = 'x-datadog-trace-id, x-datadog-parent-id, x-other' res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.calledOnce) + assert.ok(res.writeHead.calledOnce) assert.deepStrictEqual( - res.setHeader.firstCall.args, - [ALLOW_HEADERS, 'x-datadog-parent-id,x-datadog-trace-id'] + res.writeHead.firstCall.args, + [200, { [ALLOW_HEADERS]: 'x-datadog-parent-id,x-datadog-trace-id' }] ) }) @@ -867,14 +872,15 @@ describe('plugins/util/web', () => { req.headers.origin = 'https://example.com' req.headers['access-control-request-headers'] = 'baggage, traceparent, tracestate, x-other' res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.calledOnce) + assert.ok(res.writeHead.calledOnce) assert.deepStrictEqual( - res.setHeader.firstCall.args, - [ALLOW_HEADERS, 'baggage,traceparent,tracestate'] + res.writeHead.firstCall.args, + [200, { [ALLOW_HEADERS]: 'baggage,traceparent,tracestate' }] ) }) @@ -883,14 +889,38 @@ describe('plugins/util/web', () => { req.headers.origin = 'https://example.com' req.headers['access-control-request-headers'] = 'x-datadog-trace-id' res.getHeaders.returns({}) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200, { [ALLOW_ORIGIN]: 'https://example.com' }) - assert.ok(res.setHeader.calledOnce) + assert.ok(res.writeHead.calledOnce) assert.deepStrictEqual( - res.setHeader.firstCall.args, - [ALLOW_HEADERS, 'x-datadog-trace-id'] + res.writeHead.firstCall.args, + [200, { [ALLOW_ORIGIN]: 'https://example.com', [ALLOW_HEADERS]: 'x-datadog-trace-id' }] + ) + }) + + it('merges tracing headers into mixed-case explicit headers', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'baggage' + res.getHeaders.returns({}) + res.writeHead = sinon.spy() + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200, { + [ALLOW_ORIGIN]: '*', + 'Access-Control-Allow-Headers': 'content-type', + }) + + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual( + res.writeHead.firstCall.args, + [200, { + [ALLOW_ORIGIN]: '*', + 'Access-Control-Allow-Headers': 'content-type,baggage', + }] ) }) @@ -899,14 +929,75 @@ describe('plugins/util/web', () => { req.headers.origin = 'https://example.com' req.headers['access-control-request-headers'] = 'x-datadog-trace-id' res.getHeaders.returns({}) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200, 'OK', { [ALLOW_ORIGIN]: '*' }) - assert.ok(res.setHeader.calledOnce) + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual( + res.writeHead.firstCall.args, + [200, 'OK', { [ALLOW_ORIGIN]: '*', [ALLOW_HEADERS]: 'x-datadog-trace-id' }] + ) + }) + + it('honours headers passed as a flat array in rawHeaders format', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({}) + res.writeHead = sinon.spy() + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200, [ALLOW_ORIGIN, '*']) + + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual( + res.writeHead.firstCall.args, + [200, [ALLOW_ORIGIN, '*', ALLOW_HEADERS, 'x-datadog-trace-id']] + ) + }) + + it('honours mixed-case headers passed as a flat array in rawHeaders format', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({}) + res.writeHead = sinon.spy() + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200, ['Access-Control-Allow-Origin', '*']) + + assert.ok(res.writeHead.calledOnce) assert.deepStrictEqual( - res.setHeader.firstCall.args, - [ALLOW_HEADERS, 'x-datadog-trace-id'] + res.writeHead.firstCall.args, + [200, ['Access-Control-Allow-Origin', '*', ALLOW_HEADERS, 'x-datadog-trace-id']] + ) + }) + + it('preserves unrelated duplicate headers passed as a flat array', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({}) + res.writeHead = sinon.spy() + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200, [ + 'Set-Cookie', 'first=1', + 'Access-Control-Allow-Origin', '*', + 'Set-Cookie', 'second=2', + ]) + + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual( + res.writeHead.firstCall.args, + [200, [ + 'Set-Cookie', 'first=1', + 'Access-Control-Allow-Origin', '*', + 'Set-Cookie', 'second=2', + ALLOW_HEADERS, 'x-datadog-trace-id', + ]] ) }) @@ -915,14 +1006,15 @@ describe('plugins/util/web', () => { req.headers.origin = 'https://example.com' req.headers['access-control-request-headers'] = 'x-datadog-trace-id' res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.calledOnce) + assert.ok(res.writeHead.calledOnce) assert.deepStrictEqual( - res.setHeader.firstCall.args, - [ALLOW_HEADERS, 'x-datadog-trace-id'] + res.writeHead.firstCall.args, + [200, { [ALLOW_HEADERS]: 'x-datadog-trace-id' }] ) }) @@ -934,14 +1026,15 @@ describe('plugins/util/web', () => { [ALLOW_ORIGIN]: '*', [ALLOW_HEADERS]: 'content-type, x-datadog-trace-id', }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.calledOnce) + assert.ok(res.writeHead.calledOnce) assert.deepStrictEqual( - res.setHeader.firstCall.args, - [ALLOW_HEADERS, 'content-type,x-datadog-trace-id'] + res.writeHead.firstCall.args, + [200, { [ALLOW_HEADERS]: 'content-type,x-datadog-trace-id' }] ) }) @@ -950,11 +1043,13 @@ describe('plugins/util/web', () => { req.headers.origin = 'https://example.com' req.headers['access-control-request-headers'] = 'content-type, x-other' res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.notCalled) + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual(res.writeHead.firstCall.args, [200]) }) it('delegates to the original writeHead with the same arguments', () => { @@ -973,19 +1068,37 @@ describe('plugins/util/web', () => { ) }) + it('passes merged allow-headers to writeHead so it survives writeHead precedence', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200, { [ALLOW_ORIGIN]: '*', [ALLOW_HEADERS]: 'content-type' }) + + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual( + res.writeHead.firstCall.args, + [200, { [ALLOW_ORIGIN]: '*', [ALLOW_HEADERS]: 'content-type,x-datadog-trace-id' }] + ) + }) + it('trims whitespace surrounding each requested header entry', () => { req.method = 'OPTIONS' req.headers.origin = 'https://example.com' req.headers['access-control-request-headers'] = ' x-datadog-parent-id ,x-datadog-trace-id ' res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() const wrapped = web.wrapWriteHead(context) wrapped.call(res, 200) - assert.ok(res.setHeader.calledOnce) + assert.ok(res.writeHead.calledOnce) assert.deepStrictEqual( - res.setHeader.firstCall.args, - [ALLOW_HEADERS, 'x-datadog-parent-id,x-datadog-trace-id'] + res.writeHead.firstCall.args, + [200, { [ALLOW_HEADERS]: 'x-datadog-parent-id,x-datadog-trace-id' }] ) }) })