diff --git a/.github/workflows/extensions.yml b/.github/workflows/extensions.yml new file mode 100644 index 00000000..b458eacd --- /dev/null +++ b/.github/workflows/extensions.yml @@ -0,0 +1,51 @@ +name: Build extensions + +on: + push: + branches: + - '*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + max-parallel: 1 + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Change working directory to extensions/ + run: cd extensions + + - name: Download SQLite headers (Unix) + if: runner.os != 'Windows' + run: make headers + + - name: Download SQLite headers (Windows) + if: runner.os == 'Windows' + run: | + curl.exe -L https://www.sqlite.org/2024/sqlite-amalgamation-3470200.zip -o sqlite-src.zip + Expand-Archive -Path sqlite-src.zip -DestinationPath . + Copy-Item sqlite-amalgamation-3470200\sqlite3.h . + Copy-Item sqlite-amalgamation-3470200\sqlite3ext.h . + + - name: Build C files + run: | + make + + - name: Commit output files + if: matrix.os == 'ubuntu-latest' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add lib/ + git commit -m "Auto-build: Update output files" || echo "No changes to commit" + git push diff --git a/assets/config.php b/assets/config.php index a16a4201..3a521a6c 100644 --- a/assets/config.php +++ b/assets/config.php @@ -389,6 +389,7 @@ return [ // Stancl\Tenancy\Features\TenantConfig::class, // Stancl\Tenancy\Features\CrossDomainRedirect::class, // Stancl\Tenancy\Features\ViteBundler::class, + // Stancl\Tenancy\Features\DisallowSqliteAttach::class, ], /** diff --git a/composer.json b/composer.json index 905ffba3..d091c4a2 100644 --- a/composer.json +++ b/composer.json @@ -70,7 +70,7 @@ "docker-rebuild": "PHP_VERSION=8.3 docker compose up -d --no-deps --build", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", - "testbench-link": "ln -s vendor ./vendor/orchestra/testbench-core/laravel/vendor", + "testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan --memory-limit=256M", diff --git a/extensions/.gitignore b/extensions/.gitignore new file mode 100644 index 00000000..d2ff3048 --- /dev/null +++ b/extensions/.gitignore @@ -0,0 +1,3 @@ +*.h +*.zip +sqlite-amalgamation-3470200/ diff --git a/extensions/Makefile b/extensions/Makefile new file mode 100644 index 00000000..b84af00b --- /dev/null +++ b/extensions/Makefile @@ -0,0 +1,34 @@ +.PHONY: all headers + +ifeq ($(OS),Windows_NT) + OUTPUT = lib/noattach.dll + CCFLAGS = -shared +else + UNAME := $(shell uname) + CCFLAGS = -fPIC -Os -shared + ifeq ($(UNAME),Darwin) + ifeq ($(shell uname -m),arm64) + OUTPUT = lib/arm/noattach.dylib + else + OUTPUT = lib/noattach.dylib + endif + else + ifeq ($(shell uname -m),aarch64) + OUTPUT = lib/arm/noattach.so + else + OUTPUT = lib/noattach.so + endif + endif +endif + +all: $(OUTPUT) + +headers: + # To simplify compilation across platforms, we include sqlite3ext.h in this directory. + curl -L https://www.sqlite.org/2024/sqlite-amalgamation-3470200.zip -o sqlite-src.zip + unzip sqlite-src.zip + cp sqlite-amalgamation-3470200/*.h . + +$(OUTPUT): noattach.c + # We don't link against libsqlite3 since PHP statically links its own libsqlite3. + $(CC) $(CCFLAGS) -o $@ $< diff --git a/extensions/lib/.gitkeep b/extensions/lib/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/extensions/lib/arm/.gitkeep b/extensions/lib/arm/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/extensions/noattach.c b/extensions/noattach.c new file mode 100644 index 00000000..a66df12e --- /dev/null +++ b/extensions/noattach.c @@ -0,0 +1,22 @@ +#include "sqlite3ext.h" +SQLITE_EXTENSION_INIT1 + +static int deny_attach_authorizer(void *user_data, int action_code, const char *param1, const char *param2, const char *dbname, const char *trigger) { + return action_code == SQLITE_ATTACH // 24 + ? SQLITE_DENY // 1 + : SQLITE_OK; // 0 +} + +#ifdef _WIN32 +__declspec(dllexport) +#endif +int sqlite3_noattach_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) { + SQLITE_EXTENSION_INIT2(pApi); + + int rc = sqlite3_set_authorizer(db, deny_attach_authorizer, 0); + if (rc != SQLITE_OK) { + *pzErrMsg = sqlite3_mprintf("Tenancy: Failed to set authorizer"); + } + + return rc; +} diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php new file mode 100644 index 00000000..c3acb066 --- /dev/null +++ b/src/Features/DisallowSqliteAttach.php @@ -0,0 +1,63 @@ +loadExtension($connection->getPdo())) return; + } + } + + // Apply the change to all sqlite connections resolved in the future + DB::extend('sqlite', function ($config, $name) { + $conn = app(ConnectionFactory::class)->make($config, $name); + $this->loadExtension($conn->getPdo()); + + return $conn; + }); + } + + protected function loadExtension(PDO $pdo): bool + { + if (static::$loadExtensionSupported === null) { + static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); + } + + if (static::$loadExtensionSupported === false) return false; + + $suffix = match (PHP_OS_FAMILY) { + 'Linux' => '.so', + 'Windows' => '.dll', + 'Darwin' => '.dylib', + default => throw new Exception("The DisallowSqliteAttach feature doesn't support your operating system: " . PHP_OS_FAMILY), + }; + + $arch = php_uname('m'); + $arm = $arch === 'aarch64' || $arch === 'arm64'; + + static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/src/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach' . $suffix)); + if (static::$extensionPath === false) return false; + + $pdo->loadExtension(static::$extensionPath); + + return true; + } +} diff --git a/tests/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php new file mode 100644 index 00000000..4ebfb39a --- /dev/null +++ b/tests/Features/NoAttachTest.php @@ -0,0 +1,100 @@ + [DisallowSqliteAttach::class]]); + + config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); + + Event::listen(TenantCreated::class, JobPipeline::make([ + CreateDatabase::class, + MigrateDatabase::class, + ])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $tempdb1 = tempnam(sys_get_temp_dir(), 'tenancy_attach_test'); + $tempdb2 = tempnam(sys_get_temp_dir(), 'tenancy_attach_test'); + register_shutdown_function(fn () => @unlink($tempdb1)); + register_shutdown_function(fn () => @unlink($tempdb2)); + + config(['database.connections.foo' => ['driver' => 'sqlite', 'database' => $tempdb1]]); + config(['database.connections.bar' => ['driver' => 'sqlite', 'database' => $tempdb2]]); + + DB::connection('bar')->statement('CREATE TABLE secrets (key, value)'); + DB::connection('bar')->statement('INSERT INTO secrets (key, value) VALUES ("secret_foo", "secret_bar")'); + + Route::post('/central-sqli', function () { + DB::connection('foo')->select(request('q1')); + return json_encode(DB::connection('foo')->select(request('q2'))); + }); + + Route::middleware(InitializeTenancyByPath::class)->post('/{tenant}/tenant-sqli', function () { + DB::select(request('q1')); + return json_encode(DB::select(request('q2'))); + }); + + tenancy(); // trigger features: todo@samuel remove after feature refactor + + if ($disallow) { + expect(fn () => pest()->post('/central-sqli', [ + 'q1' => 'ATTACH DATABASE "' . $tempdb2 . '" as bar', + 'q2' => 'SELECT * from bar.secrets', + ])->json())->toThrow(QueryException::class, 'not authorized'); + } else { + expect(pest()->post('/central-sqli', [ + 'q1' => 'ATTACH DATABASE "' . $tempdb2 . '" as bar', + 'q2' => 'SELECT * from bar.secrets', + ])->json()[0])->toBe([ + 'key' => 'secret_foo', + 'value' => 'secret_bar', + ]); + } + + $tenant = Tenant::create([ + 'tenancy_db_connection' => 'sqlite', + ]); + + if ($disallow) { + expect(fn () => pest()->post($tenant->id . '/tenant-sqli', [ + 'q1' => 'ATTACH DATABASE "' . $tempdb2 . '" as baz', + 'q2' => 'SELECT * from bar.secrets', + ])->json())->toThrow(QueryException::class); + } else { + expect(pest()->post($tenant->id . '/tenant-sqli', [ + 'q1' => 'ATTACH DATABASE "' . $tempdb2 . '" as baz', + 'q2' => 'SELECT * from baz.secrets', + ])->json()[0])->toBe([ + 'key' => 'secret_foo', + 'value' => 'secret_bar', + ]); + } +})->with([true, false]);