Back to Blog
high SEVERITY8 min read

How SQL Injection happens in Python BigQuery connectors and how to fix it

A high-severity SQL injection vulnerability was discovered in a BigQuery connector's query-building logic, where Python f-strings interpolated user-controlled identifiers—project_id, dataset_id, table_id, and timestamp_column—directly into SQL without validation. An attacker with control over connector configuration could inject arbitrary BigQuery SQL, including destructive statements. The fix introduces strict allowlist-based identifier validation using compiled regular expressions before any S

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a SQL injection vulnerability (CWE-89) in a Python BigQuery connector (`common/data_source/bigquery_connector.py`), where user-controlled configuration values like `table_id` and `timestamp_column` were interpolated directly into SQL queries using f-strings without sanitization. The fix adds two compiled regex allowlists—`_IDENTIFIER_RE` for standard identifiers and `_PROJECT_ID_RE` for project IDs—and validates every identifier in `__init__` before any query is built, raising a `ConnectorValidationError` on invalid input.

Vulnerability at a Glance

cweCWE-89
fixAllowlist regex validation of all identifiers at connector initialization time
riskAttacker-controlled config values can inject arbitrary BigQuery SQL, including data destruction or exfiltration
languagePython
root causef-string interpolation of user-supplied identifiers (project_id, dataset_id, table_id, timestamp_column) with no validation
vulnerabilitySQL Injection via unsanitized identifier interpolation

How SQL Injection Happens in Python BigQuery Connectors and How to Fix It

Introduction

The common/data_source/bigquery_connector.py file handles all SQL query construction for a BigQuery-backed data source—reading configuration values like project_id, dataset_id, table_id, and timestamp_column at initialization and embedding them directly into query strings. A flaw in the __init__ method and downstream _build_base_query() logic meant that every one of those identifiers was trusted unconditionally, creating a direct path from connector configuration to raw BigQuery SQL execution.

This matters because BigQuery connectors are often configured through web interfaces, API payloads, or infrastructure-as-code files—surfaces that are reachable by users who should not have the ability to run arbitrary SQL. If you've ever written f"SELECT * FROM{project}.{dataset}.{table}" without first checking what those variables contain, this post is for you.


The Vulnerability Explained

What the vulnerable code looked like

Starting at line 138 in the original __init__ method, the connector simply stripped whitespace from incoming configuration values and stored them:

# BEFORE — vulnerable initialization
self.project_id = (project_id or "").strip()
self.dataset_id = (dataset_id or "").strip()
self.table_id   = (table_id   or "").strip()

Those values were then used in f-string query construction further down the file (lines 202, 207, 212, 303, 306, 310). A simplified example of what that looks like:

# Downstream query construction — the injection sink
query = (
    f"SELECT * FROM `{self.project_id}.{self.dataset_id}.{self.table_id}` "
    f"WHERE {self.timestamp_column} >= @start_time"
)

There is no escaping, no quoting of identifier parts, and no validation that the strings are legal BigQuery identifiers. The backtick quoting around the table reference helps for some characters, but timestamp_column is placed directly into the WHERE clause with no quoting at all.

How an attacker exploits this

The PR's threat model describes two concrete attack paths:

Attack 1 — Malicious table_id:

table_id = "my_table` WHERE 1=1; DROP TABLE important_data; --"

This closes the backtick, appends a destructive statement, and comments out the rest of the query. The resulting SQL becomes:

SELECT * FROM `project.dataset.my_table` WHERE 1=1; DROP TABLE important_data; --`
WHERE timestamp >= @start_time

Attack 2 — Malicious timestamp_column:

timestamp_column = "col1) OR 1=1 --"

Since timestamp_column is placed unquoted directly into the WHERE clause, this produces:

WHERE col1) OR 1=1 -- >= @start_time

Which evaluates to a tautology, returning all rows regardless of the intended time filter.

Attack 3 — Arbitrary query passthrough:
The connector also accepts a self.query field for completely custom SQL. If an attacker can set this field through a configuration API, they can run any BigQuery SQL the service account is authorized to execute—including reading sensitive tables or calling BigQuery ML functions.

Real-world impact

This is a web service. The PR explicitly notes: "vulnerabilities in request handlers are directly exploitable by remote attackers." Any API endpoint that allows a user to create or update a data source connector is a direct exploitation vector. The blast radius includes:
- Data exfiltration: UNION SELECT or subquery injection to read tables the user shouldn't access
- Data destruction: DROP TABLE or DELETE statements
- Cost escalation: Injecting expensive full-table scans to drive up BigQuery billing
- Privilege escalation: Calling BigQuery functions or accessing datasets outside the intended scope


The Fix

Two allowlist validators, applied at initialization

The fix adds two compiled regular expressions at module level and two validation functions that are called in __init__ before any value is stored:

# New module-level constants
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_PROJECT_ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")

_IDENTIFIER_RE matches standard BigQuery identifiers: must start with a letter or underscore, followed by letters, digits, or underscores only. No spaces, no backticks, no semicolons, no SQL metacharacters.

_PROJECT_ID_RE is slightly more permissive because GCP project IDs legitimately contain hyphens (e.g., my-gcp-project-123), but still excludes all SQL-meaningful characters.

def _validate_identifier(value: Optional[str], name: str) -> Optional[str]:
    if not value:
        return value
    if not _IDENTIFIER_RE.fullmatch(value):
        raise ConnectorValidationError(f"Invalid BigQuery identifier for {name!r}")
    return value


def _validate_project_id(value: str, name: str) -> str:
    if not value:
        return value
    if not _PROJECT_ID_RE.fullmatch(value):
        raise ConnectorValidationError(f"Invalid BigQuery identifier for {name!r}")
    return value

Note the use of .fullmatch() rather than .match() or .search(). This is critical: .match() anchors only at the start, so a value like valid_name; DROP TABLE x would pass a .match() check but fail .fullmatch().

Before and after

# BEFORE — no validation
self.project_id = (project_id or "").strip()
self.dataset_id = (dataset_id or "").strip()
self.table_id   = (table_id   or "").strip()

# AFTER — allowlist validation at assignment
self.project_id = _validate_project_id((project_id or "").strip(), "project_id")
self.dataset_id = _validate_identifier((dataset_id or "").strip(), "dataset_id")
# table_id and timestamp_column follow the same pattern

Now, if table_id is set to my_table\ WHERE 1=1; DROP TABLE important_data; --, the_validate_identifierfunction raises aConnectorValidationErrorimmediately ininit`—before any SQL string is ever constructed. The attack never reaches the query builder.

Why this approach is correct

Identifier validation is fundamentally different from value parameterization. BigQuery's parameterized query API (using @param_name placeholders) correctly handles data values like timestamps and strings. But table names, dataset names, column names, and project IDs are SQL structural elements—they cannot be passed as parameters. The only safe approach is to validate them against a strict allowlist of legal characters before interpolation.

The fix applies this validation at the earliest possible point: object construction. This means no code path through the connector can ever reach query-building logic with an unvalidated identifier.


Prevention & Best Practices

1. Never trust identifiers from configuration

Configuration values—even those set by administrators rather than end users—should be treated as untrusted input when they flow into SQL. Insider threats, misconfigured APIs, and supply chain attacks can all result in malicious configuration values.

2. Use .fullmatch() for allowlist regexes

Always use re.fullmatch() (or anchor your pattern with ^ and $) when validating input against an allowlist. A pattern like [A-Za-z0-9_]+ with .match() will pass valid; DROP TABLE x because .match() stops at the first non-matching character.

3. Validate at the boundary, not at the use site

The fix validates identifiers in __init__, not in _build_base_query(). This is the right pattern: validate as early as possible, so the rest of the codebase can assume the invariant holds. If you validate at the use site, you risk forgetting one of many call sites (lines 202, 207, 212, 303, 306, 310 in this case).

4. Separate identifier validation from value parameterization

Use BigQuery's ScalarQueryParameter for data values and allowlist regex validation for identifiers. These are complementary, not interchangeable.

5. Apply to all identifier fields

The PR notes that lines 207, 212, 303, 306, and 310 use similar patterns. The same _validate_identifier function should be applied to timestamp_column and any other identifier field that flows into SQL construction.

Relevant standards

  • OWASP SQL Injection Prevention Cheat Sheet: Covers parameterization and input validation strategies
  • CWE-89: Improper Neutralization of Special Elements used in an SQL Command
  • CWE-20: Improper Input Validation (applies to the missing identifier validation)

Key Takeaways

  • f-string SQL construction with identifier variables is dangerous even when values come from configuration, not just from HTTP request bodies. Configuration APIs are attack surfaces too.
  • timestamp_column in a WHERE clause is more dangerous than table identifiers because it's placed unquoted, making injection easier and the resulting SQL more predictable for an attacker.
  • .fullmatch() is the correct method for allowlist regex validation in Python—.match() and .search() both leave the door open for bypass.
  • The self.query passthrough field (allowing arbitrary custom SQL) deserves its own access control review independent of this fix—it is a separate, intentional bypass of all query construction logic.
  • Validating in __init__ rather than in each query-building method ensures the invariant is enforced once and cannot be bypassed by future code changes that add new query paths.

How Orbis AppSec Detected This

  • Source: Connector initialization parameters (project_id, dataset_id, table_id, timestamp_column) passed in from external configuration at common/data_source/bigquery_connector.py:138
  • Sink: f-string SQL query construction in _build_base_query() and related methods at lines 202, 207, 212, 303, 306, 310
  • Missing control: No allowlist validation or escaping of identifier values before interpolation into SQL strings
  • CWE: CWE-89 — Improper Neutralization of Special Elements used in an SQL Command
  • Fix: Added _validate_identifier() and _validate_project_id() functions using compiled fullmatch() regexes, called in __init__ before storing any identifier value

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

This vulnerability is a reminder that SQL injection isn't limited to login forms and search boxes. Any system that constructs SQL from configuration data—BigQuery connectors, BI tool integrations, data pipeline builders—carries the same risk. The pattern of f"SELECT * FROM {user_controlled_value}" is dangerous regardless of whether user_controlled_value came from an HTTP parameter or a YAML config file.

The fix here is elegant in its simplicity: two regular expressions, two validation functions, and three lines changed in __init__. The entire connector's query-building logic—across six vulnerable call sites—is now protected by a single enforcement point at object construction. That's the right architecture for security invariants: enforce them once, at the boundary, and let the rest of the code assume safety.


References

Frequently Asked Questions

What is SQL injection in a BigQuery connector?

It occurs when user-controlled values like table names or column names are interpolated directly into SQL query strings without validation, allowing an attacker to append or modify the SQL logic.

How do you prevent SQL injection in Python BigQuery code?

Validate all identifier values against a strict allowlist regex (e.g., `^[A-Za-z_][A-Za-z0-9_]*$`) before using them in query construction, and raise an exception for any non-conforming input.

What CWE is SQL injection?

CWE-89: Improper Neutralization of Special Elements used in an SQL Command.

Is parameterized queries enough to prevent SQL injection in BigQuery?

Parameterized queries protect data values but not identifiers (table names, column names, dataset names). Identifiers must be validated separately using an allowlist approach.

Can static analysis detect SQL injection via f-strings?

Yes. Tools like Semgrep can detect tainted data flowing from configuration or user input into f-string SQL construction, which is exactly how this vulnerability was identified.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #17500

Related Articles

critical

How SQL Injection happens in PHP PDO queries and how to fix it

A critical SQL injection vulnerability was discovered in the `getOfficialContests()` method of ContestRepository.php, where the `$site_id` parameter was directly interpolated into a SQL query string instead of using prepared statements. This vulnerability allowed attackers to inject arbitrary SQL commands and potentially access or manipulate the entire contest database. The fix replaced `pdo->query()` with `pdo->prepare()` and proper parameter binding.

critical

How SQL Injection Happens in CSV-to-SQL Converters and How to Fix It

A critical SQL injection vulnerability was discovered in the `csv2sql()` function in `src/data/converter/csv.js`, where CSV data and table names were directly interpolated into SQL INSERT statements without sanitization. The fix implements input validation through identifier sanitization and proper value escaping, eliminating the attack surface while preserving legitimate functionality.

critical

How SQL injection happens in Node.js string interpolation and how to fix it

A critical SQL injection vulnerability was discovered in the `getScript()` method of `src/core/statistics.js`, where the `metadata_id` variable was directly interpolated into DELETE and UPDATE SQL statements without any validation. An attacker controlling this parameter could inject malicious SQL payloads to delete entire tables or exfiltrate sensitive data. The fix implements strict input validation using `parseInt()` and regex patterns to ensure only safe values reach the database queries.

critical

How SQL Injection happens in Node.js MySQL queries and how to fix it

A critical SQL injection vulnerability was discovered in `divisible_asset.js` where `message_index` and `output_index` values from external payment data were directly interpolated into SQL queries without proper escaping. This fix applies `conn.escape()` to these parameters, preventing attackers from manipulating database queries through crafted payment elements.

critical

How SQL injection happens in PHP MySQLi and how to fix it

A critical SQL injection vulnerability was discovered in `sign_up.php` where user registration inputs—including Username and Email—were directly concatenated into SQL queries. Despite using `mysqli_real_escape_string()`, the code remained exploitable. The fix replaces all string-concatenated queries with MySQLi prepared statements and bound parameters, completely eliminating the injection vector.

high

How Quadratic CPU Consumption Happens in JavaScript YAML Parsing and How to Fix It

A high-severity denial-of-service vulnerability in js-yaml (GHSA-5p4m-2wfm-xmqj) allowed attackers to trigger quadratic CPU consumption by supplying crafted YAML input containing `!!omap` (ordered map) types. The vulnerability affected both the 3.x and 4.x branches of js-yaml, and the fix for CVE-2026-59870 had not been backported to all affected versions. Upgrading from `js-yaml@4.3.0` to `4.3.1` (and `3.15.0` to `3.15.1`) resolves the issue by correcting the inefficient duplicate-key detection