Skip to content

avoid duplicate Content-Length header in DefaultClient - #3451

Open
alhudz wants to merge 1 commit into
OpenFeign:masterfrom
alhudz:content-length-duplicate
Open

avoid duplicate Content-Length header in DefaultClient#3451
alhudz wants to merge 1 commit into
OpenFeign:masterfrom
alhudz:content-length-duplicate

Conversation

@alhudz

@alhudz alhudz commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Repro: a request whose template carries a non-canonically cased content-length header. For a body-less POST with sun.net.http.allowRestrictedHeaders=true, the header loop forwards content-length via addRequestProperty and the body-less fallback then adds Content-Length: 0, so the field goes out twice. RFC 7230 §3.3.2 forbids generating multiple Content-Length fields; the duplicate is ambiguous framing and servers reject it (Tomcat returns 400, #2862). The same missed comparison means a lower-cased content-length on a request with a body is not picked up for setFixedLengthStreamingMode under disableRequestBuffering, so the request goes out chunked instead of fixed-length.
Cause: the header loop compares with field.equals(CONTENT_LENGTH), but request.headers() preserves the caller's original casing.
Fix: compare with equalsIgnoreCase, so any casing is captured into the local contentLength and never forwarded; the JDK stays the only writer of the header.

lowerCaseContentLengthHeaderIsUsedForFixedLengthStreamingMode runs ungated in CI: fixed-length streaming mode makes the JDK emit the header itself, so no restricted-headers flag is needed. Before the fix the request goes out chunked with no Content-Length; after, exactly one. contentLengthHeaderIsNotDuplicatedForBodylessRequest covers the wire-level duplicate from #2862; it stays gated on sun.net.http.allowRestrictedHeaders since the duplicate only reaches the wire with that flag set.

@alhudz

alhudz commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

any update?

@velo

velo commented Jul 28, 2026

Copy link
Copy Markdown
Member

Thanks for the fix and the wire-level regression test! We ended up merging #3448, which removes the manual Content-Length write from the header loop entirely and lets the JDK own the header — that also covers the bodyless-request duplicate this PR guards against (the "0" fallback now fires exactly once). Holding off on merging this one to avoid stacking two untested-in-combination changes to the same code path; if you think the containsKey guard is still needed on top of #3448, happy to revisit.

@alhudz

alhudz commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Checked this against current master with #3448 in, by running my gated regression test on top of master without the containsKey guard:

repro: body-less POST with @Headers("Content-Length: 0") and sun.net.http.allowRestrictedHeaders=true
before #3448: recorded Content-Length = ["0", "0"]
on master now: recorded Content-Length = ["0"], test passes with no DefaultClient change

So agreed, the guard isn't needed any more. The header loop no longer writes Content-Length, which leaves the body-less 0 fallback as the only writer. Fine to close this one. If wire-level coverage for the #2862 case is worth keeping (the test in #3448 is mock-level and covers the body path), I can strip this down to just the gated test.

@velo velo 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.

Two things here: the branch is out of date, and I think the fix targets the wrong line.

The branch conflicts

DefaultClient#convertAndSend was reworked on master by #3507 (issue 2068 — empty body content type). The if (body == null && request.httpMethod().isWithBody()) block this patch edits is now } else if (request.httpMethod().isWithBody()) { with a longer explanatory comment. Please rebase onto master.

The duplicate can't happen with canonical casing

Look at the header loop just above (DefaultClient.java:170-188):

for (String value : request.headers().get(field)) {
  if (field.equals(CONTENT_LENGTH)) {
    if (!gzipEncodedRequest && !deflateEncodedRequest) {
      contentLength = Integer.valueOf(value);
    }
  }
  ...

When the field is Content-Length, the value is captured into the local contentLength and never forwarded via addRequestProperty. So for a user-supplied @Headers("Content-Length: 0") on a body-less request there is exactly one Content-Length on the wire — the one added at the end. Guarding that final addRequestProperty with containsKey(CONTENT_LENGTH) would drop the header entirely rather than de-duplicate it.

The duplicate you're seeing almost certainly comes from non-canonical casing. request.headers() is a String.CASE_INSENSITIVE_ORDER TreeMap (RequestTemplate.java:867) that preserves the caller's original casing, but line 175 compares with equals. So @Headers("content-length: 0") misses that branch, falls through to addRequestProperty("content-length", "0"), and then line 228 adds Content-Length: 0 — two headers.

Suggested fix

Change line 175 to field.equalsIgnoreCase(CONTENT_LENGTH). That fixes the actual duplicate, keeps the existing "let HttpURLConnection own the header" behaviour, and also fixes the related bug where a lower-cased Content-Length isn't picked up for setFixedLengthStreamingMode under disableRequestBuffering.

Test coverage

contentLengthHeaderIsNotDuplicatedForBodylessRequest is gated on @EnabledIfSystemProperty(named = "sun.net.http.allowRestrictedHeaders", matches = "true"), so it is skipped in CI as things stand — the regression it guards wouldn't be caught. Please either arrange for that property to be set for this test class, or write the assertion so it works without restricted headers enabled.

@alhudz
alhudz force-pushed the content-length-duplicate branch from 9e4e847 to 982fc33 Compare August 13, 2026 09:06
@alhudz

alhudz commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on the casing, that's exactly it. With canonical casing the value is captured and never forwarded, so there's no duplicate; a lower-cased content-length slips past the equals and gets forwarded on top of the fallback. Rebased onto master and replaced the guard with equalsIgnoreCase on the comparison, as suggested.

repro: body-less POST with a lower-cased content-length: 0 header, sun.net.http.allowRestrictedHeaders=true
before: recorded Content-Length = ["0", "0"]
after: ["0"]

For CI coverage I used the disableRequestBuffering side of the same comparison: the new ungated test sends a POST with a body and a lower-cased content-length header, and asserts the request goes out with fixed-length streaming, where the JDK writes the header itself, so it needs no restricted-headers flag. Before the fix it goes out chunked with no Content-Length and fails; after, it records exactly one. So a revert of the equalsIgnoreCase now fails in CI. The wire-level duplicate itself only reaches the wire with restricted headers enabled, so that test stays gated, updated to the lower-cased repro; happy to drop it if you'd rather not carry a gated test.

Full feign-core suite is green locally (652 tests) and validate-code-format is clean. PR body updated to match the new approach.

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