Skip to content

[4.x] Validate DB parameters before using them in SQL statements or file paths#1459

Draft
lukinovec wants to merge 11 commits intomasterfrom
validate-sql-parameters
Draft

[4.x] Validate DB parameters before using them in SQL statements or file paths#1459
lukinovec wants to merge 11 commits intomasterfrom
validate-sql-parameters

Conversation

@lukinovec
Copy link
Copy Markdown
Contributor

@lukinovec lukinovec commented Apr 29, 2026

In statement() calls of TenantDatabaseManagers, use parameter binding when possible. If that's not possible, validate the parameters using validateParameter() or validatePassword().

Passwords use a less strict allowlist than other parameters (e.g. DB names), since passwords are always quoted in the SQL statements (and since passwords tend to use special characters).

In SQLiteDatabaseManager, names of file-based databases are validated using a similar allowlist to the (non-password) parameters in other DB managers, with an additional character: . (this addition is necessary since file-based SQLite databases end with .sqlite).

To-dos

Summary by CodeRabbit

  • Bug Fixes

    • Added input validation for database names, usernames, and passwords across all database managers.
    • Updated database queries to use parameterized statements instead of direct interpolation.
  • Tests

    • Added comprehensive test coverage for database input validation across all supported database types.

lukinovec and others added 11 commits April 29, 2026 10:08
This is just for consistency, since all the other DB managers use select().
DB manager methods validate the parameters they use in SQL statements using validateParameter() (excluding parameters passed via bindings in SELECT statements).
WIP: password validation, SQLite (check if validation is enough for valid FS paths), revisit the test
Also, use parameterAllowlist() instead of the static property (so that we can e.g. override it later in SQLiteDatabaseManager, since overriding the static property doesn't work).
Also stop skipping the validation test for sqlite.
Also, make the validateParameter method ignore null parameters, e.g. for cases when tenants are created using Tenant::make() without tenancy_db_username set -- $databaseConfig->getUsername() allows null, same should go for the validate method whose only concern is checking strings for invalid characters.
@lukinovec
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 29, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.05%. Comparing base (9849119) to head (8592949).

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1459      +/-   ##
============================================
+ Coverage     85.84%   86.05%   +0.20%     
- Complexity     1163     1172       +9     
============================================
  Files           185      186       +1     
  Lines          3399     3434      +35     
============================================
+ Hits           2918     2955      +37     
+ Misses          481      479       -2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

📝 Walkthrough

Walkthrough

The PR introduces input validation across multiple database managers by adding a new ValidatesDatabaseParameters trait that validates database names, usernames, and passwords against character allowlists. Database managers now validate these inputs before SQL execution and switch from string interpolation to parameterized queries where possible, enhancing security against SQL injection and path traversal attacks.

Changes

Cohort / File(s) Summary
Parameter Validation Trait
src/Database/Concerns/ValidatesDatabaseParameters.php
New trait providing validateParameter() and validatePassword() methods with configurable character allowlists for identifiers and passwords.
Base Manager Integration
src/Database/TenantDatabaseManagers/TenantDatabaseManager.php
Adds ValidatesDatabaseParameters trait usage to enable validation across all manager implementations.
PostgreSQL Managers
src/Database/Concerns/ManagesPostgresUsers.php, src/Database/TenantDatabaseManagers/PostgreSQL...php, src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQL...php
Validates usernames, passwords, database/schema names before execution; converts string interpolation to parameterized queries in databaseExists() and metadata lookups.
MySQL Managers
src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php, src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php
Validates database names, usernames, passwords, and charset/collation; replaces interpolated SQL with parameterized queries in databaseExists().
MicrosoftSQL Managers
src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php, src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php
Validates database names, usernames, and passwords before CREATE/DROP operations; converts string interpolation to parameterized queries.
SQLite Manager
src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php
Validates database name before file operations (file_put_contents, unlink) using configured allowlist.
Tests
tests/TenantDatabaseManagerTest.php
Parameterized test suite verifying that invalid database names, usernames, and passwords trigger InvalidArgumentException across all manager types.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A trait hops in with lists so fine,
Each SQL string now reads the line—
No more wild chars can twist the code,
Parameters bound on safest road!
Validation guards each request true,
The databases now wink at you! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.12% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main objective of the pull request: adding validation of database parameters before they are used in SQL statements or file paths.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch validate-sql-parameters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Concerns/ValidatesDatabaseParameters.php`:
- Around line 61-63: The loop inside validateParameter currently calls
str_split($parameter) without guarding against null array elements, causing a
TypeError when callers pass arrays containing null (e.g., results of
getUsername()/getPassword()); update validateParameter to skip any null (or
non-string) elements before calling str_split—check each $parameter with
is_string($parameter) (or continue when $parameter === null) so only strings are
iterated, and keep existing handling for top-level nulls intact.

In `@src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php`:
- Around line 13-23: The code assigns the union-typed return of
validateParameter() to $database and then interpolates it into SQL, causing
PHPStan type errors; instead, call validateParameter(...) for
side-effect/validation but keep $database as the original string from the tenant
and use that when calling connection()->statement in createDatabase and
deleteDatabase (i.e., do not reassign $database to the validateParameter()
result—validate in-place and use $tenant->database()->getName() stored as the
string $database before interpolation). Ensure you update both createDatabase
and deleteDatabase (and any other methods using validateParameter() assignment)
so connection()->statement("CREATE DATABASE [{$database}]") / statement("DROP
DATABASE [{$database}]") interpolate a known string variable.

In
`@src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php`:
- Around line 39-45: The primary-key lookup query in
PermissionControlledPostgreSQLSchemaManager is missing a schema filter and can
collide across schemas; update the SELECT against
information_schema.key_column_usage to include "table_schema = ?" and bind the
existing $schema variable alongside $tableName so the query uses both parameters
when obtaining $primaryKey (keep the query against
information_schema.key_column_usage and the use of
$this->connection()->selectOne intact).

In `@src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php`:
- Around line 95-96: databaseExists currently calls getPath($name) with raw
input, leaving path traversal/existence probing unvalidated; add a call to
$this->validateParameter($name) at the start of databaseExists() so the name is
validated before passing to getPath(), and similarly ensure any other methods
that call getPath() (e.g., createDatabase(), deleteDatabase()) perform
validateParameter() first or reuse a validated helper; update databaseExists()
to return after validation and then compute $path = $this->getPath($name).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c3c813de-1e9a-4645-a9a9-dea7a7490e2e

📥 Commits

Reviewing files that changed from the base of the PR and between 9849119 and 8592949.

📒 Files selected for processing (13)
  • src/Database/Concerns/ManagesPostgresUsers.php
  • src/Database/Concerns/ValidatesDatabaseParameters.php
  • src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php
  • src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php
  • src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php
  • src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php
  • src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php
  • src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php
  • src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php
  • src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php
  • src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php
  • src/Database/TenantDatabaseManagers/TenantDatabaseManager.php
  • tests/TenantDatabaseManagerTest.php

Comment on lines +61 to +63
foreach ((array) $parameters as $parameter) {
foreach (str_split($parameter) as $char) {
if (! str_contains($allowlist, $char)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify call sites that pass arrays to validateParameter and may include nullable values.
# Expected: identify array-based calls using getUsername()/getConfig() etc., which can be null.
set -euo pipefail

echo "Array-based validateParameter call sites:"
rg -nP 'validateParameter\(\s*\[' src

echo
echo "Potential nullable sources used in DB manager parameters:"
rg -nP 'getUsername\(\)|getPassword\(\)|getConfig\(' src/Database/TenantDatabaseManagers src/Database/Concerns

Repository: archtechx/tenancy

Length of output: 3645


🏁 Script executed:

# View the full ValidatesDatabaseParameters.php file to understand the method structure
fd -name "ValidatesDatabaseParameters.php" src

Repository: archtechx/tenancy

Length of output: 292


🏁 Script executed:

# Get the actual content of the file
cat src/Database/Concerns/ValidatesDatabaseParameters.php

Repository: archtechx/tenancy

Length of output: 2963


🏁 Script executed:

# Check return types of getUsername(), getPassword(), getConfig()
rg -A 5 "function (getUsername|getPassword|getConfig)" src/Database --type php

Repository: archtechx/tenancy

Length of output: 649


validateParameter() throws TypeError when array elements are null.

While top-level null is correctly handled on line 51-56, null values inside an input array are not checked before reaching str_split() on line 62. Since getUsername() and getPassword() both return ?string, calls like validateParameter([$database, $username]) will crash with a TypeError when those methods return null.

🐛 Proposed fix
         foreach ((array) $parameters as $parameter) {
+            if (is_null($parameter)) {
+                continue;
+            }
+
             foreach (str_split($parameter) as $char) {
                 if (! str_contains($allowlist, $char)) {
                     throw new InvalidArgumentException("Invalid character '{$char}' in parameter: {$parameter}");
                 }
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
foreach ((array) $parameters as $parameter) {
foreach (str_split($parameter) as $char) {
if (! str_contains($allowlist, $char)) {
foreach ((array) $parameters as $parameter) {
if (is_null($parameter)) {
continue;
}
foreach (str_split($parameter) as $char) {
if (! str_contains($allowlist, $char)) {
throw new InvalidArgumentException("Invalid character '{$char}' in parameter: {$parameter}");
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Concerns/ValidatesDatabaseParameters.php` around lines 61 - 63,
The loop inside validateParameter currently calls str_split($parameter) without
guarding against null array elements, causing a TypeError when callers pass
arrays containing null (e.g., results of getUsername()/getPassword()); update
validateParameter to skip any null (or non-string) elements before calling
str_split—check each $parameter with is_string($parameter) (or continue when
$parameter === null) so only strings are iterated, and keep existing handling
for top-level nulls intact.

Comment on lines +13 to 23
$database = $this->validateParameter($tenant->database()->getName());

return $this->connection()->statement("CREATE DATABASE [{$database}]");
}

public function deleteDatabase(TenantWithDatabase $tenant): bool
{
return $this->connection()->statement("DROP DATABASE [{$tenant->database()->getName()}]");
$database = $this->validateParameter($tenant->database()->getName());

return $this->connection()->statement("DROP DATABASE [{$database}]");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t assign validateParameter() return value to interpolated SQL identifier vars.

Line 13 and Line 20 capture a union-typed return (array|string|null), then Line 15 and Line 22 interpolate it as string. PHPStan already flags this. Validate in-place, but keep $database as the original string variable.

Suggested fix
 public function createDatabase(TenantWithDatabase $tenant): bool
 {
-    $database = $this->validateParameter($tenant->database()->getName());
+    $database = $tenant->database()->getName();
+    $this->validateParameter($database);

     return $this->connection()->statement("CREATE DATABASE [{$database}]");
 }

 public function deleteDatabase(TenantWithDatabase $tenant): bool
 {
-    $database = $this->validateParameter($tenant->database()->getName());
+    $database = $tenant->database()->getName();
+    $this->validateParameter($database);

     return $this->connection()->statement("DROP DATABASE [{$database}]");
 }

Use this to verify all similar occurrences:

#!/bin/bash
set -euo pipefail

# 1) Find assignments from validateParameter()
rg -nP '\$\w+\s*=\s*\$this->validateParameter\(' src/Database -C2

# 2) Find interpolated statement() SQL strings
rg -nP '->statement\(".*\{\$\w+\}.*"\)' src/Database -C1
🧰 Tools
🪛 GitHub Check: Static analysis (PHPStan)

[failure] 22-22:
Part $database (array|string|null) of encapsed string cannot be cast to string.


[failure] 15-15:
Part $database (array|string|null) of encapsed string cannot be cast to string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php` around
lines 13 - 23, The code assigns the union-typed return of validateParameter() to
$database and then interpolates it into SQL, causing PHPStan type errors;
instead, call validateParameter(...) for side-effect/validation but keep
$database as the original string from the tenant and use that when calling
connection()->statement in createDatabase and deleteDatabase (i.e., do not
reassign $database to the validateParameter() result—validate in-place and use
$tenant->database()->getName() stored as the string $database before
interpolation). Ensure you update both createDatabase and deleteDatabase (and
any other methods using validateParameter() assignment) so
connection()->statement("CREATE DATABASE [{$database}]") / statement("DROP
DATABASE [{$database}]") interpolate a known string variable.

Comment on lines +39 to 45
$primaryKey = $this->connection()->selectOne(<<<'SQL'
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_name = '{$tableName}'
WHERE table_name = ?
AND constraint_name LIKE '%_pkey'
SQL)->column_name;
SQL, [$tableName])->column_name;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the PK metadata query and confirm schema predicate presence.
fd PermissionControlledPostgreSQLSchemaManager.php --exec sed -n '34,48p' {}

Repository: archtechx/tenancy

Length of output: 726


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the full method containing the code snippet to confirm $schema is in scope
fd PermissionControlledPostgreSQLSchemaManager.php --exec sed -n '25,50p' {}

Repository: archtechx/tenancy

Length of output: 1459


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check for other information_schema queries in this file that might have similar issues
rg "information_schema" src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php -A 5 -B 2

Repository: archtechx/tenancy

Length of output: 855


Primary-key lookup must include schema to avoid cross-schema collisions.

Line 42 filters only by table_name. In a multi-schema PostgreSQL environment, the information_schema.key_column_usage query can return the wrong primary key metadata if another schema contains a table with the same name. The method already correctly filters by table_schema when retrieving table names (line 31), but the primary key lookup is missing this critical filter. Add table_schema = ? and bind $schema.

Suggested fix
 $primaryKey = $this->connection()->selectOne(<<<'SQL'
     SELECT column_name
     FROM information_schema.key_column_usage
-    WHERE table_name = ?
+    WHERE table_schema = ?
+    AND table_name = ?
     AND constraint_name LIKE '%_pkey'
-SQL, [$tableName])->column_name;
+SQL, [$schema, $tableName])->column_name;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php`
around lines 39 - 45, The primary-key lookup query in
PermissionControlledPostgreSQLSchemaManager is missing a schema filter and can
collide across schemas; update the SELECT against
information_schema.key_column_usage to include "table_schema = ?" and bind the
existing $schema variable alongside $tableName so the query uses both parameters
when obtaining $primaryKey (keep the query against
information_schema.key_column_usage and the use of
$this->connection()->selectOne intact).

Comment on lines +95 to +96
$this->validateParameter($name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validation is incomplete: databaseExists() still uses unvalidated file-path input.

Lines 95 and 112 validate create/delete inputs, but Line 131 still passes raw $name to getPath(). That leaves path traversal-style existence probing unguarded. Apply the same validation in databaseExists().

Suggested fix
 public function databaseExists(string $name): bool
 {
-    return $this->isInMemory($name) || file_exists($this->getPath($name));
+    if ($this->isInMemory($name)) {
+        return true;
+    }
+
+    $this->validateParameter($name);
+
+    return file_exists($this->getPath($name));
 }

Also applies to: 112-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php` around lines
95 - 96, databaseExists currently calls getPath($name) with raw input, leaving
path traversal/existence probing unvalidated; add a call to
$this->validateParameter($name) at the start of databaseExists() so the name is
validated before passing to getPath(), and similarly ensure any other methods
that call getPath() (e.g., createDatabase(), deleteDatabase()) perform
validateParameter() first or reuse a validated helper; update databaseExists()
to return after validation and then compute $path = $this->getPath($name).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant