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:
parent
6b38a356cb
commit
a88a42fbbf
18 changed files with 356 additions and 3 deletions
102
.github/workflows/extensions.yml
vendored
Normal file
102
.github/workflows/extensions.yml
vendored
Normal 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
|
||||
2
.github/workflows/queue.yml
vendored
2
.github/workflows/queue.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/validate.yml
vendored
2
.github/workflows/validate.yml
vendored
|
|
@ -1,3 +1,5 @@
|
|||
name: Validate code
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
3
extensions/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
*.h
|
||||
*.zip
|
||||
sqlite-amalgamation-3470200/
|
||||
41
extensions/Makefile
Normal file
41
extensions/Makefile
Normal 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
0
extensions/lib/.gitkeep
Normal file
0
extensions/lib/arm/.gitkeep
Normal file
0
extensions/lib/arm/.gitkeep
Normal file
BIN
extensions/lib/arm/noattach.dylib
Executable file
BIN
extensions/lib/arm/noattach.dylib
Executable file
Binary file not shown.
BIN
extensions/lib/arm/noattach.so
Executable file
BIN
extensions/lib/arm/noattach.so
Executable file
Binary file not shown.
BIN
extensions/lib/noattach.dll
Normal file
BIN
extensions/lib/noattach.dll
Normal file
Binary file not shown.
BIN
extensions/lib/noattach.dylib
Executable file
BIN
extensions/lib/noattach.dylib
Executable file
Binary file not shown.
BIN
extensions/lib/noattach.so
Executable file
BIN
extensions/lib/noattach.so
Executable file
Binary file not shown.
22
extensions/noattach.c
Normal file
22
extensions/noattach.c
Normal 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;
|
||||
}
|
||||
}
|
||||
72
src/Features/DisallowSqliteAttach.php
Normal file
72
src/Features/DisallowSqliteAttach.php
Normal 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;
|
||||
}
|
||||
}
|
||||
106
tests/Features/NoAttachTest.php
Normal file
106
tests/Features/NoAttachTest.php
Normal 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]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue