diff --git a/.github/workflows/extensions.yml b/.github/workflows/extensions.yml new file mode 100644 index 00000000..9cc1eaf2 --- /dev/null +++ b/.github/workflows/extensions.yml @@ -0,0 +1,102 @@ +name: Build extensions + +on: + push: + paths: + - 'extensions/**' + - '.github/workflows/extensions.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + arch: x64 + - os: ubuntu-latest + arch: ARM64 + - os: windows-latest + arch: x64 + - os: macos-latest + arch: x64 + - os: macos-latest + arch: ARM64 + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Download SQLite headers (Unix) + if: runner.os != 'Windows' + run: cd extensions && make headers + + - name: Download SQLite headers (Windows) + if: runner.os == 'Windows' + run: | + cd extensions + 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: Set up QEMU (Linux cross-compilation) + if: runner.os == 'Linux' && matrix.arch == 'ARM64' + uses: docker/setup-qemu-action@v3 + + - name: Build C files (Native Windows) + if: runner.os == 'Windows' + run: cd extensions && make -B + + - name: Build C files (Native Linux) + if: runner.os == 'Linux' && matrix.arch == 'x64' + run: cd extensions && make -B + + - name: Build C files (Linux cross-compilation) + if: runner.os == 'Linux' && matrix.arch == 'ARM64' + run: | + cd extensions + docker run --platform linux/arm64 \ + -v .:/extensions \ + debian:bookworm-slim \ + bash -c "apt-get update && apt-get install -y make gcc && cd /extensions && make" + + - name: Build C files (Native macOS ARM64) + if: matrix.os == 'macos-latest' && matrix.arch == 'ARM64' + run: cd extensions && make -B + + - name: Build C files (macOS cross-compilation) + if: matrix.os == 'macos-latest' && matrix.arch == 'x64' + run: | + cd extensions + brew install llvm + export CC=/opt/homebrew/opt/llvm/bin/clang + export CFLAGS="-target x86_64-apple-darwin" + export LDFLAGS="-target x86_64-apple-darwin" + make -B ARCH=x86_64 + + - name: Commit output files + shell: bash + run: | + cd extensions + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add lib/*.{so,dylib,dll} lib/arm/*.{so,dylib} + git commit -m "Auto-build: Update extensions [skip ci]" || echo "No changes to commit" + + - name: Push files + shell: bash + run: | + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + for attempt in {1..3}; do + git pull --rebase origin $CURRENT_BRANCH && git push origin $CURRENT_BRANCH && exit 0 || { + echo "Attempt $attempt failed. Retrying in 5 seconds..." + sleep 5 + } + done + + echo "Failed to push changes after 3 attempts." + exit 1 diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index 07994bbb..0f3ec82e 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -24,4 +24,4 @@ jobs: run: | cd tenancy-queue-tester TENANCY_VERSION=${VERSION_PREFIX}#${GITHUB_SHA} ./setup.sh - TENANCY_VERSION=${VERSION_PREFIX}#${GITHUB_SHA} ./test.sh + ./test.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3931ca36..73f6355b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,3 +1,5 @@ +name: Validate code + on: [push, pull_request] jobs: diff --git a/INTERNAL.md b/INTERNAL.md index 34484836..b3335fed 100644 --- a/INTERNAL.md +++ b/INTERNAL.md @@ -16,3 +16,7 @@ The `ci.yml` workflow includes support for [act](https://github.com/nektos/act). To run all tests using act, run `composer act`. To run only certain tests using act, use `composer act-input "FILTER='some test name'"` or `composer act -- --input "FILTER='some test name'"`. + +Helpful note: GHA doesn't mount the project at /var/www/html like the docker compose setup does. This can be observed in act where the inner container's filesystem structure will match the host. + +Also, for debugging act you can just add a job that does `sleep 1h` and then `docker ps` + `docker exec -it bash`. 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 @@ // 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..851f4ca1 100644 --- a/composer.json +++ b/composer.json @@ -67,10 +67,10 @@ "docker-up": "docker compose up -d", "docker-down": "docker compose down", "docker-restart": "docker compose down && docker compose up -d", - "docker-rebuild": "PHP_VERSION=8.3 docker compose up -d --no-deps --build", + "docker-rebuild": "PHP_VERSION=8.4 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..1f61398a --- /dev/null +++ b/extensions/Makefile @@ -0,0 +1,41 @@ +.PHONY: all headers + +OUTPUT := + +CCFLAGS += -shared -Os + +ifeq ($(OS),Windows_NT) + OUTPUT = lib/noattach.dll + CC = clang +else + UNAME := $(shell uname) + CCFLAGS += -fPIC + ARCH := $(if $(ARCH),$(ARCH),$(shell uname -m)) + ifeq ($(UNAME),Darwin) + ifeq ($(ARCH),arm64) + OUTPUT = lib/arm/noattach.dylib + else + OUTPUT = lib/noattach.dylib + endif + else + ifeq ($(ARCH),aarch64) + OUTPUT = lib/arm/noattach.so + else + OUTPUT = lib/noattach.so + endif + endif +endif + +$(info OUTPUT=$(OUTPUT)) + +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/lib/arm/noattach.dylib b/extensions/lib/arm/noattach.dylib new file mode 100755 index 00000000..a9a8ec98 Binary files /dev/null and b/extensions/lib/arm/noattach.dylib differ diff --git a/extensions/lib/arm/noattach.so b/extensions/lib/arm/noattach.so new file mode 100755 index 00000000..ba61056d Binary files /dev/null and b/extensions/lib/arm/noattach.so differ diff --git a/extensions/lib/noattach.dll b/extensions/lib/noattach.dll new file mode 100644 index 00000000..6767a4ce Binary files /dev/null and b/extensions/lib/noattach.dll differ diff --git a/extensions/lib/noattach.dylib b/extensions/lib/noattach.dylib new file mode 100755 index 00000000..f547128b Binary files /dev/null and b/extensions/lib/noattach.dylib differ diff --git a/extensions/lib/noattach.so b/extensions/lib/noattach.so new file mode 100755 index 00000000..e3265c05 Binary files /dev/null and b/extensions/lib/noattach.so differ diff --git a/extensions/noattach.c b/extensions/noattach.c new file mode 100644 index 00000000..a8acf8b6 --- /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); + + if (sqlite3_set_authorizer(db, deny_attach_authorizer, 0) != SQLITE_OK) { + *pzErrMsg = sqlite3_mprintf("Tenancy: Failed to set authorizer"); + return SQLITE_ERROR; + } else { + return SQLITE_OK; + } +} diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php new file mode 100644 index 00000000..f428a051 --- /dev/null +++ b/src/Features/DisallowSqliteAttach.php @@ -0,0 +1,72 @@ +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; + } + if (static::$extensionPath === 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/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix)); + if (static::$extensionPath === false) { + return false; + } + + $pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound + + return true; + } +} diff --git a/tests/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php new file mode 100644 index 00000000..9ba6079d --- /dev/null +++ b/tests/Features/NoAttachTest.php @@ -0,0 +1,106 @@ + [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, 'not authorized'); + } 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]);