Skip to content

fix: SqlFormatter - fail closed on SQL parameter count mismatch - #11576

Open
ovr wants to merge 1 commit into
masterfrom
fix/backend-shared-sql-param-count-mismatch
Open

fix: SqlFormatter - fail closed on SQL parameter count mismatch#11576
ovr wants to merge 1 commit into
masterfrom
fix/backend-shared-sql-param-count-mismatch

Conversation

@ovr

@ovr ovr commented Aug 17, 2026

Copy link
Copy Markdown
Member

SqlEscaper.format substituted ?/?? placeholders by scanning the SQL string and silently tolerated a mismatch between placeholder and value counts — surplus values were dropped and surplus placeholders left as a literal ?. Because the compiler emits exactly one ? per parameter, any such mismatch means a stray literal ? in the SQL text (or a wrong-length values array), which shifts every subsequent value into the wrong slot.

Count the consumable placeholders (?/?? runs) in the single formatting pass and throw when the total does not equal values.length, reporting both counts. This turns silent, wrong-position substitution into a hard error.

@ovr
ovr requested a review from a team as a code owner August 17, 2026 12:58
@github-actions github-actions Bot added the javascript Pull requests that update Javascript code label Aug 17, 2026
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 2m 57s —— View job


Review: sound fix, but the guard has a hole and one behavior break needs calling out — 2 medium, 3 low (4 inline comments)

Full review — findings, security/perf notes, and task list

Task list

  • Read PR diff (git diff origin/master...HEAD)
  • Read packages/cubejs-backend-shared/src/sql-escape.ts in full
  • Check all four driver call sites (Athena / Presto / ksql / Pinot)
  • Compare old vs. new loop semantics for behavior regressions
  • Review test coverage
  • Post inline findings
  • Run package unit testsnot done: yarn --cwd packages/cubejs-backend-shared jest was blocked by the sandbox's command-approval policy. Findings below are from code reading only; please confirm CI green. Adding Bash(yarn --cwd packages/cubejs-backend-shared jest*) to --allowedTools would let me verify next time.

Verdict

The direction is right. SqlEscaper.format is the escaping path for four drivers that render user data-model SQL, and a stray literal ? shifting every subsequent value into the wrong slot is a genuine injection-adjacent hazard — turning that into a hard error is the correct conservative choice. The single-pass counting is clean: placeholders.lastIndex still advances across the continue for ???+ runs, so counting and substitution stay consistent, and the chunkIndex === 0 fast path is preserved. Not leaking the SQL text or values into the error message is a good call.

Findings

# Sev Where Issue
1 Medium src/sql-escape.ts:191-196 Error message tells the user to escape a literal ?, but the formatter has no escape syntax?? is an identifier placeholder, there is no \?. Unactionable advice.
2 Medium src/sql-escape.ts:180-182 Guard is bypassed by valueList.length === 0 early return at line 146. All four drivers call format(query, values || []), so a dynamically-built params array that lands on [] keeps the old silent-literal-? behavior. formatAnsi('SELECT ?', []) passes while formatAnsi('SELECT 1', ['x']) throws.
3 Low test/sql-escape.test.ts The one true regression isn't pinned: a literal ? after all placeholders used to render correctly (old loop exited early on valuesIndex < valueList.length), e.g. formatAnsi("SELECT ?, regexp_like(a, 'x+?')", ['v']). Now throws. Needs a test documenting it as intentional + a release note.
4 Low src/sql-escape.ts:135-139 JSDoc lost the still-true ???+-left-verbatim note, and gained no @throws despite format now throwing — a contract change for every driver.
5 Low src/sql-escape.ts:191 protected static parameterCountMismatch on a class that is neither exported nor subclassed. A module-level factory, or an exported error subclass so drivers can catch and attach query context, would be simpler and more useful.

Security

No new exposure; this closes one. The mismatch error deliberately omits SQL and values, so no data-model text or parameter contents leak into logs. Worth confirming the drivers' error paths don't re-append the raw query when this throws.

Performance

The loop no longer breaks once values are exhausted — it must scan the whole string to count. Irrelevant in practice (one regex pass over a query string, no allocation per non-placeholder match), and it's inherent to counting.

Documentation

No user-facing docs needed, but since this converts silently-tolerated input into a thrown error on a driver hot path, it belongs in the changelog / breaking-behavior notes for the Athena, Presto, ksql and Pinot drivers (see finding 3).

· branch [`fix/backend-shared-sql-param-count-mismatch`](https://github.com/cube-js/cube/tree/fix/backend-shared-sql-param-count-mismatch)

Comment on lines +191 to +196
protected static parameterCountMismatch(placeholderCount: number, valueCount: number): Error {
return new Error(
`SQL parameter count mismatch: ${placeholderCount} placeholder(s) but ${valueCount} value(s) supplied. ` +
'A literal \'?\' in SQL must be escaped or bound as a parameter.'
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remediation hint is not actionable: this formatter provides no escape syntax for a literal ?. ?? means "identifier placeholder", ???+ is left verbatim (so ??? is the only accidental workaround), and there is no \?. A user whose data-model SQL contains regexp_like(x, 'a+?') or url_extract_parameter(url, '?') gets told to "escape" something they can't escape.

Suggest either rewording to point at the real fixes (remove the stray ?, or pass a matching number of values), or actually implementing an escape (e.g. treat \? as a literal ?) and then keeping this wording.

Also: as a protected static on a class that is neither exported nor subclassed, this indirection buys nothing. A module-level factory — or better, an exported SqlParameterCountMismatchError extends Error so the four drivers (AthenaDriver, PrestoDriver, KsqlDriver, PinotDriver) can catch it and attach query context — would be more useful.

Suggested change
protected static parameterCountMismatch(placeholderCount: number, valueCount: number): Error {
return new Error(
`SQL parameter count mismatch: ${placeholderCount} placeholder(s) but ${valueCount} value(s) supplied. ` +
'A literal \'?\' in SQL must be escaped or bound as a parameter.'
);
}
protected static parameterCountMismatch(placeholderCount: number, valueCount: number): Error {
return new Error(
`SQL parameter count mismatch: ${placeholderCount} placeholder(s) but ${valueCount} value(s) supplied. ` +
'Remove any literal \'?\' from the SQL text or supply one value per placeholder.'
);
}
}

Comment thread packages/cubejs-backend-shared/src/sql-escape.ts
Comment thread packages/cubejs-backend-shared/src/sql-escape.ts
Comment thread packages/cubejs-backend-shared/test/sql-escape.test.ts
SqlEscaper.format substituted `?`/`??` placeholders by scanning the SQL
string and silently tolerated a mismatch between placeholder and value
counts — surplus values were dropped and surplus placeholders left as a
literal `?`. Because the compiler emits exactly one `?` per parameter, any
such mismatch means a stray literal `?` in the SQL text (or a wrong-length
values array), which shifts every subsequent value into the wrong slot.

Count the consumable placeholders (`?`/`??` runs) in the single formatting
pass and throw when the total does not equal `values.length`, reporting both
counts. The empty-values array is checked too, so an unparametrized query is
no longer exempt from the check. This turns silent, wrong-position
substitution into a hard error across every interpolating driver (Presto,
Trino, Athena, Dremio, ksqlDB, Pinot, and the JDBC-backed engines) at one
shared point. A missing/omitted values argument (null/undefined) still passes
the SQL through unchanged, since no substitution is requested.

Tests updated to assert the fail-closed behavior and cover literal `?` in
SQL text, an empty values array, surplus/insufficient values, and `??`
identifier counting.

BREAKING CHANGE: these four drivers format user data-model SQL, and a literal
`?` that previously rendered fine now throws — including in unparametrized
queries, which used to skip the check entirely. The old loop stopped once all
values were consumed, so a stray `?` positioned AFTER every real placeholder
(or in a query with no parameters at all) was left verbatim, e.g.
formatAnsi("SELECT ?, regexp_like(a, 'x+?')", ['v']) returned
"SELECT 'v', regexp_like(a, 'x+?')". Failing closed in both directions is
deliberate; a lazy-quantifier regex or a `?` inside a string literal in the
data model must now be bound as a parameter rather than passed through the `?`
placeholder channel.
@ovr
ovr force-pushed the fix/backend-shared-sql-param-count-mismatch branch from dabc3ee to 70b1cfe Compare August 17, 2026 15:42
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Re-review after force-push: two prior findings fixed, but the [] fix widened a user-facing break — 1 medium, 3 low (3 new inline comments)

Full review — findings, thread housekeeping, and task list

Task list

  • Diff current revision (git diff origin/master...HEAD) — commit amended, not a new commit
  • Re-read packages/cubejs-backend-shared/src/sql-escape.ts in full
  • Re-check every call site (found a 6th: DremioDriver)
  • Compare against my previous round's findings
  • Review the new test block
  • Resolve my own stale threads
  • Post new inline findings
  • Run package unit testsnot done: npx jest packages/cubejs-backend-shared/test/sql-escape.test.ts was blocked by the sandbox's command-approval policy (as last round). Findings are from code reading only; please confirm CI green. Adding Bash(npx jest*) or Bash(yarn --cwd packages/cubejs-backend-shared jest*) to --allowedTools would let me verify.

What changed since my last review

The valueList.length === 0 early return is gone, and the tests now pin the literal-?-after-placeholders regression. That addresses both of my prior findings #2 and #3, so I've resolved those two threads. The ??-as-identifier counting, the ???+ verbatim rule and the chunkIndex === 0 fast path all still hold, and placeholders.lastIndex still advances across the continue, so counting and substitution stay in lockstep.

Per @ovr's call on the doc block, I've dropped the JSDoc/@throws/explanatory-comment asks entirely — that thread is resolved and I'm not re-raising it.

Findings

# Sev Where Issue
1 Medium src/sql-escape.ts:140-148 Removing the [] exemption closed the hole but removed the last workaround for a literal ? — and the formatter still has no escape syntax. regexp_like(a, 'x+?') with no bound params now hard-fails on all six drivers with no user-side fix. Suggest implementing \?, or keeping the [] exemption.
2 Low src/sql-escape.ts:188-193 Error message still says the ? "must be escaped" — instructing the user toward a fix that does not exist. Also protected static on a class that is neither exported nor subclassed. (existing thread, deliberately left unresolved)
3 Low src/sql-escape.ts:164-179 if (valuesIndex < valueList.length) is unreachable-false on any returning path, so the skipped work is always discarded. Counting in a cheap pre-pass makes the invariant structural and drops the eslint-disable. Also fixes error-ordering vs. escapeValue's Unsupported parameter type throw.
4 Low test/sql-escape.test.ts:570-583 Nothing asserts the message carries both counts (the point of the error); format(sql) vs format(sql, []) asymmetry is pinned but unnamed; guard only exercised through ansi/presto.

Finding 1 is the one I'd want a decision on before merge — not because the direction is wrong (failing closed is right), but because the blast radius is bigger than the description says. Non-greedy quantifiers (.*?, x+?) inside regexp_like / regexp_extract / regexp_replace are ordinary in Athena/Trino/Presto data models, and every driver normalizes to format(query, values || []), so the zero-params path is the common one. Either add \? as an escape (then the error message becomes true), or keep [] exempt — the corruption vector needs ≥1 value to shift anything, so the actual hazard closes either way.

Security

Still net-positive; this closes an injection-adjacent hazard rather than opening one. The error deliberately omits SQL text and parameter values, so nothing from the data model or the parameters leaks into logs. Worth confirming the drivers' catch paths don't re-append the raw query when this throws.

Performance

Non-issue. The loop must scan the whole string to count, and the pre-pass in finding 3 adds a second scan — both are one regex walk over a query string with no per-match allocation.

Documentation

Needs a release / changelog note. Previously-valid data models become hard query failures on six drivers: athena, prestodb (incl. Trino), ksql, pinot, dremio — and I listed only four last round.

· branch fix/backend-shared-sql-param-count-mismatch · View job run

Comment on lines 140 to +148
public format(sql: string, values?: unknown): string {
if (values === null || values === undefined) {
return sql;
}

const valueList = Array.isArray(values) ? values : [values];
if (valueList.length === 0) {
return sql;
}

const placeholders = /\?+/g;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing the [] exemption removed the last workaround for a literal ?, and there is no escape syntax to replace it. This is the widest-reaching behavior change in the PR and it isn't in the description.

Before: valueList.length === 0 returned sql verbatim, so a data model whose SQL contained a ? but bound no parameters worked. Now:

formatAnsi("SELECT regexp_like(a, 'x+?') FROM t", [])  // throws (pinned at test:581)

Every consumer normalizes to [], so that path is the common one, not an edge case — six call sites, not the four I listed earlier:

  • cubejs-athena-driver/src/AthenaDriver.ts:71 (params: unknown[])
  • cubejs-prestodb-driver/src/PrestoDriver.ts:219
  • cubejs-ksql-driver/src/KsqlDriver.ts:170
  • cubejs-pinot-driver/src/PinotDriver.ts:149
  • cubejs-dremio-driver/driver/DremioDriver.js:21,211

The realistic breakage is non-greedy regex quantifiers.*?, x+?, \d{2,}? — inside regexp_like / regexp_extract / regexp_replace, which are ordinary in Athena/Trino/Presto data models, plus a bare ? in any string literal. Such a model becomes unqueryable with no user-side fix: \? is not recognized, and the only thing that survives is the accidental ???+ run. The error message compounds it by instructing the user to escape something the formatter cannot escape.

I'd support one of:

  1. Add an escape — treat \? as a literal ? that consumes no value and isn't counted, then the current message becomes true and models have a migration path. Cheapest inside the existing loop: widen the regex to /\\\?|\?+/g and continue (emitting ?) on the escaped branch.
  2. Keep the [] exemption and accept that only the mismatch-with-values case fails closed — weaker, but the corruption vector (stray ? shifting real values) requires at least one value, so it still closes the actual hazard.

Either way this needs a release note for all six drivers, since it turns previously-valid data models into hard query failures.

Comment on lines +164 to 179
placeholderCount += 1;

if (valuesIndex < valueList.length) {
const rendered = match[0].length === 2
? this.escapeIdentifier(String(valueList[valuesIndex]))
: this.escapeValue(valueList[valuesIndex]);

result += sql.slice(chunkIndex, match.index) + rendered;
chunkIndex = placeholders.lastIndex;
valuesIndex += 1;
}
match = placeholders.exec(sql);
}

if (placeholderCount !== valueList.length) {
throw SqlEscaper.parameterCountMismatch(placeholderCount, valueList.length);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but the shape is misleading: valuesIndex < valueList.length at line 166 can only be false on a path that throws at 177, so every string this branch skips building is discarded. The guard reads as if partial substitution is a supported outcome, when it never escapes the function.

Counting is cheap and independent of escaping, so the two concerns can separate — validate first, then substitute with no conditional at all:

const placeholderCount = (sql.match(/\?+/g) ?? [])
  .filter((run) => run.length <= 2).length;

if (placeholderCount !== valueList.length) {
  throw SqlEscaper.parameterCountMismatch(placeholderCount, valueList.length);
}

…after which the substitution loop drops the if and the eslint-disable no-cond-assign. Two passes over a query string is not a cost anyone can measure, and it makes the invariant "we only ever substitute when the counts already match" structural rather than something a reader has to derive from the throw fifteen lines down.

Related ordering nit: escapeValue can throw Unsupported parameter type (line 131) before the count check runs, so a call that is both count-mismatched and carries a symbol/function value reports the less actionable of the two errors. Validating up front fixes that too.

Comment on lines +570 to 583
it('leaves the no-substitution paths non-throwing', () => {
expect(formatAnsi('SELECT 1')).toBe('SELECT 1');
expect(formatAnsi('SELECT 1', [])).toBe('SELECT 1');
expect(formatAnsi('SELECT ???', [])).toBe('SELECT ???');
expect(formatAnsi('SELECT ?')).toBe('SELECT ?');
expect(formatAnsi('SELECT ?', null)).toBe('SELECT ?');
expect(formatAnsi('SELECT ?', undefined)).toBe('SELECT ?');
});

it('rejects a placeholder against an empty values array', () => {
expect(() => formatAnsi('SELECT ?', [])).toThrow(/parameter count mismatch/);
expect(() => formatAnsi("SELECT regexp_like(a, 'x+?') FROM t", []))
.toThrow(/parameter count mismatch/);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regression case I asked about last round is pinned now (line 549) — thanks, that's the important one. Two narrow gaps left in this block:

  1. Nothing asserts the counts in the message. Every case matches /parameter count mismatch/, which passes even if the two numbers were swapped or both wrong — and reporting both counts is the stated point of the new error. One case with .toThrow(/2 placeholder\(s\) but 1 value\(s\)/) would lock it in.
  2. format(sql) vs format(sql, []) asymmetry isn't explained. Lines 571-576 and 579-580 sit adjacent and disagree: formatAnsi('SELECT ?') returns 'SELECT ?', formatAnsi('SELECT ?', []) throws. That's a defensible contract (undefined = "no parameter binding requested" vs [] = "a values array of length 0"), and the tests do pin both — but the test names don't say which is which, so a future reader hitting the throw can't tell whether the undefined path is intentional or just untightened. Renaming to something like 'treats a missing values argument as no-binding-requested (does not enforce the count)' carries the intent.

Also: the guard is only driven through formatAnsi / presto. Since the counting lives in format and not in the dialect rules it can't be dialect-dependent, but one formatMySql or formatSparkSql case would keep it that way if someone later moves the check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants