Skip to content

fix: strip transport-owned headers to prevent duplicate Content-Length (closes #579) - #602

Merged
s2x merged 2 commits into
masterfrom
fix/issue-579-duplicate-content-length
Jul 28, 2026
Merged

fix: strip transport-owned headers to prevent duplicate Content-Length (closes #579)#602
s2x merged 2 commits into
masterfrom
fix/issue-579-duplicate-content-length

Conversation

@s2x

@s2x s2x commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Description

Closes #579

Changes

  • ResponseConverter::extractHeaders() now strips transport-owned headers (Content-Length, Accept-Ranges, Transfer-Encoding) centrally so Workerman's transport layer (Http::encode() / Response::__toString()) is the sole authority on message framing
  • Single-valued headers are flattened from list<string|null> to string (except Set-Cookie, which legitimately needs multiple values) to prevent array_merge_recursive in Response::withHeaders() from ever producing arrays of conflicting values
  • Headers whose values are all null/empty are dropped so they are not emitted as empty lines on the wire
  • Content-Range and the 206 status on ranged responses are preserved (Workerman sets them via header() which overwrites)
  • Existing strcasecmp guards in DefaultResponseStrategy::buildHeaderString() and StreamedResponseStrategy::buildHeaderString() kept as belt-and-braces with explanatory comments
  • Type annotations in ResponseConverterStrategyInterface and both strategies updated to array<string, string|list<string|null>>
  • Existing tests updated to the new flattened header shape (getHeader() now returns string for single-valued headers)
  • New ContentLengthDesyncTest with 9 tests covering all four body paths (file, small body, large body, streamed), ranged 206, multiple Set-Cookie, Content-Range preservation, empty body, 304, and the TRANSPORT_HEADERS constant
  • New testConvertDropsAllNullHeaderValues in ResponseConverterTest

Root cause

Workerman's Response::withHeaders() uses array_merge_recursive, which on a string key present in both arrays produces an array of both values rather than a replacement. Http::encode() adds its own Content-Length/Accept-Ranges via withHeaders() for file responses, and Response::__toString() appends its own Content-Length unconditionally for the small-body path — so an application-supplied Content-Length was emitted twice. When the two values disagreed, the conflict is a response-desync primitive (RFC 9110 §8.6) that can poison caches and leak responses across keep-alive connections.

Changelog

Security fix entry added under [Unreleased]### Security.

Code Review

  • Passed subagent code review (two passes, no blockers)
  • All review comments addressed (HEAD/304 test coverage, null-values test, docblock accuracy, CHANGELOG corrections)
  • composer lint passes (PHPStan level 8, php-cs-fixer, rector)
  • composer test passes (1566 tests)

…-Length (closes #579)

ResponseConverter::extractHeaders() now strips Content-Length, Accept-Ranges
and Transfer-Encoding centrally so Workerman's transport layer is the sole
authority on message framing. Previously array_merge_recursive in
Response::withHeaders() combined app-supplied framing headers with the values
encode()/__toString() compute, emitting Content-Length twice — a protocol
violation on file downloads and a response-desync primitive when the two
values disagreed.

Single-valued headers are also flattened from list<string|null> to string
(except Set-Cookie) to prevent array_merge_recursive from ever producing
arrays of conflicting values. Headers with all-null/empty values are dropped.
Content-Range and 206 status on ranged responses are preserved. Existing
strcasecmp guards in buildHeaderString methods kept as belt-and-braces.

Added ContentLengthDesyncTest covering all four paths (file, small body,
large body, streamed) plus ranged 206, Set-Cookie, Content-Range, empty body,
304, and the TRANSPORT_HEADERS list.
@s2x s2x self-assigned this Jul 28, 2026
- Add testRangedFileDownloadThroughEncodeEmitsCorrectBodyRange: drives the
  real Workerman Http::encode() and asserts on the wire bytes — status 206,
  exactly one Content-Length: 100, Content-Range: bytes 0-99/5000, and the
  body is the requested 100-byte range from the fixture (closes AC 2).
- Add testFileDownloadThroughEncodeEmitsSingleFramingHeaders: end-to-end
  wire test for plain file download — exactly one Content-Length and one
  Accept-Ranges, plus the full fixture as body.
- Add testHeadRequestEmitsNoBodyAndSingleContentLength: constructs a HEAD
  request via Symfony's prepare(), asserts no body and a single
  Content-Length: 0 (closes AC 7).
- Strengthen testStreamedResponseEmitsNoContentLength: set an app-supplied
  Content-Length so the test guards against a leaked app CL on the streamed
  path (closes AC 4 caveat).
- Remove the two hand-simulated ranged/file tests that duplicated encode()
  logic; the end-to-end tests supersede them.
@s2x
s2x merged commit 771d739 into master Jul 28, 2026
12 checks passed
@s2x
s2x deleted the fix/issue-579-duplicate-content-length branch July 28, 2026 21:20
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.

[Security] Duplicate Content-Length on every file download and on app-set Content-Length — response desync when values conflict

1 participant