Skip to content

[Bug]: headersArray() splits single header values on commas on Firefox and WebKit, corrupting every HTTP-date header #42687

Open

Description

Version

1.64.0-next (9cee42790), reproduced on chromium / firefox / webkit, macOS arm64

Steps to reproduce

Any header whose value legitimately contains a comma is split into multiple bogus entries on Firefox and WebKit. HTTP-date values always contain one, so Date is affected on essentially every response.

import { test } from '@playwright/test';

test('header split', async ({ page, browserName, server }) => {
  server.setRoute('/h', (req, res) => {
    res.writeHead(200, { 'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT' });
    res.end('ok');
  });
  const resp = await page.goto(server.PREFIX + '/h');
  console.log(browserName, (await resp.headersArray()).filter(h => h.name.toLowerCase() === 'last-modified'));
});

Expected

One entry holding the value that was actually sent, as chromium does:

chromium [ { name: 'last-modified', value: 'Wed, 21 Oct 2026 07:28:00 GMT' } ]

Actual

firefox  [ { name: 'last-modified', value: 'Wed' },
           { name: 'last-modified', value: '21 Oct 2026 07:28:00 GMT' } ]
webkit   [ { name: 'Last-Modified', value: 'Wed' },
           { name: 'Last-Modified', value: '21 Oct 2026 07:28:00 GMT' } ]

Neither entry holds the real value, and a header that was sent once is reported twice.

I checked this against the literal bytes rather than trusting another API. Reading the same route over a raw socket, the server writes exactly one date header:

WIRE header lines -> ["content-type: text/plain","Date: Fri, 11 Sep 2026 05:27:07 GMT","Connection: close","Transfer-Encoding: chunked"]
WIRE date count   -> 1

and for that same response headersArray() reports date once on chromium and twice on firefox and webkit, valued "Fri" and "11 Sep 2026 05:27:07 GMT".

Second, macOS-only half: Set-Cookie

wkSetCookieSeparator is ',' on darwin and a sentinel string elsewhere (wkInterceptableRequest.ts:45), so on macOS WebKit the same split also hits Set-Cookie, which very commonly carries an Expires date:

res.writeHead(200, { 'set-cookie': ['sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/'] });
chromium headersArray -> [ { value: 'sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/' } ]
firefox  headersArray -> [ { value: 'sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/' } ]
webkit   headersArray -> [ { value: 'sid=1; Expires=Wed' },
                           { value: '21 Oct 2026 07:28:00 GMT; Path=/' } ]

webkit   allHeaders()['set-cookie'] -> "sid=1; Expires=Wed\n21 Oct 2026 07:28:00 GMT; Path=/"

That last line is the worst of it: allHeaders() is correct on chromium and firefox, but on macOS WebKit the comma has become a newline inside the value. headerValue() is correct everywhere, since it re-joins.

Root cause

Both backends split on , for every header except a special-cased set-cookie.

packages/playwright-core/src/server/firefox/ffNetworkManager.ts:280:

function parseMultivalueHeaders(headers: HeadersArray) {
  const result: HeadersArray = [];
  for (const header of headers) {
    const separator = header.name.toLowerCase() === 'set-cookie' ? '\n' : ',';
    const tokens = header.value.split(separator).map(s => s.trim());
    for (const token of tokens)
      result.push({ name: header.name, value: token });
  }
  return result;
}

WebKit reaches the same place through headersObjectToArray(responsePayload.headers, ',', wkSetCookieSeparator) (wkInterceptableRequest.ts:88, wkPage.ts:407), and headersObjectToArray in packages/isomorphic/headers.ts does values.split(sep).

The split is there to recover genuinely repeated headers after the protocol has already joined them into one string, so some of the information is lost before Playwright sees it and any un-joining is a guess. But comma is a list separator only for list-valued headers; the HTTP-date headers (Date, Expires, Last-Modified, If-Modified-Since, If-Unmodified-Since) are single-valued by definition and can never be split correctly. Excluding those the way set-cookie is already excluded would fix the common case without touching the protocol, though I suspect the fully correct fix is to have juggler and the WebKit protocol hand over raw header pairs instead of a joined string, and that is not something I can do from this repo.

