Skip to content

Commit 2f45f75

Browse files
evilgensecYuan325
andauthored
fix(tools/clickhouse,tools/bigquery): validate identifier parameters to prevent injection (#3219)
## Summary This PR fixes identifier-injection vulnerabilities in three built-in tools that interpolate caller-supplied parameters directly into SQL without sanitisation. --- ### 1. `clickhouse-list-tables` — database identifier injection (original fix) `Tool.Invoke` interpolates the caller-supplied `database` parameter directly into a `SHOW TABLES FROM %s` statement: ```go query := fmt.Sprintf("SHOW TABLES FROM %s", database) ``` The existing comment acknowledges the risk. The fix adds a `validIdentifier` regex (`^[A-Za-z_][A-Za-z0-9_]*$`) that must match before interpolation. Concrete payloads on a default ClickHouse deployment: | `database` parameter | rendered query | effect | |---|---|---| | `default LIKE '%secret%'` | `SHOW TABLES FROM default LIKE '%secret%'` | filters listing by attacker-chosen pattern | | `system FORMAT JSONEachRow` | `SHOW TABLES FROM system FORMAT JSONEachRow` | enumerates `system.*` (system.users, etc.) | | `default INTO OUTFILE '/tmp/x.tsv'` | `SHOW TABLES FROM default INTO OUTFILE '/tmp/x.tsv'` | writes to disk on ClickHouse host | --- ### 2. `bigquery-forecast` — backtick + column-name injection Two injection classes: **a. Backtick injection in `history_data` (table identifier path)** ```go historyDataSource = fmt.Sprintf("TABLE `%s`", historyData) ``` When `allowed_datasets` is not configured (the default), no validation runs before this line. An attacker-supplied `history_data` containing a backtick closes the opening delimiter and appends arbitrary SQL: ``` history_data = "ds.tbl` UNION ALL SELECT secret FROM private.pii --" → TABLE `ds.tbl` UNION ALL SELECT secret FROM private.pii --` ``` **b. Column-name injection in `data_col`, `timestamp_col`, `id_cols`** ```go sql := fmt.Sprintf(`... data_col => '%s', timestamp_col => '%s' ...`, dataCol, timestampCol, ...) ``` Column names are interpolated as single-quoted strings with no validation. A value containing `'` breaks out of the string literal context. **Fix:** `ValidTableID` (restricts `history_data` to `[a-zA-Z0-9_]+` components with 1–2 dots) applied in the table-ID else-branch; `ValidColumnName` (`^[a-zA-Z_][a-zA-Z0-9_]*$`) applied to `data_col`, `timestamp_col`, and each element of `id_cols`. --- ### 3. `bigquery-analyze-contribution` — backtick + OPTIONS injection **a. Backtick injection in `input_data` (table identifier path)** ```go inputDataSource = fmt.Sprintf("SELECT * FROM `%s`", inputData) ``` Same root cause as bigquery-forecast: when `allowed_datasets` is absent, no validation before interpolation. **b. Column-name and OPTIONS injection** ```go options = append(options, fmt.Sprintf("IS_TEST_COL = '%s'", paramsMap["is_test_col"])) // and each element of dimension_id_cols: strCols = append(strCols, fmt.Sprintf("'%s'", c)) ``` `is_test_col` and `dimension_id_cols` are column names interpolated into BigQuery ML `OPTIONS()` string literals without validation. `contribution_metric` (e.g. `SUM(col)/COUNT(DISTINCT col2)`) goes into `OPTIONS(CONTRIBUTION_METRIC = '%s')`. A single-quote in this value breaks the OPTIONS string literal. **Fix:** `ValidTableID` for `input_data` in the table-ID else-branch; `ValidColumnName` for `is_test_col` and each `dimension_id_cols` element; single-quote check for `contribution_metric`. --- ## Shared validators in `bigquerycommon` `ValidTableID` and `ValidColumnName` are exported from the `bigquerycommon` package so both tools share the same rules. Tests are in `validators_test.go`. ## Test plan - [x] `go build ./...` clean - [x] `go test ./internal/tools/bigquery/... -v` passes (all existing tests + new `TestValidTableID`, `TestValidColumnName`) - [x] `go test ./internal/tools/clickhouse/clickhouselisttables/... -v` passes ## Not a dupe of #2811 / #779 Those cover the `templateParameter` flow (`internal/util/parameters/parameters.go:applyEscape`). The three tools fixed here use hard-coded `fmt.Sprintf` interpolation in their own `Invoke` methods — none of the templateParameter mitigations apply. --------- Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
1 parent c6f79e2 commit 2f45f75

6 files changed

Lines changed: 256 additions & 5 deletions

File tree

internal/tools/bigquery/bigqueryanalyzecontribution/bigqueryanalyzecontribution.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,16 +191,39 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para
191191

192192
modelID := fmt.Sprintf("contribution_analysis_model_%s", strings.ReplaceAll(uuid.New().String(), "-", ""))
193193

194+
contributionMetric, ok := paramsMap["contribution_metric"].(string)
195+
if !ok {
196+
return nil, util.NewAgentError(fmt.Sprintf("unable to cast contribution_metric parameter %v", paramsMap["contribution_metric"]), nil)
197+
}
198+
if strings.ContainsRune(contributionMetric, '\'') {
199+
return nil, util.NewAgentError("invalid 'contribution_metric': must not contain single quotes", nil)
200+
}
201+
202+
isTestCol, ok := paramsMap["is_test_col"].(string)
203+
if !ok {
204+
return nil, util.NewAgentError(fmt.Sprintf("unable to cast is_test_col parameter %v", paramsMap["is_test_col"]), nil)
205+
}
206+
if !bqutil.ValidColumnName(isTestCol) {
207+
return nil, util.NewAgentError(fmt.Sprintf("invalid column name for 'is_test_col': %q; must match [a-zA-Z_][a-zA-Z0-9_]*", isTestCol), nil)
208+
}
209+
194210
var options []string
195211
options = append(options, "MODEL_TYPE = 'CONTRIBUTION_ANALYSIS'")
196-
options = append(options, fmt.Sprintf("CONTRIBUTION_METRIC = '%s'", paramsMap["contribution_metric"]))
197-
options = append(options, fmt.Sprintf("IS_TEST_COL = '%s'", paramsMap["is_test_col"]))
212+
options = append(options, fmt.Sprintf("CONTRIBUTION_METRIC = '%s'", contributionMetric))
213+
options = append(options, fmt.Sprintf("IS_TEST_COL = '%s'", isTestCol))
198214

199215
if val, ok := paramsMap["dimension_id_cols"]; ok {
200216
if cols, ok := val.([]any); ok {
201217
var strCols []string
202218
for _, c := range cols {
203-
strCols = append(strCols, fmt.Sprintf("'%s'", c))
219+
colStr, ok := c.(string)
220+
if !ok {
221+
return nil, util.NewAgentError(fmt.Sprintf("dimension_id_cols contains non-string value: %v", c), nil)
222+
}
223+
if !bqutil.ValidColumnName(colStr) {
224+
return nil, util.NewAgentError(fmt.Sprintf("invalid column name in 'dimension_id_cols': %q; must match [a-zA-Z_][a-zA-Z0-9_]*", colStr), nil)
225+
}
226+
strCols = append(strCols, fmt.Sprintf("'%s'", colStr))
204227
}
205228
options = append(options, fmt.Sprintf("DIMENSION_ID_COLS = [%s]", strings.Join(strCols, ", ")))
206229
} else {
@@ -254,6 +277,9 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para
254277
}
255278
inputDataSource = fmt.Sprintf("(%s)", inputData)
256279
} else {
280+
if !bqutil.ValidTableID(inputData) {
281+
return nil, util.NewAgentError(fmt.Sprintf("invalid table identifier for 'input_data': %q; expected 'dataset.table' or 'project.dataset.table'", inputData), nil)
282+
}
257283
if len(source.BigQueryAllowedDatasets()) > 0 {
258284
parts := strings.Split(inputData, ".")
259285
var projectID, datasetID string

internal/tools/bigquery/bigquerycommon/util.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package bigquerycommon
1717
import (
1818
"context"
1919
"fmt"
20+
"regexp"
2021
"sort"
2122
"strings"
2223

@@ -25,6 +26,31 @@ import (
2526
bigqueryrestapi "google.golang.org/api/bigquery/v2"
2627
)
2728

29+
// validBQTableID matches BigQuery table identifiers in 'dataset.table' or
30+
// 'project.dataset.table' form. Components are restricted to letters, digits,
31+
// and underscores — the character set that BigQuery allows for dataset and
32+
// table IDs and that is safe to interpolate inside a backtick-quoted SQL
33+
// identifier.
34+
var validBQTableID = regexp.MustCompile(`^[a-zA-Z0-9_-]+(\.([a-zA-Z0-9_]+)){1,2}$`)
35+
36+
// validBQColumnName matches BigQuery column names: a letter or underscore
37+
// followed by letters, digits, or underscores.
38+
var validBQColumnName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
39+
40+
// ValidTableID returns true if s is a safe BigQuery table identifier of the
41+
// form 'dataset.table' or 'project.dataset.table'. Values that fail this check
42+
// must not be interpolated into backtick-quoted SQL.
43+
func ValidTableID(s string) bool {
44+
return validBQTableID.MatchString(s)
45+
}
46+
47+
// ValidColumnName returns true if s is a safe BigQuery column name.
48+
// Values that fail this check must not be interpolated as SQL identifiers
49+
// or into single-quoted SQL string arguments that represent column references.
50+
func ValidColumnName(s string) bool {
51+
return validBQColumnName.MatchString(s)
52+
}
53+
2854
// DryRunQuery performs a dry run of the SQL query to validate it and get metadata.
2955
func DryRunQuery(ctx context.Context, restService *bigqueryrestapi.Service, projectID string, location string, sql string, params []*bigqueryrestapi.QueryParameter, connProps []*bigqueryapi.ConnectionProperty, maximumBytesBilled int64) (*bigqueryrestapi.Job, error) {
3056
useLegacySql := false
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package bigquerycommon_test
16+
17+
import (
18+
"testing"
19+
20+
"github.com/googleapis/mcp-toolbox/internal/tools/bigquery/bigquerycommon"
21+
)
22+
23+
func TestValidTableID(t *testing.T) {
24+
tcs := []struct {
25+
in string
26+
valid bool
27+
}{
28+
// Allowed: dataset.table
29+
{"my_dataset.my_table", true},
30+
{"ds.t", true},
31+
{"ds_1.tbl_2", true},
32+
33+
// Allowed: project.dataset.table
34+
{"proj.ds.tbl", true},
35+
{"PROJ.DS.TBL", true},
36+
{"my_project.my_dataset.my_table", true},
37+
38+
// Rejected: hyphens (not valid in dataset/table IDs).
39+
{"my-project.my-dataset.my_table", false},
40+
{"my-project.my_dataset.my-table", false},
41+
{"my-project-123.dataset.table", true},
42+
43+
// Rejected: only one component (no dot)
44+
{"my_dataset", false},
45+
{"", false},
46+
47+
// Rejected: too many dots (4+ parts)
48+
{"a.b.c.d", false},
49+
50+
// Rejected: injection characters
51+
{"dataset.table`", false},
52+
{"dataset.table` UNION ALL SELECT 1 --", false},
53+
{"dataset.table'; DROP TABLE x --", false},
54+
{"dataset.table\n", false},
55+
{"dataset.table ", false},
56+
{" dataset.table", false},
57+
{"dataset.table\t", false},
58+
59+
// Rejected: backtick (closes identifier in SQL)
60+
{"ds.`table`", false},
61+
62+
// Rejected: SQL metacharacters
63+
{"ds.table--", false},
64+
{"ds.table/*", false},
65+
{"ds.table;", false},
66+
}
67+
for _, tc := range tcs {
68+
if got := bigquerycommon.ValidTableID(tc.in); got != tc.valid {
69+
t.Errorf("ValidTableID(%q) = %v, want %v", tc.in, got, tc.valid)
70+
}
71+
}
72+
}
73+
74+
func TestValidColumnName(t *testing.T) {
75+
tcs := []struct {
76+
in string
77+
valid bool
78+
}{
79+
// Allowed: simple identifiers.
80+
{"sales", true},
81+
{"sales_col", true},
82+
{"_internal", true},
83+
{"Col1", true},
84+
{"A", true},
85+
{"is_test", true},
86+
{"timestamp_col", true},
87+
88+
// Rejected: empty string.
89+
{"", false},
90+
91+
// Rejected: leading digit.
92+
{"1col", false},
93+
94+
// Rejected: SQL injection characters.
95+
{"col'", false},
96+
{"col`", false},
97+
{"col; DROP TABLE x", false},
98+
{"col UNION SELECT", false},
99+
{"col--", false},
100+
{"col/*", false},
101+
{"col(", false},
102+
{"col)", false},
103+
{"col/col2", false},
104+
105+
// Rejected: whitespace.
106+
{"col name", false},
107+
{" col", false},
108+
{"col ", false},
109+
110+
// Rejected: dots (not valid in unquoted column names).
111+
{"ds.col", false},
112+
}
113+
for _, tc := range tcs {
114+
if got := bigquerycommon.ValidColumnName(tc.in); got != tc.valid {
115+
t.Errorf("ValidColumnName(%q) = %v, want %v", tc.in, got, tc.valid)
116+
}
117+
}
118+
}

internal/tools/bigquery/bigqueryforecast/bigqueryforecast.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,18 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para
191191
}
192192
}
193193

194+
if !bqutil.ValidColumnName(dataCol) {
195+
return nil, util.NewAgentError(fmt.Sprintf("invalid column name for 'data_col': %q; must match [a-zA-Z_][a-zA-Z0-9_]*", dataCol), nil)
196+
}
197+
if !bqutil.ValidColumnName(timestampCol) {
198+
return nil, util.NewAgentError(fmt.Sprintf("invalid column name for 'timestamp_col': %q; must match [a-zA-Z_][a-zA-Z0-9_]*", timestampCol), nil)
199+
}
200+
for _, col := range idCols {
201+
if !bqutil.ValidColumnName(col) {
202+
return nil, util.NewAgentError(fmt.Sprintf("invalid column name in 'id_cols': %q; must match [a-zA-Z_][a-zA-Z0-9_]*", col), nil)
203+
}
204+
}
205+
194206
bqClient, restService, err := source.RetrieveClientAndService(accessToken)
195207
if err != nil {
196208
return nil, util.NewClientServerError("failed to retrieve BigQuery client", http.StatusInternalServerError, err)
@@ -232,6 +244,9 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para
232244
}
233245
historyDataSource = fmt.Sprintf("(%s)", historyData)
234246
} else {
247+
if !bqutil.ValidTableID(historyData) {
248+
return nil, util.NewAgentError(fmt.Sprintf("invalid table identifier for 'history_data': %q; expected 'dataset.table' or 'project.dataset.table'", historyData), nil)
249+
}
235250
if len(source.BigQueryAllowedDatasets()) > 0 {
236251
parts := strings.Split(historyData, ".")
237252
var projectID, datasetID string

internal/tools/clickhouse/clickhouselisttables/clickhouselisttables.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"fmt"
2020
"net/http"
21+
"regexp"
2122

2223
yaml "github.com/goccy/go-yaml"
2324
"github.com/googleapis/mcp-toolbox/internal/embeddingmodels"
@@ -30,6 +31,14 @@ import (
3031
const listTablesType string = "clickhouse-list-tables"
3132
const databaseKey string = "database"
3233

34+
// validIdentifier matches the ClickHouse unquoted identifier grammar: a letter
35+
// or underscore followed by letters, digits, or underscores. The `database`
36+
// parameter is interpolated directly into a `SHOW TABLES FROM` statement and
37+
// cannot be bound as a positional value, so restricting it to this character
38+
// set is the only safe option short of refactoring to use ClickHouse's
39+
// `system.tables` view.
40+
var validIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
41+
3342
func init() {
3443
if !tools.Register(listTablesType, newListTablesConfig) {
3544
panic(fmt.Sprintf("tool type %q already registered", listTablesType))
@@ -120,8 +129,17 @@ func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, para
120129
return nil, util.NewAgentError(fmt.Sprintf("invalid or missing '%s' parameter; expected a string", databaseKey), nil)
121130
}
122131

123-
// Query to list all tables in the specified database
124-
// Note: formatting identifier directly is risky if input is untrusted, but standard for this tool structure.
132+
// The database name is interpolated directly into the `SHOW TABLES FROM`
133+
// statement. Reject anything that is not a plain identifier so that a
134+
// caller cannot smuggle additional clauses (LIKE, LIMIT, FORMAT,
135+
// INTO OUTFILE, ...), quoted identifiers, or other expressions through
136+
// this parameter. Without this check, an MCP client (or a prompt-injected
137+
// LLM) could escape the intended scope of the tool and read arbitrary
138+
// system tables, switch the output format to one the calling layer cannot
139+
// parse safely, or chain a SHOW with an `INTO OUTFILE` write.
140+
if !validIdentifier.MatchString(database) {
141+
return nil, util.NewAgentError(fmt.Sprintf("invalid '%s' parameter %q: must be a plain identifier matching %s", databaseKey, database, validIdentifier.String()), nil)
142+
}
125143
query := fmt.Sprintf("SHOW TABLES FROM %s", database)
126144

127145
out, err := source.RunSQL(ctx, query, nil)

internal/tools/clickhouse/clickhouselisttables/clickhouselisttables_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,51 @@ func TestListTablesToolParseParams(t *testing.T) {
9797
t.Errorf("expected database parameter to be 'test_db', got %v", mapParams["database"])
9898
}
9999
}
100+
101+
func TestValidIdentifierRegexp(t *testing.T) {
102+
tcs := []struct {
103+
in string
104+
valid bool
105+
}{
106+
// Allowed: plain identifiers.
107+
{"default", true},
108+
{"my_db", true},
109+
{"DB1", true},
110+
{"_internal", true},
111+
{"a", true},
112+
113+
// Rejected: empty, whitespace, control characters.
114+
{"", false},
115+
{" ", false},
116+
{"\t", false},
117+
{"\n", false},
118+
119+
// Rejected: leading digit (ClickHouse identifier rule).
120+
{"1db", false},
121+
122+
// Rejected: identifier-quoting characters that would let the value
123+
// re-open the identifier and append clauses after it.
124+
{"`default`", false},
125+
{`"default"`, false},
126+
127+
// Rejected: separators / statement terminators / metacharacters.
128+
{"default;DROP TABLE x", false},
129+
{"default LIKE '%'", false},
130+
{"default LIMIT 0 FORMAT JSON", false},
131+
{"default INTO OUTFILE '/tmp/x'", false},
132+
{"system.tables", false},
133+
{"default--", false},
134+
{"default/*", false},
135+
136+
// Rejected: unicode and surrounding spaces.
137+
{"𝐝𝐞𝐟𝐚𝐮𝐥𝐭", false},
138+
{" default ", false},
139+
{"default ", false},
140+
{" default", false},
141+
}
142+
for _, tc := range tcs {
143+
if got := validIdentifier.MatchString(tc.in); got != tc.valid {
144+
t.Errorf("validIdentifier.MatchString(%q) = %v, want %v", tc.in, got, tc.valid)
145+
}
146+
}
147+
}

0 commit comments

Comments
 (0)