diff --git a/.changeset/proxy-parsed-bodies.md b/.changeset/proxy-parsed-bodies.md new file mode 100644 index 0000000000..b947b44a10 --- /dev/null +++ b/.changeset/proxy-parsed-bodies.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/api': patch +--- + +Forward the bytes consumed by the ClickHouse proxy's body parsers without +changing JSON values, form fields or text encodings. Keep request framing in +sync with inflated bytes and preserve unparsed multipart streams. diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index c2d9028a15..cf19282309 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -22,6 +22,7 @@ import usageStats from './tasks/usageStats'; import { registerAITelemetry } from './utils/aiTelemetry'; import logger, { expressLogger } from './utils/logger'; import passport from './utils/passport'; +import { captureProxyBody } from './utils/proxyBody'; registerAITelemetry(); @@ -56,9 +57,15 @@ if (!config.IS_CI && config.FRONTEND_URL) { app.disable('x-powered-by'); app.use(compression()); -app.use(express.json({ limit: '32mb' })); -app.use(express.text({ limit: '32mb' })); -app.use(express.urlencoded({ extended: false, limit: '32mb' })); +app.use(express.json({ limit: '32mb', verify: captureProxyBody })); +app.use(express.text({ limit: '32mb', verify: captureProxyBody })); +app.use( + express.urlencoded({ + extended: false, + limit: '32mb', + verify: captureProxyBody, + }), +); app.use(session(sess)); if (!config.IS_LOCAL_APP_MODE) { diff --git a/packages/api/src/routers/api/__tests__/clickhouseProxy.int.test.ts b/packages/api/src/routers/api/__tests__/clickhouseProxy.int.test.ts index 23d5c2e5ca..9e09c5406f 100644 --- a/packages/api/src/routers/api/__tests__/clickhouseProxy.int.test.ts +++ b/packages/api/src/routers/api/__tests__/clickhouseProxy.int.test.ts @@ -1,8 +1,10 @@ import { buildLogComment } from '@hyperdx/common-utils/dist/clickhouse'; import http from 'http'; +import { gzipSync } from 'zlib'; import { getLoggedInAgent, getServer } from '@/fixtures'; import Connection from '@/models/connection'; +import logger from '@/utils/logger'; // Use the real proxy instead of the blanket mock in jest.setup.ts; the int // jest config transpiles it (see jest.int.config.js). @@ -16,14 +18,11 @@ type CapturedRequest = { }; /** - * Pins what /clickhouse-proxy forwards upstream: text/plain re-injection, the - * raw-stream passthrough of multipart/form-data bodies, which + * Pins what /clickhouse-proxy forwards upstream: captured parser bytes with + * valid framing, the raw-stream passthrough of multipart/form-data bodies, which * `@clickhouse/client-web` emits when query params exceed its URL budget * (regression context: ClickHouse support-escalation #8482), and the * decode/re-encode the query string goes through in `pathRewrite`. - * - * Known-latent cases (charset-suffixed JSON, urlencoded) are not covered; - * tracked in https://github.com/hyperdxio/hyperdx/issues/2942. */ describe('clickhouse-proxy forwarding', () => { const server = getServer(); @@ -66,6 +65,7 @@ describe('clickhouse-proxy forwarding', () => { }); afterAll(async () => { + upstream.closeAllConnections(); await new Promise((resolve, reject) => upstream.close(err => (err ? reject(err) : resolve())), ); @@ -101,6 +101,217 @@ describe('clickhouse-proxy forwarding', () => { expect(captured[0].headers['x-clickhouse-user']).toBe('default'); }); + it.each(['application/json', 'application/json; charset=utf-8'])( + 'preserves %s bytes and Content-Length', + async contentType => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + const body = { + query: "SELECT '東京 🍰' FORMAT JSON", + param_name: 'café', + }; + const original = JSON.stringify(body, null, 2); + expect(Buffer.byteLength(original)).toBeGreaterThan(original.length); + + await agent + .post('/clickhouse-proxy/?query_id=test-json') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', contentType) + .send(original) + .timeout({ response: 5000, deadline: 10000 }) + .expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body).toEqual(Buffer.from(original)); + expect(captured[0].headers['content-type']).toBe(contentType); + expect(captured[0].headers['content-length']).toBe( + String(Buffer.byteLength(original)), + ); + expect(captured[0].headers['transfer-encoding']).toBeUndefined(); + }, + ); + + it('preserves parsed UTF-16LE JSON bytes and charset', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + const body = { query: "SELECT '東京 🍰' FORMAT JSON" }; + const original = Buffer.from(JSON.stringify(body, null, 2), 'utf16le'); + + const proxyRequest = agent + .post('/clickhouse-proxy/?query_id=test-utf16-json') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', 'application/json; charset=utf-16le') + .set('Content-Length', String(original.length)) + .timeout({ response: 5000, deadline: 10000 }); + proxyRequest.write(original); + await proxyRequest.expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body).toEqual(original); + expect(captured[0].headers['content-type']).toBe( + 'application/json; charset=utf-16le', + ); + expect(captured[0].headers['content-length']).toBe(String(original.length)); + expect(captured[0].headers['content-encoding']).toBeUndefined(); + }); + + it('forwards parsed gzip JSON without the original content encoding', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + const body = { query: "SELECT 'café' FORMAT JSON" }; + const serialized = Buffer.from(JSON.stringify(body, null, 2)); + const original = gzipSync(serialized); + + const proxyRequest = agent + .post('/clickhouse-proxy/?query_id=test-gzip-json') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Content-Encoding', 'gzip') + .set('Content-Length', String(original.length)) + .timeout({ response: 5000, deadline: 10000 }); + proxyRequest.write(original); + await proxyRequest.expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body).toEqual(serialized); + expect(captured[0].headers['content-type']).toBe( + 'application/json; charset=utf-8', + ); + expect(captured[0].headers['content-length']).toBe( + String(serialized.length), + ); + expect(captured[0].headers['content-encoding']).toBeUndefined(); + }); + + it.each([ + 'application/x-www-form-urlencoded', + 'application/x-www-form-urlencoded; charset=utf-8', + ])('preserves %s bytes and repeated form keys', async contentType => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + const original = + 'query=SELECT%20%27caf%C3%A9%27¶m=first¶m=second%20value&empty=&literal=%2B%26%3D'; + const entries = [ + ['query', "SELECT 'café'"], + ['param', 'first'], + ['param', 'second value'], + ['empty', ''], + ['literal', '+&='], + ]; + + await agent + .post('/clickhouse-proxy/?query_id=test-urlencoded') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', contentType) + .send(original) + .timeout({ response: 5000, deadline: 10000 }) + .expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body.toString()).toBe(original); + expect([...new URLSearchParams(captured[0].body.toString())]).toEqual( + entries, + ); + expect(captured[0].headers['content-type']).toBe(contentType); + expect(captured[0].headers['content-length']).toBe( + String(Buffer.byteLength(original)), + ); + }); + + it('keeps chunked framing when forwarding captured JSON bytes', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + const body = { query: "SELECT 'café' FORMAT JSON" }; + const original = JSON.stringify(body, null, 2); + const proxyRequest = agent + .post('/clickhouse-proxy/?query_id=test-chunked-json') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Transfer-Encoding', 'chunked') + .timeout({ response: 5000, deadline: 10000 }); + + proxyRequest.write(original.slice(0, 10)); + proxyRequest.write(original.slice(10)); + await proxyRequest.expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body.toString()).toBe(original); + expect(captured[0].headers['transfer-encoding']).toBe('chunked'); + expect(captured[0].headers['content-length']).toBeUndefined(); + }); + + it.each(['/clickhouse-proxy/', '/CLICKHOUSE-PROXY/'])( + 'preserves JSON number literals and duplicate keys on %s', + async route => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + const original = + '{"id":9007199254740993,"value":1.0,"value":1e2,"query":"SELECT 1"}'; + + await agent + .post(`${route}?query_id=test-json-fidelity`) + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', 'application/json; charset=utf-8') + .send(original) + .timeout({ response: 5000, deadline: 10000 }) + .expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body).toEqual(Buffer.from(original)); + }, + ); + + it('preserves an empty JSON body for query-string SQL', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + + await agent + .post('/clickhouse-proxy/?query=SELECT+1') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', 'application/json') + .set('Content-Length', '0') + .timeout({ response: 5000, deadline: 10000 }) + .expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body).toEqual(Buffer.alloc(0)); + expect(captured[0].headers['content-length']).toBe('0'); + }); + + it('preserves bytes under duplicate charset parameters', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + const contentType = 'application/json; charset=utf-8; charset=utf-16le'; + const original = Buffer.from('{"query":"SELECT 1"}', 'utf16le'); + const proxyRequest = agent + .post('/clickhouse-proxy/?query_id=test-duplicate-charset') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', contentType) + .set('Content-Length', String(original.length)) + .timeout({ response: 5000, deadline: 10000 }); + proxyRequest.write(original); + await proxyRequest.expect(200); + + expect(captured).toHaveLength(1); + expect(captured[0].body).toEqual(original); + expect(captured[0].headers['content-type']).toBe(contentType); + expect(captured[0].headers['content-length']).toBe(String(original.length)); + }); + + it('still rejects invalid JSON before forwarding', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connectionId = await createConnection(team._id.toString()); + + await agent + .post('/clickhouse-proxy/') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', 'application/json') + .send('{invalid') + .expect(400); + + expect(captured).toHaveLength(0); + }); + it('forwards a large multipart/form-data body byte-for-byte (unparsed stream passthrough)', async () => { const { agent, team } = await getLoggedInAgent(server); const connectionId = await createConnection(team._id.toString()); @@ -120,25 +331,40 @@ describe('clickhouse-proxy forwarding', () => { `some.rather.long.map.attribute.key.number.${i}\r\n`, ); } - parts.push(`--${boundary}--\r\n`); - const body = parts.join(''); + const body = Buffer.concat([ + Buffer.from(parts.join('')), + Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="bytes.bin"\r\n` + + `Content-Type: application/octet-stream\r\n\r\n`, + ), + Buffer.from([0x00, 0xff, 0x80, 0x0d, 0x0a]), + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); expect(Buffer.byteLength(body)).toBeGreaterThan(4096); - await agent - .post('/clickhouse-proxy/?query_id=test-multipart') - .set('x-hyperdx-connection-id', connectionId) - .set('Content-Type', `multipart/form-data; boundary=${boundary}`) - .send(body) - .expect(200); + const loggerError = jest.spyOn(logger, 'error'); + try { + await agent + .post('/clickhouse-proxy/?query_id=test-multipart') + .set('x-hyperdx-connection-id', connectionId) + .set('Content-Type', `multipart/form-data; boundary=${boundary}`) + .send(body) + .timeout({ response: 5000, deadline: 10000 }) + .expect(200); - expect(captured).toHaveLength(1); - expect(captured[0].headers['content-type']).toBe( - `multipart/form-data; boundary=${boundary}`, - ); - expect(captured[0].body.toString()).toBe(body); - expect(captured[0].headers['content-length']).toBe( - String(Buffer.byteLength(body)), - ); + expect(captured).toHaveLength(1); + expect(captured[0].headers['content-type']).toBe( + `multipart/form-data; boundary=${boundary}`, + ); + expect(captured[0].body).toEqual(body); + expect(captured[0].headers['content-length']).toBe( + String(Buffer.byteLength(body)), + ); + expect(loggerError).not.toHaveBeenCalled(); + } finally { + loggerError.mockRestore(); + } }); // The browser client ships `log_comment` as a URL query param, and diff --git a/packages/api/src/routers/api/clickhouseProxy.ts b/packages/api/src/routers/api/clickhouseProxy.ts index e41946c1b0..d67e00b118 100644 --- a/packages/api/src/routers/api/clickhouseProxy.ts +++ b/packages/api/src/routers/api/clickhouseProxy.ts @@ -11,6 +11,7 @@ import { getNonNullUserWithTeam } from '@/middleware/auth'; import { validateRequestHeaders } from '@/middleware/validation'; import { recordOperationOutcome } from '@/utils/instrumentation'; import logger from '@/utils/logger'; +import { writeProxyBody } from '@/utils/proxyBody'; import { IPV6_BRACKET_RE, isPrivateIp } from '@/utils/validators'; import { objectIdSchema } from '@/utils/zod'; @@ -261,22 +262,7 @@ const proxyMiddleware: RequestHandler = return res.sendStatus(405); } - let body = _req.body; - if (_req.headers['content-type'] === 'application/json') { - try { - body = JSON.stringify(body); - } catch (e) { - console.error(e); - } - } - - try { - proxyReq.write(body); - } catch { - console.error( - `clickhouseProxy error writing body, body is type ${typeof body}`, - ); - } + writeProxyBody(proxyReq, _req); }, proxyRes: (proxyRes, _req, res) => { const startedAt = (res as Response).locals?.hdxProxyStartedAt; diff --git a/packages/api/src/utils/__tests__/proxyBody.test.ts b/packages/api/src/utils/__tests__/proxyBody.test.ts new file mode 100644 index 0000000000..7f9fdd3092 --- /dev/null +++ b/packages/api/src/utils/__tests__/proxyBody.test.ts @@ -0,0 +1,230 @@ +import { IncomingMessage, ServerResponse } from 'http'; +import { Socket } from 'net'; + +import logger from '@/utils/logger'; +import { captureProxyBody, writeProxyBody } from '@/utils/proxyBody'; + +jest.mock('@/utils/logger', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + +const makeProxyRequest = () => ({ + getHeader: jest.fn(), + setHeader: jest.fn(), + removeHeader: jest.fn(), + write: jest.fn().mockReturnValue(true), + destroy: jest.fn().mockReturnThis(), +}); + +describe('ClickHouse proxy body capture', () => { + const makeRequest = ( + path?: string, + url?: string, + ): Parameters[0] => + Object.assign(new IncomingMessage(new Socket()), { path, url }); + const response = new ServerResponse(new IncomingMessage(new Socket())); + + it.each([ + { path: '/clickhouse-proxy', url: '/clickhouse-proxy' }, + { path: '/CLICKHOUSE-PROXY', url: '/CLICKHOUSE-PROXY' }, + { path: '/clickhouse-proxy/', url: '/clickhouse-proxy/' }, + { + path: '/clickhouse-proxy', + url: '/clickhouse-proxy?query=SELECT%201', + }, + { + path: '/clickhouse-proxy/database', + url: '/clickhouse-proxy/database?query=SELECT%201', + }, + { + path: '/clickhouse-proxy', + url: 'http://localhost/clickhouse-proxy?query=SELECT%201', + }, + ])('retains the inflated bytes for $url', ({ path, url }) => { + const req = makeRequest(path, url); + const body = Buffer.from('{ "value": 9007199254740993, "scale": 1.000 }'); + + captureProxyBody(req, response, body); + + expect(req._hdxProxyBody).toBe(body); + }); + + it.each([ + undefined, + '', + '/clickhouse-proxy-other', + '/login', + '/api/clickhouse-proxy', + ])('does not retain bodies for unrelated path %s', path => { + const req = makeRequest(path); + + captureProxyBody(req, response, Buffer.from('private body')); + + expect(req._hdxProxyBody).toBeUndefined(); + }); +}); + +describe('captured proxy request bodies', () => { + it('leaves an unconsumed stream alone', () => { + const proxy = makeProxyRequest(); + + writeProxyBody(proxy, { headers: {} }); + + expect(proxy.write).not.toHaveBeenCalled(); + expect(proxy.getHeader).not.toHaveBeenCalled(); + expect(proxy.setHeader).not.toHaveBeenCalled(); + expect(proxy.removeHeader).not.toHaveBeenCalled(); + expect(proxy.destroy).not.toHaveBeenCalled(); + }); + + it.each([undefined, false])( + 'does not reconstruct a body without captured bytes (_body=%s)', + parsed => { + const proxy = makeProxyRequest(); + const req = { + headers: {}, + _hdxProxyBody: undefined, + body: { query: 'SELECT 1' }, + _body: parsed, + }; + + writeProxyBody(proxy, req); + + expect(proxy.write).not.toHaveBeenCalled(); + expect(proxy.setHeader).not.toHaveBeenCalled(); + expect(proxy.removeHeader).not.toHaveBeenCalled(); + expect(proxy.destroy).not.toHaveBeenCalled(); + }, + ); + + it('destroys the upstream request when a parsed body was not captured', () => { + const proxy = makeProxyRequest(); + const req = { + headers: {}, + _hdxProxyBody: undefined, + body: { query: 'SELECT 1' }, + _body: true, + }; + + writeProxyBody(proxy, req); + + expect(proxy.write).not.toHaveBeenCalled(); + expect(proxy.setHeader).not.toHaveBeenCalled(); + expect(proxy.destroy).toHaveBeenCalledTimes(1); + const [[error]] = proxy.destroy.mock.calls; + expect(error).toBeInstanceOf(Error); + expect(logger.error).toHaveBeenCalledWith( + { err: error }, + expect.any(String), + ); + }); + + it.each<[string, Buffer]>([ + [ + 'JSON number spelling and whitespace', + Buffer.from('{ "value": 9007199254740993, "scale": 1.000 }'), + ], + [ + 'form field spelling and repeated values', + Buffer.from('query=SELECT%201¶m=a%2bb¶m=c+d&empty='), + ], + ['binary bytes', Buffer.from([0, 255, 10])], + ])('preserves %s', (_description, body) => { + const proxy = makeProxyRequest(); + + writeProxyBody(proxy, { headers: {}, _hdxProxyBody: body }); + + expect(proxy.write).toHaveBeenCalledTimes(1); + expect(proxy.write).toHaveBeenCalledWith(body); + expect(proxy.setHeader).toHaveBeenCalledWith('content-length', body.length); + expect(proxy.destroy).not.toHaveBeenCalled(); + }); + + it('forwards an empty captured body with a zero content length', () => { + const proxy = makeProxyRequest(); + const body = Buffer.alloc(0); + + writeProxyBody(proxy, { headers: {}, _hdxProxyBody: body }); + + expect(proxy.write).toHaveBeenCalledWith(body); + expect(proxy.setHeader).toHaveBeenCalledWith('content-length', 0); + expect(proxy.destroy).not.toHaveBeenCalled(); + }); + + it('uses the captured bytes instead of the parsed body', () => { + const proxy = makeProxyRequest(); + const body = Buffer.from('{ "value": 9007199254740993 }'); + const req = { + headers: {}, + _hdxProxyBody: body, + body: { value: 9007199254740992 }, + _body: true, + }; + + writeProxyBody(proxy, req); + + expect(proxy.write).toHaveBeenCalledWith(body); + }); + + it('frames the byte length of a multibyte payload', () => { + const proxy = makeProxyRequest(); + const text = "SELECT 'café'"; + const body = Buffer.from(text); + + writeProxyBody(proxy, { headers: {}, _hdxProxyBody: body }); + + expect(body.length).toBeGreaterThan(text.length); + expect(proxy.setHeader).toHaveBeenCalledWith('content-length', body.length); + expect(proxy.write).toHaveBeenCalledWith(body); + }); + + it('removes compression metadata and preserves the original charset', () => { + const proxy = makeProxyRequest(); + const body = Buffer.from("SELECT 'café'", 'utf16le'); + const req = { + _hdxProxyBody: body, + headers: { + 'content-type': 'text/plain; charset="utf-16le"', + 'content-encoding': 'gzip', + }, + }; + + writeProxyBody(proxy, req); + + expect(proxy.removeHeader).toHaveBeenCalledWith('content-encoding'); + expect(proxy.removeHeader).not.toHaveBeenCalledWith('content-type'); + expect(proxy.setHeader).toHaveBeenCalledTimes(1); + expect(proxy.setHeader).toHaveBeenCalledWith('content-length', body.length); + expect(proxy.write).toHaveBeenCalledWith(body); + }); + + it('keeps transfer encoding without a conflicting content length', () => { + const proxy = makeProxyRequest(); + const body = Buffer.from('SELECT 1'); + proxy.getHeader.mockReturnValue('chunked'); + + writeProxyBody(proxy, { headers: {}, _hdxProxyBody: body }); + + expect(proxy.getHeader).toHaveBeenCalledWith('transfer-encoding'); + expect(proxy.removeHeader).toHaveBeenCalledWith('content-length'); + expect(proxy.removeHeader).toHaveBeenCalledWith('content-encoding'); + expect(proxy.setHeader).not.toHaveBeenCalled(); + expect(proxy.write).toHaveBeenCalledWith(body); + }); + + it('destroys the upstream request when a body write fails', () => { + const proxy = makeProxyRequest(); + const error = new Error('write failed'); + proxy.write.mockImplementation(() => { + throw error; + }); + + writeProxyBody(proxy, { + headers: {}, + _hdxProxyBody: Buffer.from('SELECT 1'), + }); + + expect(proxy.destroy).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/api/src/utils/proxyBody.ts b/packages/api/src/utils/proxyBody.ts new file mode 100644 index 0000000000..5f0a50aaff --- /dev/null +++ b/packages/api/src/utils/proxyBody.ts @@ -0,0 +1,54 @@ +import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; + +import logger from '@/utils/logger'; + +type ParsedRequest = Pick & { + _hdxProxyBody?: Buffer; + // Set by body-parser before it reads the stream. + _body?: boolean; +}; + +type ProxyRequest = Pick< + ClientRequest, + 'getHeader' | 'setHeader' | 'removeHeader' | 'write' | 'destroy' +>; + +export function captureProxyBody( + req: IncomingMessage & ParsedRequest & { path?: string }, + _res: ServerResponse, + body: Buffer, +) { + if (/^\/clickhouse-proxy(?:\/|$)/i.test(req.path ?? '')) { + // verify receives inflated bytes before parsing can change their values. + req._hdxProxyBody = body; + } +} + +export function writeProxyBody(proxyReq: ProxyRequest, req: ParsedRequest) { + const body = req._hdxProxyBody; + if (body === undefined) { + // No parser consumed this stream, so let the proxy pipe the original bytes. + if (!req._body) return; + + // A drained stream would leave upstream waiting on the original length. + const err = new Error('ClickHouse proxy body was parsed but not captured'); + logger.error({ err }, 'Unable to forward ClickHouse proxy body'); + proxyReq.destroy(err); + return; + } + + try { + proxyReq.removeHeader('content-encoding'); + if (proxyReq.getHeader('transfer-encoding')) { + proxyReq.removeHeader('content-length'); + } else { + proxyReq.setHeader('content-length', body.length); + } + proxyReq.write(body); + } catch (err) { + logger.error({ err }, 'Unable to forward ClickHouse proxy body'); + proxyReq.destroy( + err instanceof Error ? err : new Error('Unable to forward proxy body'), + ); + } +}