1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 06:44:04 +00:00

[4.x] Add DisallowSqliteAttach feature (#1283)

* queue.yml: remove TENANCY_VERSION env var from test.sh

* add DisallowSqliteAttach feature

* Fix code style (php-cs-fixer)

* ci: add cd to each step

* ci: simpler solution to race conditions, proper os/arch matrix

* ci: fix runs-on matrix

* ci: fix workflow on windows, fix makefile

* Auto-build: Update extensions [skip ci]

* Auto-build: Update extensions [skip ci]

* ci: try fixing retry logic, make makefile use cl on Windows

* ci: use the current branch for rebase

* ci: try calling vcvars64

* ci: misc minor fixes

* ci: try fixing c compiler on windows

* ci: misc minor fixes

* ci: add debug steps

* ci: try to fix windows build

* ci: try using clang on windows

* ci: windows fixes, makefile fix

* Auto-build: Update extensions [skip ci]

* ci: dont produce .exp .lib on Windows

* ci: try forcing shell: bash on commit step

* ci: try to get linux cross-compilation working

* ci: reformulate condition

* ci: fix syntax error

* ci: correct debian image name

* Auto-build: Update extensions [skip ci]

* ci: try to set up macOS cross-compilation

* ci: add ARCH variable to makefile, override it during cross-compilation

* Auto-build: Update extensions [skip ci]

* ci: X64 -> x64

* ci: only trigger extensions.yml on pushes to extensions/

* fix tests on x64

* ci: try using bash for pushing on windows; ignore phpstan error

* fix test failing in ci but passing locally

* bump php version in composer.json, trigger extensions.yml build

* remove comment

* noattach: more explicit return values, avoid potential non-bool return values

* makefile: use -Os on Windows

* ci: use make -B

* ci: try triggering extensions build on extensions.yml file changes

* Auto-build: Update extensions [skip ci]

* Auto-build: Update extensions [skip ci]

* ci: remove windows linker flag, use a whitelist for git add

* Auto-build: Update extensions [skip ci]

* Auto-build: Update extensions [skip ci]

* Auto-build: Update extensions [skip ci]

* fix path in feature class, minor refactor

* Fix code style (php-cs-fixer)

---------

Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Samuel Štancl 2025-01-04 15:34:37 +01:00 committed by GitHub
parent 6b38a356cb
commit a88a42fbbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 356 additions and 3 deletions

102
.github/workflows/extensions.yml vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -1,3 +1,5 @@
name: Validate code
on: [push, pull_request]
jobs:

View file

@ -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 <id> bash`.

View file

@ -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,
],
/**

View file

@ -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",

3
extensions/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*.h
*.zip
sqlite-amalgamation-3470200/

41
extensions/Makefile Normal file
View file

@ -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 $@ $<

0
extensions/lib/.gitkeep Normal file
View file

View file

BIN
extensions/lib/arm/noattach.dylib Executable file

Binary file not shown.

BIN
extensions/lib/arm/noattach.so Executable file

Binary file not shown.

BIN
extensions/lib/noattach.dll Normal file

Binary file not shown.

BIN
extensions/lib/noattach.dylib Executable file

Binary file not shown.

BIN
extensions/lib/noattach.so Executable file

Binary file not shown.

22
extensions/noattach.c Normal file
View file

@ -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;
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Exception;
use Illuminate\Database\Connectors\ConnectionFactory;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Support\Facades\DB;
use PDO;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class DisallowSqliteAttach implements Feature
{
protected static bool|null $loadExtensionSupported = null;
public static string|false|null $extensionPath = null;
public function bootstrap(Tenancy $tenancy): void
{
// Handle any already resolved connections
foreach (DB::getConnections() as $connection) {
if ($connection instanceof SQLiteConnection) {
if (! $this->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;
}
}

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Features\DisallowSqliteAttach;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Tests\Etc\Tenant;
test('sqlite ATTACH statements can be blocked', function (bool $disallow) {
try {
readlink(base_path('vendor'));
} catch (\Throwable) {
symlink(base_path('vendor'), '/var/www/html/vendor');
}
if (php_uname('m') == 'aarch64') {
// Escape testbench prison. Can't hardcode /var/www/html/extensions/... here
// since GHA doesn't mount the filesystem on the container's workdir
DisallowSqliteAttach::$extensionPath = realpath(base_path('../../../../extensions/lib/arm/noattach.so'));
} else {
DisallowSqliteAttach::$extensionPath = realpath(base_path('../../../../extensions/lib/noattach.so'));
}
if ($disallow) config(['tenancy.features' => [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]);