Skip to content

fix: handle undefined statusText in writeHead arguments#49

Open
mahmoodhamdi wants to merge 1 commit into
jshttp:masterfrom
mahmoodhamdi:fix/writehead-undefined-statustext
Open

fix: handle undefined statusText in writeHead arguments#49
mahmoodhamdi wants to merge 1 commit into
jshttp:masterfrom
mahmoodhamdi:fix/writehead-undefined-statustext

Conversation

@mahmoodhamdi

Copy link
Copy Markdown

Summary

Fixes argument parsing in setWriteHeadHeaders to correctly handle writeHead(statusCode, undefined, headers) — where the statusText is explicitly passed as undefined or null.

Problem

When res.writeHead(200, undefined, { 'X-Custom': 'value' }) is called through on-headers, the headers object at the third argument position is silently dropped.

Root cause: The argument parsing logic determines the header index based only on whether arguments[1] is a string:

var headerIndex = length > 1 && typeof arguments[1] === 'string'
    ? 2
    : 1

When arguments[1] is undefined, it's not a string, so headerIndex = 1. Then arguments[1] (which is undefined) is read as the headers, and the actual headers at arguments[2] are never accessed.

Without on-headers: Native Node.js writeHead(200, undefined, headers) works correctly.
With on-headers: Headers are silently dropped.

This was reported as expressjs/compression#254, where the compression middleware (which uses on-headers) caused headers to be lost when the Vercel AI SDK called writeHead(status, undefined, headers).

Fix

When there are more than 2 arguments, the headers are always at index 2 regardless of whether the second argument is a string, matching Node.js native writeHead behavior:

var headerIndex = length > 1 && typeof arguments[1] === 'string'
    ? 2
    : length > 2
      ? 2
      : 1

Testing

Added 2 new tests:

  • writeHead(status, undefined, obj) — headers are set correctly
  • writeHead(status, null, obj) — headers are set correctly

All 26 tests pass. Lint clean.

When writeHead is called with three arguments where the second
argument (statusText) is undefined or null, the headers object at
the third argument position was being ignored. This is because the
argument parsing only checked if the second argument was a string
to determine the header index, without considering that three
arguments always means the third is headers.

This caused headers set via writeHead(status, undefined, headers)
to be silently dropped, which affected downstream packages like
compression middleware (expressjs/compression#254).

The fix checks if there are more than two arguments, and if so,
always reads headers from the third position, matching Node.js
native writeHead behavior.

@bjohansebas bjohansebas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, the fix is correct for the reported writeHead(status, undefined/null, headers) case.

While checking this against native Node across the full argument space, I noticed one edge case the headerIndex approach doesn't fully cover. This patch forwards the leading args ([statusCode, arguments[1]]) to the original writeHead, so when the 2nd argument is a non-string it gets re-applied by native as headers:

res.writeHead(200, { 'X-A': '1' }, { 'X-B': '2' })
// native → only X-B (reason object ignored)
// PR #49 → BOTH X-A and X-B (X-A leaks via the forwarded leading args)

(writeHead(status, obj, undefined) also only works today by accident, the forwarded arguments[1] happens to be re-applied by native.)

A small refactor mirrors Node's rule exactly and stops forwarding a non-string reason:

 function setWriteHeadHeaders (statusCode) {
   var length = arguments.length
-  var headerIndex = length > 1 && typeof arguments[1] === 'string'
-    ? 2
-    : length > 2
-      ? 2
-      : 1
-
-  var headers = length >= headerIndex + 1
-    ? arguments[headerIndex]
-    : undefined
+  var hasReason = length > 1 && typeof arguments[1] === 'string'
+
+  // locate the headers argument, mirroring node's
+  // writeHead(statusCode[, statusMessage][, headers]):
+  // - with a string statusMessage, headers are the 3rd argument
+  // - otherwise node resolves headers as `headers ?? statusMessage`, so a
+  //   non-string 2nd argument is only used when the 3rd is null/undefined
+  var headers
+  if (hasReason) {
+    headers = arguments[2]
+  } else if (length > 2) {
+    headers = arguments[2] != null ? arguments[2] : arguments[1]
+  } else {
+    headers = arguments[1]
+  }
 
   this.statusCode = statusCode
 
   if (Array.isArray(headers)) {
     // handle array case
     setHeadersFromArray(this, headers)
   } else if (headers) {
     // handle object case
     setHeadersFromObject(this, headers)
   }
 
-  // copy leading arguments
-  var args = new Array(Math.min(length, headerIndex))
-  for (var i = 0; i < args.length; i++) {
-    args[i] = arguments[i]
-  }
+  // only forward the status code and a real (string) status message to the
+  // original writeHead; headers are already applied above via setHeader, and a
+  // non-string 2nd argument must not be forwarded or node would re-apply it as
+  // headers
+  var args = hasReason
+    ? [statusCode, arguments[1]]
+    : [statusCode]
 
   return args
 }

Plus a test:

+  describe('writeHead(status, obj, obj)', function () {
+    it('should use the last object as headers and ignore a non-string statusMessage, matching node', function (done) {
+      var server = createServer(listener, handler)
+
+      function handler (req, res) {
+        res.writeHead(201, { 'X-First': 'a' }, { 'X-Second': 'b' })
+      }
+
+      function listener (req, res) {}
+
+      request(server)
+        .get('/')
+        .expect('X-Second', 'b')
+        .expect(201)
+        .expect(function (res) {
+          assert.strictEqual(res.headers['x-first'], undefined, 'X-First should be ignored')
+        })
+        .end(done)
+    })
+  })

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants