Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
57 changes: 57 additions & 0 deletions docs/rules/no-unsafe-sqlite-interpolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# no-unsafe-sqlite-interpolation

📝 Disallow interpolation into SQL strings passed to Node’s `node:sqlite` APIs.

💼 This rule is enabled in the following [configs](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config): ✅ `recommended`, ☑️ `unopinionated`.

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

Interpolating values into SQL strings makes them part of the SQL source instead of bound parameters. This can lead to SQL injection and data corruption.

Tagged templates passed directly to `exec()` or `prepare()` are also reported because those methods do not bind tagged-template interpolations, including static tags such as `String.raw`.

Use placeholders with `DatabaseSync#prepare()` and bind values when executing the prepared statement:

```js
const query = database.prepare('SELECT * FROM users WHERE id = ?');
query.get(id);
```

For Node.js 24.9.0 and later, use `SQLTagStore` tagged templates, which bind interpolated values as parameters:

```js
const sql = database.createTagStore();
sql.get`SELECT * FROM users WHERE id = ${id}`;
```

It only recognizes `DatabaseSync` instances imported from exactly `node:sqlite`, including simple `const` aliases of constructors, namespaces, and instances. It checks only `exec()` and `prepare()` and ignores string concatenation, generic query methods, dynamic imports, factory-created databases, and mutable assignment flows.

## Examples

```js
import {DatabaseSync} from 'node:sqlite';

const database = new DatabaseSync(':memory:');

// ❌
database.exec(`SELECT * FROM users WHERE id = ${id}`);
```

```js
import {DatabaseSync} from 'node:sqlite';

const database = new DatabaseSync(':memory:');

// ❌
database.prepare(`SELECT * FROM users WHERE id = ${id}`);
```

