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 likedb,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 aSQLExceptionimmediately 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, andcolumnsfields ofJdbcSinkOption, consumed insideJdbcSinkFunction.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_]+$ondb,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.