From 2a870c9f932902a4795cb16555006786005b38e0 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 5 Nov 2026 21:48:13 +0100 Subject: [PATCH 1/2] fix(Database): escape table identifiers in SQLite3 foreign_key_list PRAGMA --- docs/changelogs/v4.7.7.md | 1 + system/Database/SQLite3/Connection.php | 8 +- .../Live/SQLite3/GetForeignKeyDataTest.php | 86 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php diff --git a/docs/changelogs/v4.7.7.md b/docs/changelogs/v4.7.7.md index cdf4eb2db148..e1e95515672f 100644 --- a/docs/changelogs/v4.7.7.md +++ b/docs/changelogs/v4.7.7.md @@ -14,6 +14,7 @@ Release Date: Unreleased ## Bugs Fixed +- **Database:** Fixed a bug where `SQLite3\Connection::_foreignKeyData()` did not escape table identifiers in `PRAGMA foreign_key_list`, causing syntax errors on table names containing hyphens or schemas. - **Security:** Fixed a bug where `Security::__construct()` threw a `TypeError` when the CSRF cookie was an array, causing an HTTP 500 response for affected requests. - **Worker Mode:** Fixed a bug where every request that ended with an uncaught exception, including a `404 Page Not Found` without a 404 override, called `exit()` in the exception handler. This ended the worker script, so FrankenPHP booted a new one, and the worker loop skipped its cleanup after the request. The exception handler now returns to the worker loop. diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 582ab2b4b2b1..ab09dd82e58e 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -363,7 +363,13 @@ protected function _foreignKeyData(string $table): array return []; } - $query = $this->query("PRAGMA foreign_key_list({$table})")->getResult(); + $parts = explode('.', $table); + $tableName = array_pop($parts); + $schema = array_pop($parts); + $pragma = ($schema === null ? '' : $this->escapeIdentifiers($schema) . '.') + . 'foreign_key_list(' . $this->escapeIdentifiers($tableName) . ')'; + + $query = $this->query('PRAGMA ' . $pragma)->getResult(); $indexes = []; foreach ($query as $row) { diff --git a/tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php b/tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php new file mode 100644 index 000000000000..ffbd64a37f76 --- /dev/null +++ b/tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live\SQLite3; + +use CodeIgniter\Test\CIUnitTestCase; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class GetForeignKeyDataTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $this->db = Database::connect($this->DBGroup); + if ($this->db->DBDriver !== 'SQLite3') { + $this->markTestSkipped('This test is only for SQLite3.'); + } + + $config = [ + 'DBDriver' => 'SQLite3', + 'database' => 'database.db', + 'DBDebug' => true, + ]; + $this->db = db_connect($config, false); + } + + public function testGetForeignKeyDataWithSpecialTableName(): void + { + $this->db->query('DROP TABLE IF EXISTS `test-parent`'); + $this->db->query('DROP TABLE IF EXISTS `test-child`'); + + $this->db->query('CREATE TABLE `test-parent` (id INTEGER PRIMARY KEY)'); + $this->db->query('CREATE TABLE `test-child` (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES `test-parent`(id))'); + + $foreignKeys = $this->db->getForeignKeyData('test-child'); + + $this->assertCount(1, $foreignKeys); + $firstKey = reset($foreignKeys); + $this->assertSame('test-child', $firstKey->table_name); + $this->assertSame(['parent_id'], $firstKey->column_name); + $this->assertSame('test-parent', $firstKey->foreign_table_name); + $this->assertSame(['id'], $firstKey->foreign_column_name); + + $this->db->query('DROP TABLE IF EXISTS `test-child`'); + $this->db->query('DROP TABLE IF EXISTS `test-parent`'); + } + + public function testGetForeignKeyDataWithAttachedDatabase(): void + { + $this->db->query('ATTACH DATABASE ":memory:" AS `test_schema`'); + $this->db->query('DROP TABLE IF EXISTS `test_schema`.`attached-parent`'); + $this->db->query('DROP TABLE IF EXISTS `test_schema`.`attached-child`'); + + $this->db->query('CREATE TABLE `test_schema`.`attached-parent` (id INTEGER PRIMARY KEY)'); + $this->db->query('CREATE TABLE `test_schema`.`attached-child` (id INTEGER PRIMARY KEY, p_id INTEGER, FOREIGN KEY (p_id) REFERENCES `attached-parent`(id))'); + + $foreignKeys = $this->db->getForeignKeyData('test_schema.attached-child'); + + $this->assertCount(1, $foreignKeys); + $firstKey = reset($foreignKeys); + $this->assertSame('test_schema.attached-child', $firstKey->table_name); + $this->assertSame(['p_id'], $firstKey->column_name); + $this->assertSame('attached-parent', $firstKey->foreign_table_name); + $this->assertSame(['id'], $firstKey->foreign_column_name); + + $this->db->query('DROP TABLE IF EXISTS `test_schema`.`attached-child`'); + $this->db->query('DROP TABLE IF EXISTS `test_schema`.`attached-parent`'); + $this->db->query('DETACH DATABASE `test_schema`'); + } +} From 53fe70b17d328b4fb31acb0bff0f7a5cf58c3975 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 6 Nov 2026 20:57:10 +0100 Subject: [PATCH 2/2] fix: properly escape table name and schema in SQLite3 getForeignKeyData --- system/Database/SQLite3/Connection.php | 4 +- .../Live/SQLite3/GetForeignKeyDataTest.php | 86 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index ab09dd82e58e..d134f6eec8dd 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -366,8 +366,8 @@ protected function _foreignKeyData(string $table): array $parts = explode('.', $table); $tableName = array_pop($parts); $schema = array_pop($parts); - $pragma = ($schema === null ? '' : $this->escapeIdentifiers($schema) . '.') - . 'foreign_key_list(' . $this->escapeIdentifiers($tableName) . ')'; + $pragma = ($schema === null || $schema === '' ? '' : $this->escapeIdentifier($schema) . '.') + . 'foreign_key_list(' . $this->escape($tableName) . ')'; $query = $this->query('PRAGMA ' . $pragma)->getResult(); $indexes = []; diff --git a/tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php b/tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php index ffbd64a37f76..f426931d547d 100644 --- a/tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php +++ b/tests/system/Database/Live/SQLite3/GetForeignKeyDataTest.php @@ -83,4 +83,90 @@ public function testGetForeignKeyDataWithAttachedDatabase(): void $this->db->query('DROP TABLE IF EXISTS `test_schema`.`attached-parent`'); $this->db->query('DETACH DATABASE `test_schema`'); } + + public function testGetForeignKeyDataWithParenthesesInTableName(): void + { + $this->db->query('DROP TABLE IF EXISTS "parent(v1)"'); + $this->db->query('DROP TABLE IF EXISTS "child(v2)"'); + + $this->db->query('CREATE TABLE "parent(v1)" (id INTEGER PRIMARY KEY)'); + $this->db->query('CREATE TABLE "child(v2)" (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES "parent(v1)"(id))'); + + $foreignKeys = $this->db->getForeignKeyData('child(v2)'); + + $this->assertCount(1, $foreignKeys); + $firstKey = reset($foreignKeys); + $this->assertSame('child(v2)', $firstKey->table_name); + $this->assertSame(['parent_id'], $firstKey->column_name); + $this->assertSame('parent(v1)', $firstKey->foreign_table_name); + $this->assertSame(['id'], $firstKey->foreign_column_name); + + $this->db->query('DROP TABLE IF EXISTS "child(v2)"'); + $this->db->query('DROP TABLE IF EXISTS "parent(v1)"'); + } + + public function testGetForeignKeyDataWithQuotesAndSpacesInTableName(): void + { + $this->db->query('DROP TABLE IF EXISTS "parent table"'); + $this->db->query('DROP TABLE IF EXISTS "child\'quote with space"'); + + $this->db->query('CREATE TABLE "parent table" (id INTEGER PRIMARY KEY)'); + $this->db->query('CREATE TABLE "child\'quote with space" (id INTEGER PRIMARY KEY, p_id INTEGER, FOREIGN KEY (p_id) REFERENCES "parent table"(id))'); + + $foreignKeys = $this->db->getForeignKeyData("child'quote with space"); + + $this->assertCount(1, $foreignKeys); + $firstKey = reset($foreignKeys); + $this->assertSame("child'quote with space", $firstKey->table_name); + $this->assertSame(['p_id'], $firstKey->column_name); + $this->assertSame('parent table', $firstKey->foreign_table_name); + $this->assertSame(['id'], $firstKey->foreign_column_name); + + $this->db->query('DROP TABLE IF EXISTS "child\'quote with space"'); + $this->db->query('DROP TABLE IF EXISTS "parent table"'); + } + + public function testGetForeignKeyDataWithNumericAndReservedWordTableName(): void + { + $this->db->query('DROP TABLE IF EXISTS "order"'); + $this->db->query('DROP TABLE IF EXISTS "12345"'); + + $this->db->query('CREATE TABLE "order" (id INTEGER PRIMARY KEY)'); + $this->db->query('CREATE TABLE "12345" (id INTEGER PRIMARY KEY, order_id INTEGER, FOREIGN KEY (order_id) REFERENCES "order"(id))'); + + $foreignKeys = $this->db->getForeignKeyData('12345'); + + $this->assertCount(1, $foreignKeys); + $firstKey = reset($foreignKeys); + $this->assertSame('12345', $firstKey->table_name); + $this->assertSame(['order_id'], $firstKey->column_name); + $this->assertSame('order', $firstKey->foreign_table_name); + $this->assertSame(['id'], $firstKey->foreign_column_name); + + $this->db->query('DROP TABLE IF EXISTS "12345"'); + $this->db->query('DROP TABLE IF EXISTS "order"'); + } + + public function testGetForeignKeyDataWithAttachedDatabaseSpecialIdentifiers(): void + { + $this->db->query('ATTACH DATABASE ":memory:" AS "schema-db(1)"'); + $this->db->query('DROP TABLE IF EXISTS "schema-db(1)"."parent(v1)"'); + $this->db->query('DROP TABLE IF EXISTS "schema-db(1)"."child(v2)"'); + + $this->db->query('CREATE TABLE "schema-db(1)"."parent(v1)" (id INTEGER PRIMARY KEY)'); + $this->db->query('CREATE TABLE "schema-db(1)"."child(v2)" (id INTEGER PRIMARY KEY, p_id INTEGER, FOREIGN KEY (p_id) REFERENCES "parent(v1)"(id))'); + + $foreignKeys = $this->db->getForeignKeyData('schema-db(1).child(v2)'); + + $this->assertCount(1, $foreignKeys); + $firstKey = reset($foreignKeys); + $this->assertSame('schema-db(1).child(v2)', $firstKey->table_name); + $this->assertSame(['p_id'], $firstKey->column_name); + $this->assertSame('parent(v1)', $firstKey->foreign_table_name); + $this->assertSame(['id'], $firstKey->foreign_column_name); + + $this->db->query('DROP TABLE IF EXISTS "schema-db(1)"."child(v2)"'); + $this->db->query('DROP TABLE IF EXISTS "schema-db(1)"."parent(v1)"'); + $this->db->query('DETACH DATABASE "schema-db(1)"'); + } }