```js
import {DatabaseSync} from 'node:sqlite';

const database = new DatabaseSync(':memory:');

// ✅
database.exec('CREATE TABLE users (id INTEGER)');
```
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export default defineConfig([
| [no-unsafe-dom-html](docs/rules/no-unsafe-dom-html.md) | Disallow unsafe DOM HTML APIs. | | | | |
| [no-unsafe-promise-all-settled-values](docs/rules/no-unsafe-promise-all-settled-values.md) | Disallow reading `.value` from `Promise.allSettled()` results without a fulfilled status guard. | ✅ ☑️ | | | |
| [no-unsafe-property-key](docs/rules/no-unsafe-property-key.md) | Disallow unsafe values as property keys. | ✅ | | | |
| [no-unsafe-sqlite-interpolation](docs/rules/no-unsafe-sqlite-interpolation.md) | Disallow interpolation into SQL strings passed to Node’s `node:sqlite` APIs. | ✅ ☑️ | | | |
| [no-unsafe-string-replacement](docs/rules/no-unsafe-string-replacement.md) | Disallow non-literal replacement values in `String#replace()` and `String#replaceAll()`. | ✅ | | | |
| [no-unused-array-method-return](docs/rules/no-unused-array-method-return.md) | Disallow ignoring the return value of selected array methods. | ✅ ☑️ | | | |
| [no-unused-properties](docs/rules/no-unused-properties.md) | Disallow unused object properties. | | | | |
Expand Down
1 change: 1 addition & 0 deletions rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export {default as 'no-unsafe-buffer-conversion'} from './no-unsafe-buffer-conve
export {default as 'no-unsafe-dom-html'} from './no-unsafe-dom-html.js';
export {default as 'no-unsafe-promise-all-settled-values'} from './no-unsafe-promise-all-settled-values.js';
export {default as 'no-unsafe-property-key'} from './no-unsafe-property-key.js';
export {default as 'no-unsafe-sqlite-interpolation'} from './no-unsafe-sqlite-interpolation.js';
export {default as 'no-unsafe-string-replacement'} from './no-unsafe-string-replacement.js';
export {default as 'no-unused-array-method-return'} from './no-unused-array-method-return.js';
export {default as 'no-unused-properties'} from './no-unused-properties.js';
Expand Down
304 changes: 304 additions & 0 deletions rules/no-unsafe-sqlite-interpolation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import {findVariable, getPropertyName} from '@eslint-community/eslint-utils';
import {isRuntimeImportSpecifier, isTypeScriptExpressionWrapper} from './utils/index.js';

const MESSAGE_ID = 'no-unsafe-sqlite-interpolation';
const messages = {
[MESSAGE_ID]: 'Do not interpolate values into `node:sqlite` SQL strings.',
};

const databaseMethods = new Set([
'exec',
'prepare',
]);

const unwrapExpression = node => {
while (node && (node.type === 'ChainExpression' || node.type === 'TSInstantiationExpression' || isTypeScriptExpressionWrapper(node))) {
node = node.expression;
}

return node;
};

const getNonTypeDefinitions = variable => variable.defs.filter(definition => definition.type !== 'Type');

/** Get the unique value binding and definition for an identifier, skipping type-only bindings. */
const getVariableInfo = (node, context) => {
if (node.type !== 'Identifier') {
return;
}

let scope = context.sourceCode.getScope(node);
let variable = findVariable(scope, node);
let definitions = variable ? getNonTypeDefinitions(variable) : [];
while (variable && definitions.length === 0) {
scope = scope.upper;
if (!scope) {
return;
}

variable = findVariable(scope, node.name);
definitions = variable ? getNonTypeDefinitions(variable) : [];
}

if (!variable || definitions.length !== 1) {
return;
}

return {
variable,
definition: definitions[0],
};
};

const getConstVariableInfo = (node, context) => {
const variableInfo = getVariableInfo(node, context);
const definition = variableInfo?.definition;
if (
!variableInfo
|| definition.type !== 'Variable'
|| definition.node.type !== 'VariableDeclarator'
|| definition.parent.type !== 'VariableDeclaration'
|| definition.parent.kind !== 'const'
) {
return;
}

return {
...variableInfo,
initializer: definition.node.init,
};
};

const getSimpleConstVariableInfo = (node, context) => {
const variableInfo = getConstVariableInfo(node, context);
return variableInfo?.definition.node.id.type === 'Identifier' ? variableInfo : undefined;
};

const getImportSpecifierName = node => node.imported.type === 'Identifier' ? node.imported.name : node.imported.value;

// Only runtime ESM imports are supported. CommonJS `require()` and TypeScript `import =` declarations are intentionally ignored.
const getNodeSqliteImportSpecifier = (node, context) => {
const definition = getVariableInfo(node, context)?.definition;
if (
!definition
|| definition.type !== 'ImportBinding'
|| definition.parent.type !== 'ImportDeclaration'
|| definition.parent.source.value !== 'node:sqlite'
|| !isRuntimeImportSpecifier(definition.node)
) {
return;
}

return definition.node;
};

const isDatabaseSyncImport = (node, context) => {
const specifier = getNodeSqliteImportSpecifier(node, context);
return specifier?.type === 'ImportSpecifier'
&& getImportSpecifierName(specifier) === 'DatabaseSync';
};

const isSqliteNamespaceImport = (node, context) => {
const specifier = getNodeSqliteImportSpecifier(node, context);

return specifier?.type === 'ImportNamespaceSpecifier'
|| specifier?.type === 'ImportDefaultSpecifier'
|| (
specifier?.type === 'ImportSpecifier'
&& getImportSpecifierName(specifier) === 'default'
);
};

const isDatabaseSyncConstructor = (node, context, seenVariables = new Set()) => {
const callee = unwrapExpression(node);
if (!callee) {
return false;
}

if (callee.type === 'Identifier') {
if (isDatabaseSyncImport(callee, context)) {
return true;
}

const variableInfo = getSimpleConstVariableInfo(callee, context);
if (!variableInfo || seenVariables.has(variableInfo.variable)) {
return false;
}

seenVariables.add(variableInfo.variable);
return isDatabaseSyncConstructor(variableInfo.initializer, context, seenVariables);
}

if (callee.type !== 'MemberExpression' || getPropertyName(callee, context.sourceCode.getScope(callee)) !== 'DatabaseSync') {
return false;
}

return isSqliteNamespace(callee.object, context);
};

const isSqliteNamespace = (node, context, seenVariables = new Set()) => {
node = unwrapExpression(node);
if (node?.type !== 'Identifier') {
return false;
}

if (isSqliteNamespaceImport(node, context)) {
return true;
}

const variableInfo = getSimpleConstVariableInfo(node, context);
if (!variableInfo || seenVariables.has(variableInfo.variable)) {
return false;
}

seenVariables.add(variableInfo.variable);
return isSqliteNamespace(variableInfo.initializer, context, seenVariables);
};

const createDatabaseInstanceChecker = context => {
const cache = new WeakMap();

const isDatabaseInstance = (node, seenVariables = new Set()) => {
node = unwrapExpression(node);
if (!node) {
return false;
}

if (node.type === 'NewExpression') {
return isDatabaseSyncConstructor(node.callee, context);
}

if (node.type !== 'Identifier') {
return false;
}

const variableInfo = getSimpleConstVariableInfo(node, context);
if (!variableInfo) {
return false;
}

const {variable, initializer} = variableInfo;
if (cache.has(variable)) {
return cache.get(variable);
}

if (seenVariables.has(variable)) {
return false;
}

seenVariables.add(variable);
const result = isDatabaseInstance(initializer, seenVariables);
cache.set(variable, result);

return result;
};

return isDatabaseInstance;
};

const createUnsafeSqlArgumentChecker = context => {
const cache = new WeakMap();

const isUnsafeSqlArgument = (node, seenVariables = new Set()) => {
node = unwrapExpression(node);
if (!node) {
return false;
}

if (node.type === 'TemplateLiteral') {
return node.expressions.length > 0;
}

if (node.type === 'TaggedTemplateExpression') {
return true;
}

if (node.type !== 'Identifier') {
return false;
}

const variableInfo = getSimpleConstVariableInfo(node, context);
if (!variableInfo) {
return false;
}

const {variable, initializer} = variableInfo;
if (cache.has(variable)) {
return cache.get(variable);
}

if (seenVariables.has(variable)) {
return false;
}

seenVariables.add(variable);
const result = isUnsafeSqlArgument(initializer, seenVariables);
cache.set(variable, result);

return result;
};

return isUnsafeSqlArgument;
};

const getProblem = (callExpression, context, isDatabaseInstance, isUnsafeSqlArgument) => {
const callee = unwrapExpression(callExpression.callee);
if (callee?.type !== 'MemberExpression') {
return;
}

const method = getPropertyName(callee, context.sourceCode.getScope(callee));
if (!databaseMethods.has(method) || !isDatabaseInstance(callee.object)) {
return;
}

const [sql] = callExpression.arguments;
if (!isUnsafeSqlArgument(sql, new Set())) {
return;
}

return {
node: sql,
messageId: MESSAGE_ID,
};
};

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const callExpressions = [];

context.on('CallExpression', node => {
callExpressions.push(node);
});

context.onExit('Program', function * () {
const isDatabaseInstance = createDatabaseInstanceChecker(context);
const isUnsafeSqlArgument = createUnsafeSqlArgumentChecker(context);

for (const callExpression of callExpressions) {
const problem = getProblem(callExpression, context, isDatabaseInstance, isUnsafeSqlArgument);
if (problem) {
yield problem;
}
}
});
};

/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Disallow interpolation into SQL strings passed to Node’s `node:sqlite` APIs.',
recommended: 'unopinionated',
},
schema: [],
messages,
languages: [
'js/js',
],
},
};

export default config;
Loading
Loading