Skip to content

[client-v2] Response silently truncated when a proxy ignores Accept-Encoding: lz4 (useHttpCompression mode) #3163

Description

@claude

Description

When the client is configured with useHttpCompression(true) + compressServerResponse(true), it sends Accept-Encoding: lz4 and then assumes the response body is LZ4-framed. Accept-Encoding is only a hint — the server, or any proxy in between (Cloudflare, nginx, an API gateway), is free to return the body uncompressed, typically for small payloads.

HttpAPIClientHelper.wrapResponseEntity does check Content-Encoding for the HTTP-compression branch, but when the header is absent it falls through to an unconditional LZ4 wrap:

https://github.com/ClickHouse/clickhouse-java/blob/main/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java#L1039-L1055

if (httpEntity.getContentEncoding() != null) {
    return new CompressedEntity(httpEntity, true, CompressorStreamFactory.getSingleton());
}

// data compression
if (serverCompression && !(httpStatus == SC_FORBIDDEN || httpStatus == SC_UNAUTHORIZED)) {
    return new LZ4Entity(httpEntity, useHttpCompression, true, false, buffSize, true, lz4Factory);
}

LZ4Entity.getContent() then does:

https://github.com/ClickHouse/clickhouse-java/blob/main/client-v2/src/main/java/com/clickhouse/client/api/internal/LZ4Entity.java#L52-L63

InputStream content = httpEntity.getContent();
try {
    return new FramedLZ4CompressorInputStream(content);
} catch (IOException e) {
    // ... "easiest way to handle empty content"
    return content;
}

FramedLZ4CompressorInputStream's constructor consumes the 4 signature bytes before deciding the stream is not LZ4-framed. The catch swallows that and returns the same, already-advanced stream. The result is not an error — it is silent data corruption: the first 4 bytes of every uncompressed response are dropped.

This is the Java analogue of ClickHouse/clickhouse-rs#483, but with a worse failure mode (silent truncation rather than a decompression error).

Note this only affects the HTTP-compression mode. In the default mode (useHttpCompression=false) the client uses the compress=1 query parameter, which is ClickHouse's native block compression and is not subject to HTTP content negotiation, so a proxy cannot turn it off.

ClickHouse server version

Verified against server 26.9.6.6 for general sanity; the reproduction itself uses WireMock to stand in for the non-compressing proxy, since the corruption is entirely client-side.

Reproduction

Add to client-v2/src/test/java/com/clickhouse/client/api/:

package com.clickhouse.client.api;

import com.clickhouse.client.api.query.QueryResponse;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

/**
 * Simulates a proxy that ignores Accept-Encoding: lz4 and returns a plain,
 * uncompressed body with no Content-Encoding response header.
 */
public class NoContentEncodingScratchTest {

    private static final String BODY = "{\"num\":1}\n{\"num\":2}\n";

    private WireMockServer mockServer;

    @BeforeMethod
    public void setUp() {
        mockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        mockServer.start();
        mockServer.stubFor(WireMock.post(WireMock.anyUrl())
                .willReturn(WireMock.aResponse().withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        // NOTE: no Content-Encoding header, body is NOT compressed
                        .withBody(BODY)));
    }

    @AfterMethod
    public void tearDown() {
        if (mockServer != null) {
            mockServer.stop();
        }
    }

    private String read(Client client) throws Exception {
        try (QueryResponse response = client.query("SELECT 1 AS num").get();
             InputStream in = response.getInputStream()) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            byte[] buf = new byte[1024];
            int n;
            while ((n = in.read(buf)) > 0) {
                out.write(buf, 0, n);
            }
            return new String(out.toByteArray(), StandardCharsets.UTF_8);
        }
    }

    @Test
    public void testHttpCompressionUncompressedResponse() throws Exception {
        try (Client client = new Client.Builder()
                .addEndpoint("http://localhost:" + mockServer.port())
                .setUsername("default").setPassword("")
                .setDefaultDatabase("default")
                .compressServerResponse(true)
                .useHttpCompression(true)
                .build()) {
            Assert.assertEquals(read(client), BODY);
        }
    }

    @Test
    public void testNoCompressionBaseline() throws Exception {
        try (Client client = new Client.Builder()
                .addEndpoint("http://localhost:" + mockServer.port())
                .setUsername("default").setPassword("")
                .setDefaultDatabase("default")
                .compressServerResponse(false)
                .build()) {
            Assert.assertEquals(read(client), BODY);
        }
    }
}

Run:

mvn -pl client-v2 -Dtest=NoContentEncodingScratchTest test

Result:

[ERROR] Failures:
[ERROR]   NoContentEncodingScratchTest.testHttpCompressionUncompressedResponse:68 expected [{"num":1}
{"num":2}
] but found [m":1}
{"num":2}
]
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
  • Expected: {"num":1}\n{"num":2}\n
  • Actual: m":1}\n{"num":2}\n — the leading {"nu (the 4 bytes consumed by the LZ4 frame-signature check) is gone.

The testNoCompressionBaseline case passes, confirming the corruption comes from the compression wrapper and not from the test harness.

Suggested fix

In HttpAPIClientHelper.wrapResponseEntity (client-v2/.../internal/HttpAPIClientHelper.java:1039-1055), when useHttpCompression is true the decision should be driven solely by the response's Content-Encoding header — if it is absent, return the entity unwrapped rather than falling through to the serverCompression LZ4 branch (which is meant for the native compress=1 mode, where useHttpCompression is false).

Independently, the catch (IOException) in LZ4Entity.getContent() (client-v2/.../internal/LZ4Entity.java:57-63) should not return a partially-consumed stream. If that fallback is kept for the empty-body case, the underlying stream needs to be buffered/pushback-wrapped so the signature bytes can be replayed, or the emptiness should be detected explicitly instead of inferred from a thrown exception.

Link

Analogous upstream report: ClickHouse/clickhouse-rs#483

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions