diff --git a/lib/sea/SeaInputValidation.ts b/lib/sea/SeaInputValidation.ts new file mode 100644 index 00000000..8d4e4113 --- /dev/null +++ b/lib/sea/SeaInputValidation.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Int64 from 'node-int64'; +import { DBSQLParameter, DBSQLParameterValue } from '../DBSQLParameter'; +import ParameterError from '../errors/ParameterError'; + +/** + * Coerce an empty-string metadata argument to `undefined`. + * + * The kernel's `Identifier` / `LikePattern` reject empty strings with + * `InvalidArgument`, whereas the Thrift backend forwards `""` to the server + * which treats it as "unspecified" (match-all / session default). To keep the + * SEA metadata surface behaviourally identical to Thrift, the SEA adapter + * maps `""` → `undefined` before crossing the napi boundary so the kernel + * sees "argument omitted" rather than "empty identifier". + */ +export function emptyToUndefined(value: string | undefined | null): string | undefined { + return value == null || value === '' ? undefined : value; +} + +/** + * Walk a SQL string counting `?` parameter markers, ignoring markers inside + * string literals (`'...'`, `"..."`), backtick-quoted identifiers, and + * comments (`-- ...`, `/* ... *​/`). Mirrors the kernel's + * `statement::params::count_parameter_markers` state machine so the JS-side + * arity check matches what the kernel binds. + */ +export function countParameterMarkers(sql: string): number { + let count = 0; + let i = 0; + const n = sql.length; + type State = 'normal' | 'single' | 'double' | 'backtick' | 'line' | 'block'; + let state: State = 'normal'; + while (i < n) { + const c = sql[i]; + const next = i + 1 < n ? sql[i + 1] : ''; + switch (state) { + case 'normal': + if (c === '?') { + count += 1; + } else if (c === "'") { + state = 'single'; + } else if (c === '"') { + state = 'double'; + } else if (c === '`') { + state = 'backtick'; + } else if (c === '-' && next === '-') { + state = 'line'; + i += 1; + } else if (c === '/' && next === '*') { + state = 'block'; + i += 1; + } + break; + case 'single': + if (c === "'" && next === "'") i += 1; // escaped '' + else if (c === "'") state = 'normal'; + break; + case 'double': + if (c === '"' && next === '"') i += 1; // escaped "" + else if (c === '"') state = 'normal'; + break; + case 'backtick': + if (c === '`') state = 'normal'; + break; + case 'line': + if (c === '\n') state = 'normal'; + break; + case 'block': + if (c === '*' && next === '/') { + state = 'normal'; + i += 1; + } + break; + } + i += 1; + } + return count; +} + +/** + * Reject a parameter value that cannot be bound as a scalar. Arrays and plain + * objects stringify to garbage (e.g. `[1,2,3]` → `"1,2,3"`) that the server + * fails to coerce — on the Thrift path the operation never returns to + * FINISHED (a DoS hazard), and on SEA it surfaces an opaque server error. We + * fail fast at bind time instead, mirroring the kernel's compound-type + * rejection. `DBSQLParameter`, `Int64`, `Date`, and JS primitives are allowed. + */ +export function assertBindableValue(value: DBSQLParameter | DBSQLParameterValue, label: string): void { + if (value instanceof DBSQLParameter) return; + if (value === null || value === undefined) return; + if (Array.isArray(value)) { + throw new ParameterError( + `${label} is an array; compound types (ARRAY/MAP/STRUCT) are not bindable as a parameter value`, + ); + } + if (typeof value === 'object' && !(value instanceof Date) && !(value instanceof Int64)) { + throw new ParameterError( + `${label} is an object; only scalar values (string/number/bigint/boolean), Date, and Int64 are bindable`, + ); + } +} diff --git a/lib/sea/SeaPositionalParams.ts b/lib/sea/SeaPositionalParams.ts index 040a8992..807eb491 100644 --- a/lib/sea/SeaPositionalParams.ts +++ b/lib/sea/SeaPositionalParams.ts @@ -14,6 +14,7 @@ import { DBSQLParameter, DBSQLParameterValue } from '../DBSQLParameter'; import { SeaNativeTypedValueInput, SeaNativeNamedTypedValueInput } from './SeaNativeLoader'; +import { assertBindableValue } from './SeaInputValidation'; /** * Derive `(precision,scale)` from a decimal value string for the SEA @@ -73,7 +74,10 @@ export function buildSeaPositionalParams( if (ordinalParameters === undefined || ordinalParameters.length === 0) { return undefined; } - return ordinalParameters.map(toTypedValueInput); + return ordinalParameters.map((value, i) => { + assertBindableValue(value, `ordinalParameters[${i}]`); + return toTypedValueInput(value); + }); } /** @@ -88,8 +92,8 @@ export function buildSeaNamedParams( if (namedParameters === undefined || Object.keys(namedParameters).length === 0) { return undefined; } - return Object.keys(namedParameters).map((name) => ({ - name, - ...toTypedValueInput(namedParameters[name]), - })); + return Object.keys(namedParameters).map((name) => { + assertBindableValue(namedParameters[name], `namedParameters[${name}]`); + return { name, ...toTypedValueInput(namedParameters[name]) }; + }); } diff --git a/lib/sea/SeaSessionBackend.ts b/lib/sea/SeaSessionBackend.ts index 7889cf1d..f8ca84f3 100644 --- a/lib/sea/SeaSessionBackend.ts +++ b/lib/sea/SeaSessionBackend.ts @@ -38,6 +38,7 @@ import SeaTableTypeFilter from './SeaTableTypeFilter'; import { seaServerInfoValue } from './SeaServerInfo'; import { buildSeaPositionalParams, buildSeaNamedParams } from './SeaPositionalParams'; import ParameterError from '../errors/ParameterError'; +import { emptyToUndefined, countParameterMarkers } from './SeaInputValidation'; export interface SeaSessionBackendOptions { /** The opaque napi `Connection` handle returned by `openSession`. */ @@ -141,6 +142,18 @@ export default class SeaSessionBackend implements ISessionBackend { if (positionalParams !== undefined && namedParams !== undefined) { throw new ParameterError('Driver does not support both ordinal and named parameters.'); } + // Arity check: positional params must match the `?` marker count, or the + // server silently binds the prefix and drops the rest (data-correctness + // footgun). Markers inside string literals / comments are not counted. + if (positionalParams !== undefined) { + const markerCount = countParameterMarkers(statement); + if (positionalParams.length !== markerCount) { + throw new ParameterError( + `ordinalParameters length ${positionalParams.length} does not match the ` + + `${markerCount} '?' placeholder(s) in the SQL`, + ); + } + } const nativeOptions: SeaNativeExecuteOptions = {}; if (positionalParams !== undefined) { @@ -198,8 +211,8 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listSchemas( - request.catalogName, - request.schemaName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), ); } catch (err) { throw decodeNapiKernelError(err); @@ -212,9 +225,9 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listTables( - request.catalogName, - request.schemaName, - request.tableName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), + emptyToUndefined(request.tableName), request.tableTypes, ); } catch (err) { @@ -245,10 +258,10 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listColumns( - request.catalogName, - request.schemaName, - request.tableName, - request.columnName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), + emptyToUndefined(request.tableName), + emptyToUndefined(request.columnName), ); } catch (err) { throw decodeNapiKernelError(err); @@ -261,9 +274,9 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listFunctions( - request.catalogName, - request.schemaName, - request.functionName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), + emptyToUndefined(request.functionName), ); } catch (err) { throw decodeNapiKernelError(err); diff --git a/tests/unit/sea/execution.test.ts b/tests/unit/sea/execution.test.ts index 23408aed..7a2d45d8 100644 --- a/tests/unit/sea/execution.test.ts +++ b/tests/unit/sea/execution.test.ts @@ -82,7 +82,10 @@ class FakeNativeConnection implements SeaNativeConnection { // Metadata stubs — return a fresh statement so callers can test wrapping. public async listCatalogs() { return new FakeNativeStatement(); } - public async listSchemas(_catalog: string | undefined, _schemaPattern: string | undefined) { + public lastListSchemasArgs?: [string | undefined | null, string | undefined | null]; + + public async listSchemas(catalog: string | undefined | null, schemaPattern: string | undefined | null) { + this.lastListSchemasArgs = [catalog, schemaPattern]; return new FakeNativeStatement(); } @@ -397,6 +400,37 @@ describe('SeaSessionBackend', () => { expect((thrown as Error).message).to.match(/both ordinal and named/); }); + it('executeStatement rejects an array-shaped ordinal parameter (DoS guard)', async () => { + const session = makeSession(new FakeNativeConnection()); + let thrown: unknown; + try { + await session.executeStatement('SELECT ?', { ordinalParameters: [[1, 2, 3]] as never }); + } catch (err) { + thrown = err; + } + expect(thrown).to.be.instanceOf(Error); + expect((thrown as Error).message).to.match(/array/); + }); + + it('executeStatement rejects an ordinal-parameter count mismatch', async () => { + const session = makeSession(new FakeNativeConnection()); + let thrown: unknown; + try { + await session.executeStatement('SELECT ? AS only', { ordinalParameters: [1, 2] }); + } catch (err) { + thrown = err; + } + expect(thrown).to.be.instanceOf(Error); + expect((thrown as Error).message).to.match(/does not match/); + }); + + it('getSchemas coerces empty-string args to undefined (Thrift-parity for the kernel)', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.getSchemas({ catalogName: '', schemaName: '%' }); + expect(connection.lastListSchemasArgs).to.deep.equal([undefined, '%']); + }); + it('executeStatement uses the no-options fast path when nothing is bound', async () => { const connection = new FakeNativeConnection(); const session = makeSession(connection); diff --git a/tests/unit/sea/inputValidation.test.ts b/tests/unit/sea/inputValidation.test.ts new file mode 100644 index 00000000..b7766d52 --- /dev/null +++ b/tests/unit/sea/inputValidation.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import Int64 from 'node-int64'; +import { + emptyToUndefined, + countParameterMarkers, + assertBindableValue, +} from '../../../lib/sea/SeaInputValidation'; +import { DBSQLParameter, DBSQLParameterType } from '../../../lib/DBSQLParameter'; +import ParameterError from '../../../lib/errors/ParameterError'; + +describe('SeaInputValidation.emptyToUndefined', () => { + it('maps empty string and null/undefined to undefined; passes real values', () => { + expect(emptyToUndefined('')).to.equal(undefined); + expect(emptyToUndefined(null)).to.equal(undefined); + expect(emptyToUndefined(undefined)).to.equal(undefined); + expect(emptyToUndefined('samples')).to.equal('samples'); + expect(emptyToUndefined('%')).to.equal('%'); + }); +}); + +describe('SeaInputValidation.countParameterMarkers', () => { + it('counts bare markers', () => { + expect(countParameterMarkers('SELECT ?')).to.equal(1); + expect(countParameterMarkers('SELECT * FROM t WHERE a = ? AND b = ?')).to.equal(2); + expect(countParameterMarkers('SELECT 1')).to.equal(0); + }); + + it('ignores markers inside string literals, identifiers, and comments', () => { + expect(countParameterMarkers("SELECT '?' AS q")).to.equal(0); + expect(countParameterMarkers('SELECT "?" AS q')).to.equal(0); + expect(countParameterMarkers('SELECT `a?b` FROM t')).to.equal(0); + expect(countParameterMarkers('SELECT 1 -- ? in a line comment\n, ?')).to.equal(1); + expect(countParameterMarkers('SELECT /* ? in block */ ?')).to.equal(1); + expect(countParameterMarkers("SELECT 'it''s ?' , ?")).to.equal(1); // escaped quote + }); +}); + +describe('SeaInputValidation.assertBindableValue', () => { + it('accepts scalars, Date, Int64, bigint, null, and DBSQLParameter', () => { + expect(() => assertBindableValue(42, 'p')).to.not.throw(); + expect(() => assertBindableValue('x', 'p')).to.not.throw(); + expect(() => assertBindableValue(true, 'p')).to.not.throw(); + expect(() => assertBindableValue(BigInt(10), 'p')).to.not.throw(); + expect(() => assertBindableValue(null, 'p')).to.not.throw(); + expect(() => assertBindableValue(new Date(), 'p')).to.not.throw(); + expect(() => assertBindableValue(new Int64(5), 'p')).to.not.throw(); + expect(() => assertBindableValue(new DBSQLParameter({ type: DBSQLParameterType.INTEGER, value: 1 }), 'p')).to.not.throw(); + }); + + it('rejects arrays (compound types)', () => { + expect(() => assertBindableValue([1, 2, 3] as never, 'ordinalParameters[0]')).to.throw(ParameterError, /array/); + }); + + it('rejects plain objects', () => { + expect(() => assertBindableValue({ a: 1 } as never, 'p')).to.throw(ParameterError, /object/); + }); +});