1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-05 10:14:04 +00:00

add DisallowSqliteAttach feature

This commit is contained in:
Samuel Štancl 2025-01-02 05:46:43 +01:00
parent 613ab5bbc8
commit 9bb06afc57
10 changed files with 275 additions and 1 deletions

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

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

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

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

3
extensions/.gitignore vendored Normal file
View file

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

34
extensions/Makefile Normal file
View file

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

0
extensions/lib/.gitkeep Normal file
View file

View file

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);
int rc = sqlite3_set_authorizer(db, deny_attach_authorizer, 0);
if (rc != SQLITE_OK) {
*pzErrMsg = sqlite3_mprintf("Tenancy: Failed to set authorizer");
}
return rc;
}

View file

@ -0,0 +1,63 @@
<?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;
$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;
}
}

View file

@ -0,0 +1,100 @@
<?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');
}
DisallowSqliteAttach::$extensionPath = '/var/www/html/extensions/lib/arm/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);
} 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]);