shashank created CAMEL-25573:
--------------------------------
Summary: camel-netty-http - unmarshal().gzipDeflater() of a
request body fails with "Input is not in the .gz format", and a byte-by-byte
read of the body ends at the first 0xFF byte or throws at its end
Key: CAMEL-25573
URL: https://issues.apache.org/jira/browse/CAMEL-25573
Project: Camel
Issue Type: Bug
Components: camel-netty-http
Reporter: shashank
Assignee: shashank
The body of a request received by a netty-http consumer is a
{{NettyChannelBufferStreamCache}} ({{DefaultNettyHttpBinding.toCamelMessage}},
lines 119-123 of main 2a0a2b54f4ba; not with {{disableStreamCache=true}} or
{{httpProxy=true}}). The class is a {{java.io.InputStream}}, and a route step
that asks for the body as an {{InputStream}} gets this object. Its {{read()}}
does not keep the {{InputStream}} contract. With the default configuration:
# Loud: {{from("netty-http:...").unmarshal().gzipDeflater()}} does not work.
Every request fails with HTTP 500 and {{java.io.IOException: Input is not in
the .gz format.}} in the log. The same holds for any reader that reads the
first bytes one by one (to sniff a header, for example): it gets wrong values
for bytes from 0x80, or an {{IndexOutOfBoundsException}} at the end of the body.
# Silent, and narrower: user code or a library that reads the body byte by byte
({{while ((b = in.read()) != -1)}}) gets only the bytes before the first 0xFF,
without any error, and negative values for bytes from 0x80. A byte 0xFF is
common in binary payloads and never occurs in UTF-8 text, so text bodies are
not cut; for them the loop ends with the {{IndexOutOfBoundsException}} (loud).
Not affected: everything that reads in bulk. The type converters of Camel to
{{byte[]}} and {{String}} ({{StreamCacheConverter.convertToByteArray}} calls
{{writeTo}}) and {{NettyHttpConverter.toByteBuf}} read with {{read(byte[], int,
int)}}, which is why most routes do not see this. Silent loss is not the
general case.
The causes in the class:
* {{read()}} (line 56) is {{return buffer.readByte();}}. {{ByteBuf.readByte()}}
returns a Java {{byte}} (-128 to 127) and throws {{IndexOutOfBoundsException}}
when nothing is left. {{InputStream.read()}} must return "an int in the range 0
to 255" and -1 at the end of the stream. So a byte 0xFF is returned as -1,
which every reader takes for the end of the stream, bytes 0x80 to 0xFE are
returned as negative values, and at the end of the body the method throws
instead of returning -1.
* {{markSupported()}} (line 51) returns true, but {{mark(int)}} is not
overridden (it is the empty method of {{InputStream}}). The gzip reader of
commons-compress uses a stream with {{markSupported()}} as it is, so it reads
the magic number with {{read()}} and gets -117 for the byte 0x8b.
({{java.util.zip.GZIPInputStream}} fails with
{{NettyChannelBufferStreamCache.read() returned value out of range -1..255:
-117}}.)
* {{read(byte[], int, int)}} (lines 65-78) takes {{buffer.capacity()}} for the
end of the content; for a buffer whose capacity is larger than its writer index
it throws {{IndexOutOfBoundsException}} at the end of the content. (The
aggregated content of a request has no spare capacity, so this one is not
reached by the consumer today.)
h3. Reproduction
New {{NettyHttpGzipRequestBodyTest}} (4 tests, real consumer and producer) and
{{NettyChannelBufferStreamCacheTest}} (7 tests on the class). With the class of
main 10 of the 11 fail:
* {{gzipDataFormatUnmarshalsTheRequestBody}},
{{resetReturnsTheWholeRequestBodyAfterTheGzipUnmarshal}},
{{writeToReturnsTheWholeRequestBodyAfterTheGzipUnmarshal}}:
{{NettyHttpOperationFailedException ... statusCode: 500}}, in the log of the
consumer {{java.io.IOException: Input is not in the .gz format.}} (all three
fail at the unmarshal)
* {{processorReadsTheRequestBodyByteByByte}} (body {{10 20 FF 30 40}}, the
route counts the bytes with {{read()}}): {{expected: <5> but was: <2>}}
* {{readReturnsEveryByteAsUnsignedValue}}: {{expected: <128> but was: <-128>}}
* {{readLoopCopiesBinaryContent}}: {{array lengths differ, expected: <5> but
was: <2>}}
* {{readLoopEndsAtTheEndOfText}}: {{IndexOutOfBounds readerIndex(5) + length(1)
exceeds writerIndex(5)}}
* {{markIsNotSupported}}: {{expected: <false> but was: <true>}}
* {{readStopsAtTheLastWrittenByte}}: {{IndexOutOfBounds readerIndex(0) +
length(16) exceeds writerIndex(3)}}
* {{gzipInputStreamReadsItsHeader}}: {{IOException:
...NettyChannelBufferStreamCache.read() returned value out of range -1..255:
-117}}
{{resetAndWriteToReturnTheWholeBodyAfterAReaderThatMarks}} passes on main too
(there {{mark}} does nothing either); it guards the fix against a {{mark(int)}}
that moves the reset position, see below.
h3. Proposed fix
* {{read()}} returns {{buffer.readByte() & 0xFF}}, and -1 when the buffer has
no readable byte.
* {{markSupported()}} returns false. {{mark(int)}} stays the empty method and
{{reset()}} is not changed: it goes back to the start of the body, as
{{StreamCache.reset()}} needs. A reader that needs mark support wraps the
stream (the gzip reader of commons-compress does that by itself with a
{{BufferedInputStream}}).
* {{read(byte[], int, int)}} uses {{buffer.readableBytes()}}.
Why not implement {{mark(int)}}: {{reset()}}, {{writeTo()}} (used by the
converters to {{byte[]}} and {{String}}) and {{NettyHttpConverter.toByteBuf}}
all go back to the marked reader index of the buffer, which today is always the
start because only the constructor marks. If {{mark(int)}} moved it, a reader
that marks in the middle (the gzip reader marks before every buffer of 8192
bytes) would leave the stream cache returning only its tail: with that variant
the tests got 3634 of 20028 bytes from {{reset()}} and {{writeTo()}} after the
gzip unmarshal. A stream cache without mark support whose {{reset()}} goes back
to the start exists already: {{FileInputStreamCache}} (line 59, no
{{markSupported}} or {{mark}} override; {{reset()}} at line 89 drops the open
stream, so the next read starts at the beginning of the file). The in-memory
stream caches of camel-support differ: they are, or delegate to, a
{{ByteArrayInputStream}}, which supports mark.
Behaviour change: code that reads the body byte by byte now gets values from 0
to 255 and -1 at the end, and {{markSupported()}} is false. An upgrade note for
4.24 is included (the fix creates {{camel-4x-upgrade-guide-4_24.adoc}}). In the
main sources of Camel the only caller of {{markSupported()}} on a body stream
that can be this class is {{BlobClientWrapper}} of camel-azure-storage-blob
(line 137; read, not run). camel-netty-http with the fix: 312 tests, 0
failures, 8 skipped (skipped before as well).
Not run: a byte-by-byte reader of a text body in a route (the exception at the
end is shown on the class only), a dead letter channel with
{{useOriginalMessage}}.
Related, and independent of this ticket: the gzip data format of
camel-zip-deflater moving the mark of the in-memory stream caches of
camel-support has its own ticket. This fix does not rely on that one.
Found with a Lean 4 model of {{read()}} and of the copy loop of a reader: "the
reader gets the whole body" fails for every body with a byte 0xFF (the reader
ends normally with the bytes before it); a body without one ends with the
exception in the tested case. For the fixed {{read()}} the copy loop is proved
to return every body, and main and the fix are proved equal for bytes below
0x80. For {{reset()}} the model shows that no sequence of read, mark and reset
calls of a reader changes where it goes back to (by construction: {{mark}} does
nothing), and gives the 3634 of 20028 bytes for the variant with a marking
{{mark(int)}}. Then confirmed with the consumer as above.
Affected: main; camel-4.22.x, camel-4.18.x and camel-4.14.x have the same lines
(read, not run).
Duplicate check (2026-10-12): JIRA text "NettyChannelBufferStreamCache" (5:
CAMEL-7884, CAMEL-8284, CAMEL-8408, CAMEL-13967, CAMEL-14166, none about
{{read()}} or {{mark}}), "netty-http markSupported" (none), "not in the .gz
format" (none), "netty-http gzip" (1, unrelated), "netty-http InputStream read
negative" (1, unrelated); GitHub pull requests "NettyChannelBufferStreamCache":
1 merged (#25247, locks), none open.
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)