Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions lib/sea/SeaInputValidation.ts
Original file line number Diff line number Diff line change
@@ -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`,
);
}
}
14 changes: 9 additions & 5 deletions lib/sea/SeaPositionalParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
}

/**
Expand All @@ -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]) };
});
}
37 changes: 25 additions & 12 deletions lib/sea/SeaSessionBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/sea/execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions tests/unit/sea/inputValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading