Back to Blog
critical SEVERITY5 min read

JdbcSinkFunction.invoke() SQL Injection via Unvalidated Identifiers

The `JdbcSinkFunction.invoke()` method in Lacus's RTC engine built SQL INSERT statements with `String.format()`, placing database name, table name, and column names directly into the query string without validation. Although the actual row values used parameterized `?` placeholders, the identifiers themselves were injectable, letting a crafted sink configuration break out of the intended INSERT statement. The fix adds a strict allowlist regex that rejects any identifier containing characters out

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 5, 2026•Reviewed October 5, 2026

Answer Summary

The affected first-party code is the `JdbcSinkFunction.invoke()` method in Lacus's RTC data-sink engine, which builds SQL INSERT statements for streaming data pipelines. An attacker who controls the `JdbcSinkOption` configuration (db name, table name, or column names) could inject arbitrary SQL by embedding characters like semicolons or comment sequences into those identifiers, since they were interpolated via `String.format()` with no validation. The fix adds a `validateIdentifier()` check using the regex `^[a-zA-Z0-9_]+$` applied to `db`, `table`, and every column name before the SQL string is built; no package version is affected since this is first-party code. This is tracked as CWE-89 (SQL Injection).

Vulnerability at a Glance

cweCWE-89
fixAdded `validateIdentifier()` regex allowlist (`^[a-zA-Z0-9_]+$`) applied to all identifiers before SQL construction
riskArbitrary SQL execution through crafted database/table/column names in sink configuration
languageJava
root cause`String.format()` interpolates `db`, `table`, and `columns` directly into the INSERT statement with no identifier validation
vulnerabilitySQL Injection via unsanitized SQL identifiers

A sink configuration that could rewrite its own query

Most SQL injection writeups focus on user-supplied values — a form field, a URL parameter, a search box. This one is different: the vulnerability lived in how JdbcSinkFunction.invoke() built the structure of the INSERT statement itself, not the data going into it.

JdbcSinkFunction is part of Lacus's RTC (real-time compute) engine. It's a Flink sink function that takes a stream of records (Map<String, String>) and writes them into a relational database. To build the INSERT statement, invoke() reads a JdbcSinkOption — which carries the target database name, table name, and column list — and formats them straight into the SQL text:

String sql = String.format("INSERT INTO %s.%s (%s) VALUES (%s)",
        db,
        table,
        columns,
        ...);

The row values were correctly bound with ? placeholders further down. But db, table, and the column names pulled from columns were never checked against anything — they went straight from configuration into the query string. If anything upstream of this sink (a dynamic sink-configuration API, a multi-tenant setup, a templated job definition) ever let an operator or attacker influence those three fields, the attacker effectively controlled part of the SQL grammar, not just a value inside it.

Affected Versions

Affected not applicable (first-party code)
Fixed in not applicable (first-party code)
Ecosystem Java / Maven
CVE / GHSA not assigned
CWE CWE-89 (SQL Injection)

The Vulnerability Explained

The core problem is that String.format() has no concept of "this is a SQL identifier, escape it accordingly." It just substitutes text. In the vulnerable version:

columns.add(firstFields.next().getKey());
}

String sql = String.format("INSERT INTO %s.%s (%s) VALUES (%s)",
        db,
        table,

db and table come from JdbcSinkOption, and columns is built from the keys of the incoming record map. None of these three go through any character-class check before landing in the query text.

Consider the regression test's attack strings: "users; DROP TABLE users;--" and "table` WHERE 1=1--". If a JdbcSinkOption were ever constructed with a table value like the first string, the resulting SQL would no longer be a single clean INSERT — it would contain a statement terminator and a second, attacker-chosen statement, with the rest commented out by --. Depending on the JDBC driver and whether multi-statement execution is enabled, that second statement runs with whatever privileges the sink's database connection holds.

The realistic attack surface here isn't a web form — it's the sink configuration path. Lacus's RTC engine is designed to be configured declaratively (data sink definitions, job templates, possibly multi-tenant pipeline configs). Anywhere that configuration data for db, table, or column names originates from a less-trusted source than the database admin — a tenant-supplied schema name, a UI field for "target table," a column mapping derived from an external schema — becomes a potential injection point. Because this is the identifier portion of the query, not the value portion, the existing ? parameterization for row data provided zero protection.

The Fix

The fix adds a dedicated identifier validator and applies it to every piece of the query that was being interpolated as raw text:

private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[a-zA-Z0-9_]+$");

private static String validateIdentifier(String identifier) throws SQLException {
    if (identifier == null || !SAFE_IDENTIFIER.matcher(identifier).matches()) {
        throw new SQLException("Invalid SQL identifier: " + identifier);
    }
    return identifier;
}

And at the call site, before the SQL string is ever built:

validateIdentifier(db);
validateIdentifier(table);
for (String column : columns) {
    validateIdentifier(column);
}

This works because it changes the trust model for db, table, and each column name from "assumed safe because it's configuration, not user input" to "explicitly verified to be a bare alphanumeric/underscore token." A legitimate database name, table name, or column name has no business containing a semicolon, a backtick, a space, or a comment marker — the allowlist regex ^[a-zA-Z0-9_]+$ rejects anything else outright and fails fast with a SQLException rather than silently building a dangerous query. The regression test's three inputs make the contract explicit: "users; DROP TABLE users;--" and "table` WHERE 1=1--" must be rejected, while "valid_table_name" must still produce a normal INSERT INTO ... statement.

Row values were left untouched — they were already using ? placeholders bound through the JDBC driver, which is the correct mechanism for literal data. The fix only had to close the identifier gap, because identifiers can never be parameterized the same way values can; the database driver has no API for "bind this string as a table name."

Key Takeaways

  • String.format() on SQL text treats identifiers and values identically — it has no awareness that %s.%s (%s) is schema/table/column syntax, not data.
  • Parameterizing values with ? (as this code already did for the row data) does nothing to protect identifiers like db, table, or column names — those need their own validation path.
  • A simple allowlist (^[a-zA-Z0-9_]+$) is often the right tool for identifiers, since legitimate database, table, and column names rarely need anything outside that character set.
  • Configuration-driven SQL (sink options, job templates, dynamic schema mappings) deserves the same scrutiny as user-form input — "it comes from config" is not a security boundary.
  • Fail closed: validateIdentifier() throws a SQLException immediately rather than attempting to sanitize or escape the identifier, which avoids the trap of incomplete escaping.

How Orbis AppSec Detected This

  • Source: the db, table, and columns fields of JdbcSinkOption, consumed inside JdbcSinkFunction.invoke()
  • Sink: the String.format("INSERT INTO %s.%s (%s) VALUES (%s)", ...) call that assembles the SQL statement
  • Missing control: no character-class or allowlist validation on identifiers before they were embedded in the query string
  • CWE: CWE-89 (SQL Injection)
  • Fix: added validateIdentifier(), enforcing ^[a-zA-Z0-9_]+$ on db, table, and every column name before the INSERT statement is constructed

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

Parameterized queries only protect the parts of a SQL statement that go through bind variables — table names, column names, and schema names are never among them, because the JDBC API has no mechanism to bind an identifier. JdbcSinkFunction.invoke() had correctly parameterized its row values but left db, table, and columns as raw interpolated text, which meant the sink's configuration itself was the attack surface. The fix is intentionally narrow and strict: a single allowlist regex, applied consistently to every identifier before the SQL string is built, that fails loudly on anything resembling an injection payload instead of trying to escape it.

Prevention and further reading

Frequently Asked Questions

Does the fix in `JdbcSinkFunction` affect the parameterized `?` placeholders used for row values?

No. Row values were already passed through JDBC `PreparedStatement` parameters; the fix only adds validation for `db`, `table`, and `columns`, which are SQL identifiers and can't be parameterized the same way.

What happens if `JdbcSinkOption` is configured with a table name like `table\` WHERE 1=1--`?

After the fix, `validateIdentifier()` matches it against `^[a-zA-Z0-9_]+$`, finds the backtick and other characters invalid, and throws a `SQLException` before the INSERT statement is ever built.

Why can't the `db`, `table`, and `columns` fields in `JdbcSinkOption` just use `?` placeholders like the row values do?

JDBC placeholders only bind literal values, not SQL identifiers like schema, table, or column names — those must be validated and safely embedded directly into the query text, which is what the new allowlist check does.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #129

Related Articles

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.

critical

Toolforge Database Creation SQL Injection via Unicode Backtick Bypass

A critical SQL injection vulnerability in the database creation routine of a Node.js wiki library allowed attackers to inject arbitrary SQL by exploiting a weak validation check. The fix replaces a character-based block with a strict allowlist pattern, ensuring only valid database identifiers reach the SQL engine.

critical

heatmap.php SQL Injection: $_REQUEST Parameters in Unparameterized

A critical SQL injection vulnerability in the heatmap data retrieval endpoint allowed attackers to execute arbitrary database commands by manipulating coordinate bounds or time range parameters. The vulnerability affected all six user-controlled $_REQUEST parameters passed directly into query construction without parameterization.

critical

Actual Budget addTransaction.sh SQL Injection via Shell Variable

A critical SQL injection vulnerability in Actual Budget's transaction automation script allowed attackers to manipulate database records through shell variables interpolated directly into SQL strings. The fix introduces proper escaping functions and numeric validation to prevent injection through unquoted fields.

critical

SQL's Insert() and Update() Methods Used F-String Interpolation in u2share_batch_give_sugar

The SQL helper class in u2share_batch_give_sugar used Python f-strings to construct INSERT and UPDATE queries, creating SQL injection vulnerabilities even though values appeared to come from internal constants. The fix replaces all f-string query construction with sqlite3 parameterized queries using `?` placeholders, eliminating string interpolation entirely from the database path.

critical

redmine_drawio View Hook Inlines Base64 Redmine API Keys

The Redmine drawio plugin's body-bottom view listener embedded the logged-in user's Redmine REST API key into client-side JavaScript on every wiki and issue page, "protected" only by Base64 encoding of the reversed string. Any script on the page — or anyone with browser developer tools, a cached copy of the HTML, or a proxy log — could decode it in one line and act as that user through the Redmine REST API. The fix removes the embedded credential from the rendered page entirely; the plugin's dia