From b8df75f31e98d7893a9520f691d396257d35170c Mon Sep 17 00:00:00 2001 From: Daniel Lower Date: Tue, 10 Mar 2026 17:53:12 +1300 Subject: [PATCH] fix: escape control characters in query parameter values Named query parameters are deserialized by ClickHouse using deserializeTextEscaped (TSV format). A raw tab byte (0x09) is treated as a TSV field delimiter and causes error 457 (BAD_QUERY_PARAMETER): "isn't parsed completely". Fix encodeFieldDump (native TCP path) to TSV-escape control characters (\t, \n, \r, \\, \0) before sending over the wire. Add escapeQueryParam for the same escaping on the HTTP path, where values are sent as URL query parameters. This matches the behaviour of the official C++ and Python ClickHouse SDKs, which both apply escaping transparently so callers can pass raw Go/Python string values without knowing the wire format. Co-Authored-By: Claude Sonnet 4.6 --- conn_http.go | 2 +- lib/proto/query.go | 32 +++++++++++++-- lib/proto/query_test.go | 75 ++++++++++++++++++++++++++++++++++ query_parameters.go | 27 ++++++++++++ tests/query_parameters_test.go | 33 +++++++++++++++ 5 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 lib/proto/query_test.go diff --git a/conn_http.go b/conn_http.go index 0ac2f87734..eaa7ae08d6 100644 --- a/conn_http.go +++ b/conn_http.go @@ -639,7 +639,7 @@ func (h *httpConnect) createRequest(ctx context.Context, requestUrl string, read query.Set(key, fmt.Sprint(value)) } for key, value := range options.parameters { - query.Set(fmt.Sprintf("param_%s", key), value) + query.Set(fmt.Sprintf("param_%s", key), escapeQueryParam(value)) } req.URL.RawQuery = query.Encode() } diff --git a/lib/proto/query.go b/lib/proto/query.go index b6f0c44a2b..ed5ba7fa7d 100644 --- a/lib/proto/query.go +++ b/lib/proto/query.go @@ -218,13 +218,37 @@ func (s *Parameter) encode(buffer *chproto.Buffer, revision uint64) error { return nil } -// encodes a field dump with an appropriate type format -// implements the same logic as in ClickHouse Field::restoreFromDump (https://github.com/ClickHouse/ClickHouse/blob/master/src/Core/Field.cpp#L312) -// currently, only string type is supported +// encodeFieldDump encodes a query parameter value for transmission over the native TCP protocol. +// ClickHouse deserializes named query parameters using TSV escaped format (deserializeTextEscaped), +// so control characters must be escaped: a raw 0x09 tab byte is treated as a TSV field delimiter +// and causes error 457 (BAD_QUERY_PARAMETER). Values are wrapped in single quotes. +// Currently only string type is supported. func encodeFieldDump(value any) (string, error) { switch v := value.(type) { case string: - return fmt.Sprintf("'%v'", strings.ReplaceAll(v, "'", "\\'")), nil + var sb strings.Builder + sb.Grow(len(v) + 2) + sb.WriteByte('\'') + for i := 0; i < len(v); i++ { + switch v[i] { + case '\\': + sb.WriteString(`\\`) + case '\'': + sb.WriteString(`\'`) + case '\t': + sb.WriteString(`\t`) + case '\n': + sb.WriteString(`\n`) + case '\r': + sb.WriteString(`\r`) + case '\000': + sb.WriteString(`\0`) + default: + sb.WriteByte(v[i]) + } + } + sb.WriteByte('\'') + return sb.String(), nil } return "", fmt.Errorf("unsupported field type %T", value) diff --git a/lib/proto/query_test.go b/lib/proto/query_test.go new file mode 100644 index 0000000000..04a625e7bd --- /dev/null +++ b/lib/proto/query_test.go @@ -0,0 +1,75 @@ +package proto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeFieldDump(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "plain string unchanged", + input: "hello", + expected: "'hello'", + }, + { + name: "tab is TSV-escaped", + input: "hello\tworld", + expected: `'hello\tworld'`, + }, + { + name: "newline is TSV-escaped", + input: "hello\nworld", + expected: `'hello\nworld'`, + }, + { + name: "carriage return is TSV-escaped", + input: "hello\rworld", + expected: `'hello\rworld'`, + }, + { + name: "backslash is doubled", + input: `hello\world`, + expected: `'hello\\world'`, + }, + { + name: "single quote is escaped", + input: "it's", + expected: `'it\'s'`, + }, + { + name: "null byte is escaped", + input: "hello\x00world", + expected: `'hello\0world'`, + }, + { + name: "multiple special chars", + input: "a\tb\nc", + expected: `'a\tb\nc'`, + }, + { + name: "empty string", + input: "", + expected: "''", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := encodeFieldDump(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, got) + }) + } + + t.Run("unsupported type returns error", func(t *testing.T) { + _, err := encodeFieldDump(42) + assert.Error(t, err) + }) +} diff --git a/query_parameters.go b/query_parameters.go index bb11b77d7a..c2ead49164 100644 --- a/query_parameters.go +++ b/query_parameters.go @@ -3,6 +3,7 @@ package clickhouse import ( "errors" "regexp" + "strings" "time" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" @@ -60,6 +61,32 @@ func bindQueryOrAppendParameters(paramsProtocolSupport bool, options *QueryOptio return bind(timezone, query, args...) } +// escapeQueryParam applies TSV escaping to a query parameter value. +// ClickHouse deserializes named query parameters using deserializeTextEscaped, +// so control characters must be escaped before sending over any transport. +// A raw tab (0x09) is treated as a TSV field delimiter and causes error 457. +func escapeQueryParam(v string) string { + var sb strings.Builder + sb.Grow(len(v)) + for i := 0; i < len(v); i++ { + switch v[i] { + case '\\': + sb.WriteString(`\\`) + case '\t': + sb.WriteString(`\t`) + case '\n': + sb.WriteString(`\n`) + case '\r': + sb.WriteString(`\r`) + case '\000': + sb.WriteString(`\0`) + default: + sb.WriteByte(v[i]) + } + } + return sb.String() +} + func formatTimeWithScale(t time.Time, scale TimeUnit) string { switch scale { case MicroSeconds: diff --git a/tests/query_parameters_test.go b/tests/query_parameters_test.go index e3bb84d6aa..de91e9a56a 100644 --- a/tests/query_parameters_test.go +++ b/tests/query_parameters_test.go @@ -134,4 +134,37 @@ func TestQueryParameters(t *testing.T) { assert.Equal(t, uint8(42), actualNum) assert.Equal(t, "hello", actualStr) }) + + t.Run("string with special characters", func(t *testing.T) { + TestProtocols(t, func(t *testing.T, protocol clickhouse.Protocol) { + conn, err := GetNativeConnection(t, protocol, nil, nil, nil) + require.NoError(t, err) + + cases := []struct { + name string + value string + }{ + {"tab", "hello\tworld"}, + {"newline", "hello\nworld"}, + {"carriage return", "hello\rworld"}, + {"backslash", `hello\world`}, + {"single quote", "it's"}, + {"null byte", "hello\x00world"}, + {"multiple special chars", "a\tb\nc"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + chCtx := clickhouse.Context(ctx, clickhouse.WithParameters(clickhouse.Parameters{ + "str": tc.value, + })) + var result string + row := conn.QueryRow(chCtx, "SELECT {str:String}") + require.NoError(t, row.Err()) + require.NoError(t, row.Scan(&result)) + assert.Equal(t, tc.value, result) + }) + } + }) + }) }