Repository: nodejs/node
Pull request: sqlite: allow setting defensive flag

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->

Adds support for setting the defensive flag. See [`SQLITE_DBCONFIG_DEFENSIVE`](https://www.sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive).

This is one of the APIs needed for [Defense Against The Dark Arts](https://www.sqlite.org/security.html) when dealing with untrusted SQL queries. Another being the [`sqlite3_limit()`](https://www.sqlite.org/c3ref/limit.html) interface, which may be a neat future addition.

Changed files:
filename: doc/api/sqlite.md
patch: @@ -98,6 +98,10 @@ exposed by this class execute synchronously.
 <!-- YAML
 added: v22.5.0
 changes:
+  - version:
+      - REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/60217
+    description: Add `defensive` option.
   - version:
       - v24.4.0
       - v22.18.0
@@ -140,6 +144,10 @@ changes:
     character (e.g., `foo` instead of `:foo`). **Default:** `true`.
   * `allowUnknownNamedParameters` {boolean} If `true`, unknown named parameters are ignored when binding.
     If `false`, an exception is thrown for unknown named parameters. **Default:** `false`.
+  * `defensive` {boolean} If `true`, enables the defensive flag. When the defensive flag is enabled,
+    language features that allow ordinary SQL to deliberately corrupt the database file are disabled.
+    The defensive flag can also be set using `enableDefensive()`.
+    **Default:** `false`.
 
 Constructs a new `DatabaseSync` instance.
 
@@ -261,6 +269,19 @@ Enables or disables the `loadExtension` SQL function, and the `loadExtension()`
 method. When `allowExtension` is `false` when constructing, you cannot enable
 loading extensions for security reasons.
 
+### `database.enableDefensive(active)`
+
+<!-- YAML
+added:
+  - REPLACEME
+-->
+
+* `active` {boolean} Whether to set the defensive flag.
+
+Enables or disables the defensive flag. When the defensive flag is active,
+language features that allow ordinary SQL to deliberately corrupt the database file are disabled.
+See [`SQLITE_DBCONFIG_DEFENSIVE`][] in the SQLite documentation for details.
+
 ### `database.location([dbName])`
 
 <!-- YAML
@@ -1306,6 +1327,7 @@ callback function to indicate what type of operation is being authorized.
 [Type conversion between JavaScript and SQLite]: #type-conversion-between-javascript-and-sqlite
 [`ATTACH DATABASE`]: https://www.sqlite.org/lang_attach.html
 [`PRAGMA foreign_keys`]: https://www.sqlite.org/pragma.html#pragma_foreign_keys
+[`SQLITE_DBCONFIG_DEFENSIVE`]: https://www.sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive
 [`SQLITE_DETERMINISTIC`]: https://www.sqlite.org/c3ref/c_deterministic.html
 [`SQLITE_DIRECTONLY`]: https://www.sqlite.org/c3ref/c_deterministic.html
 [`SQLITE_MAX_FUNCTION_ARG`]: https://www.sqlite.org/limits.html#max_function_arg

filename: src/env_properties.h
patch: @@ -130,6 +130,7 @@
   V(cwd_string, "cwd")                                                         \
   V(data_string, "data")                                                       \
   V(default_is_true_string, "defaultIsTrue")                                   \
+  V(defensive_string, "defensive")                                             \
   V(deserialize_info_string, "deserializeInfo")                                \
   V(dest_string, "dest")                                                       \
   V(destroyed_string, "destroyed")                                             \

filename: src/node_sqlite.cc
patch: @@ -753,6 +753,14 @@ bool DatabaseSync::Open() {
   CHECK_ERROR_OR_THROW(env()->isolate(), this, r, SQLITE_OK, false);
   CHECK_EQ(foreign_keys_enabled, open_config_.get_enable_foreign_keys());
 
+  int defensive_enabled;
+  r = sqlite3_db_config(connection_,
+                        SQLITE_DBCONFIG_DEFENSIVE,
+                        static_cast<int>(open_config_.get_enable_defensive()),
+                        &defensive_enabled);
+  CHECK_ERROR_OR_THROW(env()->isolate(), this, r, SQLITE_OK, false);
+  CHECK_EQ(defensive_enabled, open_config_.get_enable_defensive());
+
   sqlite3_busy_timeout(connection_, open_config_.get_timeout());
 
   if (allow_load_extension_) {
@@ -1065,6 +1073,21 @@ void DatabaseSync::New(const FunctionCallbackInfo<Value>& args) {
             allow_unknown_named_params_v.As<Boolean>()->Value());
       }
     }
+
+    Local<Value> defensive_v;
+    if (!options->Get(env->context(), env->defensive_string())
+             .ToLocal(&defensive_v)) {
+      return;
+    }
+    if (!defensive_v->IsUndefined()) {
+      if (!defensive_v->IsBoolean()) {
+        THROW_ERR_INVALID_ARG_TYPE(
+            env->isolate(),
+            "The \"options.defensive\" argument must be a boolean.");
+        return;
+      }
+      open_config.set_enable_defensive(defensive_v.As<Boolean>()->Value());
+    }
   }
 
   new DatabaseSync(
@@ -1835,6 +1858,26 @@ void DatabaseSync::EnableLoadExtension(
   CHECK_ERROR_OR_THROW(isolate, db, load_extension_ret, SQLITE_OK, void());
 }
 
+void DatabaseSync::EnableDefensive(const FunctionCallbackInfo<Value>& args) {
+  DatabaseSync* db;
+  ASSIGN_OR_RETURN_UNWRAP(&db, args.This());
+  Environment* env = Environment::GetCurrent(args);
+  THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open");
+
+  auto isolate = args.GetIsolate();
+  if (!args[0]->IsBoolean()) {
+    THROW_ERR_INVALID_ARG_TYPE(isolate,
+                               "The \"active\" argument must be a boolean.");
+    return;
+  }
+
+  const int enable = args[0].As<Boolean>()->Value();
+  int defensive_enabled;
+  const int defensive_ret = sqlite3_db_config(
+      db->connection_, SQLITE_DBCONFIG_DEFENSIVE, enable, &defensive_enabled);
+  CHECK_ERROR_OR_THROW(isolate, db, defensive_ret, SQLITE_OK, void());
+}
+
 void DatabaseSync::LoadExtension(const FunctionCallbackInfo<Value>& args) {
   DatabaseSync* db;
   ASSIGN_OR_RETURN_UNWRAP(&db, args.This());
@@ -3316,6 +3359,8 @@ static void Initialize(Local<Object> target,
                  db_tmpl,
                  "enableLoadExtension",
                  DatabaseSync::EnableLoadExtension);
+  SetProtoMethod(
+      isolate, db_tmpl, "enableDefensive", DatabaseSync::EnableDefensive);
   SetProtoMethod(
       isolate, db_tmpl, "loadExtension", DatabaseSync::LoadExtension);
   SetProtoMethod(

filename: src/node_sqlite.h
patch: @@ -65,6 +65,10 @@ class DatabaseOpenConfiguration {
     return allow_unknown_named_params_;
   }
 
+  inline void set_enable_defensive(bool flag) { defensive_ = flag; }
+
+  inline bool get_enable_defensive() const { return defensive_; }
+
  private:
   std::string location_;
   bool read_only_ = false;
@@ -75,6 +79,7 @@ class DatabaseOpenConfiguration {
   bool return_arrays_ = false;
   bool allow_bare_named_params_ = true;
   bool allow_unknown_named_params_ = false;
+  bool defensive_ = false;
 };
 
 class DatabaseSync;
@@ -140,6 +145,7 @@ class DatabaseSync : public BaseObject {
   static void ApplyChangeset(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void EnableLoadExtension(
       const v8::FunctionCallbackInfo<v8::Value>& args);
+  static void EnableDefensive(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void LoadExtension(const v8::FunctionCallbackInfo<v8::Value>& args);
   static void SetAuthorizer(const v8::FunctionCallbackInfo<v8::Value>& args);
   static int AuthorizerCallback(void* user_data,

filename: test/parallel/test-sqlite-config.js
patch: @@ -0,0 +1,56 @@
+'use strict';
+const { skipIfSQLiteMissing } = require('../common/index.mjs');
+const { test } = require('node:test');
+const assert = require('node:assert');
+const { DatabaseSync } = require('node:sqlite');
+skipIfSQLiteMissing();
+
+function checkDefensiveMode(db) {
+  function journalMode() {
+    return db.prepare('PRAGMA journal_mode').get().journal_mode;
+  }
+
+  assert.strictEqual(journalMode(), 'memory');
+  db.exec('PRAGMA journal_mode=OFF');
+
+  switch (journalMode()) {
+    case 'memory': return true; // journal_mode unchanged, defensive mode must be active
+    case 'off': return false;  // journal_mode now 'off', so defensive mode not active
+    default: throw new Error('unexpected journal_mode');
+  }
+}
+
+test('by default, defensive mode is off', (t) => {
+  const db = new DatabaseSync(':memory:');
+  t.assert.strictEqual(checkDefensiveMode(db), false);
+});
+
+test('when passing { defensive: true } as config, defensive mode is on', (t) => {
+  const db = new DatabaseSync(':memory:', {
+    defensive: true
+  });
+  t.assert.strictEqual(checkDefensiveMode(db), true);
+});
+
+test('defensive mode on after calling db.enableDefensive(true)', (t) => {
+  const db = new DatabaseSync(':memory:');
+  db.enableDefensive(true);
+  t.assert.strictEqual(checkDefensiveMode(db), true);
+});
+
+test('defensive mode should be off after calling db.enableDefensive(false)', (t) => {
+  const db = new DatabaseSync(':memory:', {
+    defensive: true
+  });
+  db.enableDefensive(false);
+  t.assert.strictEqual(checkDefensiveMode(db), false);
+});
+
+test('throws if options.defensive is provided but is not a boolean', (t) => {
+  t.assert.throws(() => {
+    new DatabaseSync(':memory:', { defensive: 42 });
+  }, {
+    code: 'ERR_INVALID_ARG_TYPE',
+    message: 'The "options.defensive" argument must be a boolean.',
+  });
+});