I left the chunked transfer-encoding difference I noticed in request().sizes() out of this report to keep it to one thing.

I am a freshman in college doing my best to contribute something useful here, so if the split is deliberate and headersArray() is only ever meant to be approximate on these two browsers, please say so and I will drop it.

Activity

  1. ashishb-automation commented on Sep 13, 2026

    @ashishb-automation

    Hi, I’d like to investigate this issue if it’s available for community contribution. I’ll first reproduce it across Chromium, Firefox and WebKit, then trace where the header values are being split. My proposed change would include a focused fix and regression coverage for HTTP-date and Set-Cookie values. Please let me know if this approach is acceptable before I begin.

  2. ayaangazali commented on Sep 13, 2026

    @ayaangazali
    Author

    Not mine to assign, and it is still unassigned, so that is a maintainer call rather than mine. Your plan sounds right to me though.

    Two things that might save you time, since I burned some on both:

    The Set-Cookie half is macOS-only. wkSetCookieSeparator is ',' on darwin and a sentinel string everywhere else (wkInterceptableRequest.ts:45), so on Linux and Windows WebKit Set-Cookie is already protected and only the other headers split. Firefox never splits Set-Cookie at all, it uses '\n' for that one. So if you test only on Linux you will see a narrower bug than the report describes.

    The harder part is that the split is not gratuitous. Both protocols hand Playwright a single joined string per header name, so genuinely repeated headers have already been flattened by the time parseMultivalueHeaders and headersObjectToArray see them, and splitting is an attempt to recover them. That is why I stopped short of sending a patch: excluding the HTTP-date headers the way set-cookie is already excluded fixes the reported case cleanly, but it is a targeted fix rather than a general one, and the general one probably means getting raw header pairs out of juggler and the WebKit protocol. Worth deciding which of those you are aiming at before you write the regression tests, because it changes what they should assert.

    I have both backends set up locally, so if a comparison run against chromium would help at any point just say.

  3. github-actions commented on Sep 14, 2026

    @github-actions
    Contributor

    Hi, I'm the Playwright bot and I took a first look at this.

    Reproduced on tip-of-tree (1.64.0-next, sha 52a0e72) on macOS arm64, exactly as described. headersArray() splits single-valued header values on , in Firefox and WebKit, so any HTTP-date header comes back as two bogus entries and neither holds the real value. Chromium is correct. Your reading of the source is right too — parseMultivalueHeaders in ffNetworkManager.ts and headersObjectToArray(..., ',', wkSetCookieSeparator) in wkInterceptableRequest.ts are the two places, and set-cookie is the only name excluded.

    The macOS-only second half reproduces as well: on this machine wkSetCookieSeparator is ',', so WebKit splits Set-Cookie too, and allHeaders()['set-cookie'] comes back with the comma turned into a newline inside the value. That one is the most damaging of the set, since allHeaders() is the API people reach for and it silently returns a corrupted string rather than an obviously-wrong split.

    What I ran and what came back

    Condensed to a single spec in tests/page/, run on macOS with npm run test -- --project=chromium-page --project=firefox-page --project=webkit-page:

    import { test, expect } from './pageTest';
    
    test('headersArray should not split http-date header on comma', async ({ page, server }) => {
      server.setRoute('/h', (req, res) => {
        res.writeHead(200, {
          'content-type': 'text/plain',
          'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT',
          'set-cookie': ['sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/'],
        });
        res.end('ok');
      });
      const resp = await page.goto(server.PREFIX + '/h');
      const all = await resp.headersArray();
      expect(all.filter(h => h.name.toLowerCase() === 'last-modified')).toHaveLength(1);
      expect(all.filter(h => h.name.toLowerCase() === 'set-cookie')).toHaveLength(1);
    });

    chromium-page passes, firefox-page and webkit-page fail. Logged values:

    chromium
      headersArray last-modified -> [{"name":"last-modified","value":"Wed, 21 Oct 2026 07:28:00 GMT"}]
      headersArray set-cookie    -> [{"name":"set-cookie","value":"sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/"}]
      allHeaders()['set-cookie'] -> "sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/"
    
    firefox
      headersArray last-modified -> [{"value":"Wed"},{"value":"21 Oct 2026 07:28:00 GMT"}]
      headersArray set-cookie    -> [{"value":"sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/"}]   (correct, uses '\n')
      allHeaders()['set-cookie'] -> "sid=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/"
    
    webkit
      headersArray last-modified -> [{"name":"Last-Modified","value":"Wed"},{"name":"Last-Modified","value":"21 Oct 2026 07:28:00 GMT"}]
      headersArray set-cookie    -> [{"value":"sid=1; Expires=Wed"},{"value":"21 Oct 2026 07:28:00 GMT; Path=/"}]
      allHeaders()['set-cookie'] -> "sid=1; Expires=Wed\n21 Oct 2026 07:28:00 GMT; Path=/"
    

    allHeaders()['last-modified'] and headerValue('last-modified') are correct on all three, since both re-join — consistent with what you reported. I did not test Linux or Windows, but wkSetCookieSeparator is ',' only on darwin, so the Set-Cookie half being macOS-only matches the code.

    So: real bug, not deliberate approximation, and worth fixing. Your framing of the tradeoff is the right one — the split exists to un-flatten genuinely repeated headers after the protocol has already joined them, so excluding the single-valued HTTP-date names (and, on macOS WebKit, Set-Cookie) fixes the common case without touching the protocol, while the general fix means getting raw header pairs out of juggler and the WebKit protocol. Which of those to take is a maintainer call, so I'll leave that open rather than guess at it.

    This is a first pass from a bot, so a maintainer may see it differently — but the report was unusually precise and the source pointers all checked out.

    Triaged on macOS by the Playwright bot - agent run

  4. mohanram-dev commented on Sep 15, 2026

    @mohanram-dev

    Submitted PR #42723 addressing this issue by preserving standard single-valued and HTTP-date headers without comma-splitting, as well as safely preserving dates inside \Expires\ attributes for comma-separated Set-Cookie headers on macOS WebKit.

  5. dcrousso commented on Sep 18, 2026

    @dcrousso
    Contributor

    part of this is being fixed upstream WebKit/WebKit#74308

  6. ayaangazali commented on Sep 19, 2026

    @ayaangazali
    Author

    Now that #42723 is closed, one piece of hard evidence for whoever picks this up next, so the next attempt does not land in the same place.

    I had asserted on this issue that the protocols hand Playwright headers already joined, so the comma split is an attempt to recover repeated headers rather than something gratuitous. That was inference, so I went and instrumented the raw juggler payload. It holds up. Serving two x-multi headers, two Set-Cookie headers and one Last-Modified in the same response, Network.responseReceived arrives as:

    {"name": "last-modified", "value": "Wed, 21 Oct 2026 07:28:00 GMT"}
    {"name": "x-multi",       "value": "m1, m2"}
    {"name": "set-cookie",    "value": "a=1; Path=/\nb=2; Path=/"}
    

    The payload is typed as an array, which made me look twice, but it is one entry per header name, not per wire occurrence. Gecko has already joined the two x-multi headers with ", " before Playwright sees them, which is exactly why set-cookie gets a \n separator and everything else gets ,.

    So from that string alone x-multi: m1, m2 (two headers, must split) and last-modified: Wed, 21 Oct ... (one header, must not split) are genuinely indistinguishable. Any rule that works purely on the joined value is guessing, which I think is the same thing Devin Rousso (@dcrousso) means by a broad approach having unexpected failures.

    That also lines up with visitOriginalResponseHeaders being the direction. It is not in firefox/protocol.d.ts today, so it reads as new juggler surface that would hand over the unjoined originals and remove the guess entirely, rather than a smarter way to split. If that is right then the fix here rides a Firefox roll and there is not much to do in this repo in the meantime.

    Happy to re-run my three-engine reproducer against whatever lands, I still have it set up.

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

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions