From aa2854b04c1f471a25085d12d822a28ad73ccae6 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 11 Aug 2026 14:24:25 -0400 Subject: [PATCH] Reject database filenames and VFS names containing NUL `Database.new` silently truncated a filename containing an embedded NUL at the NUL, then opened the truncated path with the default `READWRITE | CREATE` flags. A path that passed application-level checks in full could therefore open, or create, a different file than the application validated. The `zvfs` argument and UTF-16 filenames were truncated the same way. Ruby stdlib path APIs raise `ArgumentError` on embedded NUL; this extension did not. `Database.new` will now raise `ArgumentError` when the filename or VFS name contains an embedded NUL byte, or when a UTF-16 filename contains an embedded 0x0000 code unit, and will not open or create any file. --- ext/sqlite3/database.c | 18 ++++++++++++++-- test/test_database.rb | 48 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/ext/sqlite3/database.c b/ext/sqlite3/database.c index 91550db0..85a6d318 100644 --- a/ext/sqlite3/database.c +++ b/ext/sqlite3/database.c @@ -167,7 +167,21 @@ allocate(VALUE klass) static char * utf16_string_value_ptr(VALUE str) { + VALUE utf16str, codepoints; + StringValue(str); + + utf16str = rb_str_dup(str); + if (!UTF16_LE_P(utf16str) && !UTF16_BE_P(utf16str)) { + /* an untagged string (the utf16 option) holds native-byte-order UTF-16 */ + const union { uint16_t u16; uint8_t u8[2]; } native = { 1 }; + rb_enc_associate_index(utf16str, rb_enc_find_index(native.u8[0] ? "UTF-16LE" : "UTF-16BE")); + } + codepoints = rb_funcall(utf16str, rb_intern("codepoints"), 0); + if (RTEST(rb_funcall(codepoints, rb_intern("include?"), 1, INT2FIX(0)))) { + rb_raise(rb_eArgError, "string contains null char"); + } + rb_str_buf_cat(str, "\x00\x00", 2L); return RSTRING_PTR(str); } @@ -200,10 +214,10 @@ rb_sqlite3_open_v2(VALUE self, VALUE file, VALUE mode, VALUE zvfs) flags = NUM2INT(mode); status = sqlite3_open_v2( - StringValuePtr(file), + StringValueCStr(file), &ctx->db, flags, - NIL_P(zvfs) ? NULL : StringValuePtr(zvfs) + NIL_P(zvfs) ? NULL : StringValueCStr(zvfs) ); if (status != SQLITE_OK) { diff --git a/test/test_database.rb b/test/test_database.rb index 718fce8b..79feea3f 100644 --- a/test/test_database.rb +++ b/test/test_database.rb @@ -21,6 +21,10 @@ def teardown @db.close unless @db.closed? end + def native_utf16_encoding + ([1].pack("I") == [1].pack("N")) ? Encoding::UTF_16BE : Encoding::UTF_16LE + end + def mock_database_load_extension_internal(db) class << db attr_reader :load_extension_internal_path @@ -269,15 +273,51 @@ def test_open_yields_self end def test_new_with_options - # determine if Ruby is running on Big Endian platform - utf16 = ([1].pack("I") == [1].pack("N")) ? "UTF-16BE" : "UTF-16LE" - - db = SQLite3::Database.new(":memory:".encode(utf16), utf16: true) + db = SQLite3::Database.new(":memory:".encode(native_utf16_encoding), utf16: true) assert_instance_of(SQLite3::Database, db) ensure db&.close end + def test_new_with_filename_containing_null_byte_raises_and_creates_no_file + Dir.mktmpdir do |dir| + assert_raises(ArgumentError) { SQLite3::Database.new("#{dir}/prefix\0suffix.db") } + assert_empty Dir.children(dir) + end + end + + def test_new_with_utf16_filename_containing_null_char_raises_and_creates_no_file + Dir.mktmpdir do |dir| + assert_raises(ArgumentError) { SQLite3::Database.new("#{dir}/prefix\0suffix.db".encode(native_utf16_encoding)) } + assert_empty Dir.children(dir) + end + end + + def test_new_with_untagged_utf16_filename_and_utf16_option + Dir.mktmpdir do |dir| + untagged = "#{dir}/test.db".encode(native_utf16_encoding).force_encoding(Encoding::BINARY) + db = SQLite3::Database.new(untagged, utf16: true) + assert_path_exists("#{dir}/test.db") + ensure + db&.close + end + end + + def test_new_with_untagged_utf16_filename_containing_null_char_raises_and_creates_no_file + Dir.mktmpdir do |dir| + untagged = "#{dir}/prefix\0suffix.db".encode(native_utf16_encoding).force_encoding(Encoding::BINARY) + assert_raises(ArgumentError) { SQLite3::Database.new(untagged, utf16: true) } + assert_empty Dir.children(dir) + end + end + + def test_new_with_vfs_name_containing_null_byte_raises + Dir.mktmpdir do |dir| + assert_raises(ArgumentError) { SQLite3::Database.new("#{dir}/test.db", {}, "prefix\0suffix") } + assert_empty Dir.children(dir) + end + end + def test_close db = SQLite3::Database.new(":memory:") db.close