From 338dede7d4378a8454787a678e3304a8b9ae5753 Mon Sep 17 00:00:00 2001 From: Curt Hagenlocher Date: Tue, 8 Sep 2026 08:17:01 -0700 Subject: [PATCH] fix(csharp/src/Drivers/BigQuery): harden metadata query identifier and pattern handling The metadata queries in GetObjects and GetTableSchema built INFORMATION_SCHEMA SQL by interpolating caller-supplied catalog, dataset, table, and column values and patterns directly into the query text, relying on a regex allowlist (^[a-zA-Z0-9_-]+, unanchored) to filter input. That allowlist did not fully constrain the input, and was also in tension with the fact that some legitimate BigQuery identifier and pattern values (for example, table names containing Unicode letters, spaces, or the '%' wildcard) don't satisfy it. This change tightens identifier handling and removes the interpolation: - Every caller-supplied value across all six INFORMATION_SCHEMA queries is now bound as a query parameter rather than interpolated into the SQL text. - The catalog and dataset that name the view can't be parameters (BigQuery doesn't support binding identifiers), so those are still validated against an allowlist, now anchored with \A/\z instead of ^/$ (since .NET's $ also matches before a trailing newline). Rejected values are no longer echoed into the exception message, since a rejected value could itself contain characters - including newlines and other control characters - that might forge or split log/error output if interpolated there. - Search patterns (used for catalog, dataset, table, and column name filters) are now handled correctly: BigQuery's LIKE evaluates '%' and '_' as wildcards once the pattern is bound as a parameter, whereas the old allowlist rejected any pattern containing '%', and separately rejected Unicode letters/spaces that BigQuery permits in identifiers. - Catalog and dataset patterns, which are matched client-side via PatternToRegEx, are aligned with the table/column patterns, which are matched server-side via LIKE: every literal character is now escaped (previously '.' matched any character and '[' threw), and matching is now case-sensitive in both paths, matching BigQuery's own case-sensitive dataset and table names. - EscapeLikePattern doubles backslashes so that user-supplied '\' characters remain literal under BigQuery's LIKE escaping, per the ADBC search pattern semantics (which give special meaning only to '%' and '_', with no other escaping supported). Breaking changes, all of them cases that were previously incorrect: - Catalog and dataset patterns now match case-sensitively. - Regex metacharacters in a catalog or dataset pattern are literal. - A catalog or dataset containing a character outside [a-zA-Z0-9_-] is rejected rather than truncated past the first invalid character. This mirrors a fix from CurtHagenlocher/bigquery#297, which found and fixed the same issue in a downstream fork of this driver. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02844248-1442-4b59-9cc7-fffc21b698fa --- .../Drivers/BigQuery/BigQueryConnection.cs | 174 +++++++++++++----- 1 file changed, 128 insertions(+), 46 deletions(-) diff --git a/csharp/src/Drivers/BigQuery/BigQueryConnection.cs b/csharp/src/Drivers/BigQuery/BigQueryConnection.cs index bc8957f005..6a1cdbc1bf 100644 --- a/csharp/src/Drivers/BigQuery/BigQueryConnection.cs +++ b/csharp/src/Drivers/BigQuery/BigQueryConnection.cs @@ -583,7 +583,7 @@ private IArrowArray[] GetCatalogs( foreach (string projectId in projectIds) { - if (Regex.IsMatch(projectId, catalogRegexp, RegexOptions.IgnoreCase)) + if (Regex.IsMatch(projectId, catalogRegexp)) { catalogNameBuilder.Append(projectId); @@ -641,7 +641,7 @@ private StructArray GetDbSchemas( { foreach (BigQueryDataset schema in schemas) { - if (Regex.IsMatch(schema.Reference.DatasetId, dbSchemaRegexp, RegexOptions.IgnoreCase)) + if (Regex.IsMatch(schema.Reference.DatasetId, dbSchemaRegexp)) { dbSchemaNameBuilder.Append(schema.Reference.DatasetId); length++; @@ -694,27 +694,34 @@ private StructArray GetTableSchemas( int length = 0; string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.TABLES", - Sanitize(catalog), Sanitize(dbSchema)); + SanitizeIdentifier(catalog), SanitizeIdentifier(dbSchema)); + + List parameters = new List(); + List predicates = new List(); if (tableNamePattern != null) { - query = string.Concat(query, string.Format(" WHERE table_name LIKE '{0}'", Sanitize(tableNamePattern))); - if (tableTypes?.Count > 0) - { - IEnumerable sanitizedTypes = tableTypes.Select(x => Sanitize(x)); - query = string.Concat(query, string.Format(" AND table_type IN ('{0}')", string.Join("', '", sanitizedTypes).ToUpper())); - } + predicates.Add("table_name LIKE @tableNamePattern"); + parameters.Add(new BigQueryParameter("tableNamePattern", BigQueryDbType.String, EscapeLikePattern(tableNamePattern))); } - else + + if (tableTypes?.Count > 0) { - if (tableTypes?.Count > 0) + // IN UNNEST rather than IN, because BigQuery does not expand a single array + // parameter into an IN list. + predicates.Add("table_type IN UNNEST(@tableTypes)"); + parameters.Add(new BigQueryParameter("tableTypes", BigQueryDbType.Array, tableTypes.Select(x => x.ToUpperInvariant()).ToList()) { - IEnumerable sanitizedTypes = tableTypes.Select(x => Sanitize(x)); - query = string.Concat(query, string.Format(" WHERE table_type IN ('{0}')", string.Join("', '", sanitizedTypes).ToUpper())); - } + ArrayElementType = BigQueryDbType.String, + }); } - BigQueryResults? result = ExecuteQuery(query, parameters: null); + if (predicates.Count > 0) + { + query = string.Concat(query, " WHERE ", string.Join(" AND ", predicates)); + } + + BigQueryResults? result = ExecuteQuery(query, parameters); if (result != null) { @@ -800,15 +807,21 @@ private StructArray GetColumnSchema( ArrowBuffer.BitmapBuilder nullBitmapBuffer = new ArrowBuffer.BitmapBuilder(); int length = 0; - string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.COLUMNS WHERE table_name = '{2}'", - Sanitize(catalog), Sanitize(dbSchema), Sanitize(table)); + string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.COLUMNS WHERE table_name = @tableName", + SanitizeIdentifier(catalog), SanitizeIdentifier(dbSchema)); + + List parameters = new List + { + new BigQueryParameter("tableName", BigQueryDbType.String, table), + }; if (columnNamePattern != null) { - query = string.Concat(query, string.Format("AND column_name LIKE '{0}'", Sanitize(columnNamePattern))); + query = string.Concat(query, " AND column_name LIKE @columnNamePattern"); + parameters.Add(new BigQueryParameter("columnNamePattern", BigQueryDbType.String, EscapeLikePattern(columnNamePattern))); } - BigQueryResults? result = ExecuteQuery(query, parameters: null); + BigQueryResults? result = ExecuteQuery(query, parameters); if (result != null) { @@ -902,10 +915,13 @@ private StructArray GetConstraintSchema( ArrowBuffer.BitmapBuilder nullBitmapBuffer = new ArrowBuffer.BitmapBuilder(); int length = 0; - string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE table_name = '{2}'", - Sanitize(catalog), Sanitize(dbSchema), Sanitize(table)); + string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE table_name = @tableName", + SanitizeIdentifier(catalog), SanitizeIdentifier(dbSchema)); - BigQueryResults? result = ExecuteQuery(query, parameters: null); + BigQueryResults? result = ExecuteQuery(query, new List + { + new BigQueryParameter("tableName", BigQueryDbType.String, table), + }); if (result != null) { @@ -966,12 +982,16 @@ private StringArray GetConstraintColumnNames( { return this.TraceActivity(activity => { - string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE table_name = '{2}' AND constraint_name = '{3}' ORDER BY ordinal_position", - Sanitize(catalog), Sanitize(dbSchema), Sanitize(table), Sanitize(constraintName)); + string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE table_name = @tableName AND constraint_name = @constraintName ORDER BY ordinal_position", + SanitizeIdentifier(catalog), SanitizeIdentifier(dbSchema)); StringArray.Builder constraintColumnNamesBuilder = new StringArray.Builder(); - BigQueryResults? result = ExecuteQuery(query, parameters: null); + BigQueryResults? result = ExecuteQuery(query, new List + { + new BigQueryParameter("tableName", BigQueryDbType.String, table), + new BigQueryParameter("constraintName", BigQueryDbType.String, constraintName), + }); if (result != null) { @@ -1001,10 +1021,13 @@ private StructArray GetConstraintsUsage( ArrowBuffer.BitmapBuilder nullBitmapBuffer = new ArrowBuffer.BitmapBuilder(); int length = 0; - string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE WHERE constraint_name = '{2}'", - Sanitize(catalog), Sanitize(dbSchema), Sanitize(constraintName)); + string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE WHERE constraint_name = @constraintName", + SanitizeIdentifier(catalog), SanitizeIdentifier(dbSchema)); - BigQueryResults? result = ExecuteQuery(query, parameters: null); + BigQueryResults? result = ExecuteQuery(query, new List + { + new BigQueryParameter("constraintName", BigQueryDbType.String, constraintName), + }); if (result != null) { @@ -1042,15 +1065,42 @@ private StructArray GetConstraintsUsage( }); } - private string PatternToRegEx(string? pattern) + /// + /// Translates an ADBC search pattern into an equivalent regular expression, for the + /// catalog and dataset patterns that are matched client-side against listing results + /// rather than server-side by a SQL LIKE. + /// + /// + /// Only "%" and "_" carry meaning in an ADBC search pattern; every other character is + /// literal, so each one is escaped rather than passed through to the regex engine. The + /// match is case-sensitive so that these patterns behave the same way as the table and + /// column patterns, which reach BigQuery as a case-sensitive LIKE. + /// + internal static string PatternToRegEx(string? pattern) { if (pattern == null) return ".*"; - StringBuilder builder = new StringBuilder("(?i)^"); - string convertedPattern = pattern.Replace("_", ".").Replace("%", ".*"); - builder.Append(convertedPattern); - builder.Append("$"); + StringBuilder builder = new StringBuilder(@"\A"); + + foreach (char c in pattern) + { + switch (c) + { + case '_': + builder.Append('.'); + break; + case '%': + builder.Append(".*"); + break; + default: + builder.Append(Regex.Escape(c.ToString())); + break; + } + } + + // \z rather than $, which in .NET also matches before a trailing newline. + builder.Append(@"\z"); return builder.ToString(); } @@ -1141,10 +1191,13 @@ public override Schema GetTableSchema(string? catalog, string? dbSchema, string { return this.TraceActivity(activity => { - string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.COLUMNS WHERE table_name = '{2}'", - Sanitize(catalog), Sanitize(dbSchema), Sanitize(tableName)); + string query = string.Format("SELECT * FROM `{0}`.`{1}`.INFORMATION_SCHEMA.COLUMNS WHERE table_name = @tableName", + SanitizeIdentifier(catalog), SanitizeIdentifier(dbSchema)); - BigQueryResults? result = ExecuteQuery(query, parameters: null); + BigQueryResults? result = ExecuteQuery(query, new List + { + new BigQueryParameter("tableName", BigQueryDbType.String, tableName), + }); List fields = new List(); @@ -1344,25 +1397,54 @@ public override void Dispose() this._fileActivityListener?.Dispose(); } - private static Regex sanitizedInputRegex = new Regex("^[a-zA-Z0-9_-]+"); + /// + /// Matches a complete BigQuery project or dataset identifier. The character class is the + /// union of what BigQuery permits in each: a project id allows lowercase letters, digits + /// and hyphens, a dataset id allows letters, digits and underscores. Both anchors are + /// required - \A and \z rather than ^ and $, because .NET's $ also matches immediately + /// before a trailing newline. + /// + private static readonly Regex s_identifierRegex = new Regex(@"\A[a-zA-Z0-9_-]+\z", RegexOptions.CultureInvariant); - private string Sanitize(string? input) + /// + /// Validates a value that is interpolated into a query as a quoted identifier. + /// + /// + /// BigQuery query parameters cannot stand in for identifiers, so the catalog and dataset + /// that name an INFORMATION_SCHEMA view have to be embedded in the query text. They are + /// therefore checked against the full set of characters BigQuery allows in those names; + /// anything else - a backtick that would close the identifier especially - is rejected. + /// Every other caller-supplied value is passed as a query parameter instead. + /// + internal static string SanitizeIdentifier(string? input) { if (string.IsNullOrEmpty(input)) return string.Empty; - bool isValidInput = sanitizedInputRegex.IsMatch(input); - - if (isValidInput) - { - return input!; - } - else + if (!s_identifierRegex.IsMatch(input)) { - throw new AdbcException($"{input} is invalid", AdbcStatusCode.InvalidArgument); + // The rejected value is deliberately omitted: it failed validation precisely + // because it may contain characters - newlines and other control characters + // among them - that would let it forge or split log/error output if embedded. + throw new AdbcException("catalog or dataset identifier is invalid", AdbcStatusCode.InvalidArgument); } + + return input!; } + /// + /// Escapes an ADBC search pattern so that BigQuery's LIKE operator gives it the meaning + /// the ADBC specification defines. + /// + /// + /// An ADBC search pattern gives special meaning to "%" (zero or more characters) and "_" + /// (exactly one character) and nothing else - the specification states that escaping is + /// not supported. BigQuery's LIKE additionally treats "\" as an escape character, so a + /// backslash is doubled here to keep it literal. The two wildcards are deliberately left + /// alone so they still reach LIKE as wildcards. + /// + internal static string EscapeLikePattern(string pattern) => pattern.Replace("\\", "\\\\"); + /// /// Gets the access token from the token endpoint. ///