Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [Unreleased]

### Security
- [#49] The FoundationDB client library can now be loaded from a pinned
absolute path via the `FDB_LIBRARY_PATH` environment variable, instead of
the bare soname (`libfdb_c.so`) resolved through the dynamic linker search
path — which was subject to library search-path hijacking. Configured
paths must be absolute; relative paths are rejected with an
`InvalidArgumentException`. See the README ("Pinning the client library
path") for details.

### Fixed
- [#55] Minor lifecycle and hygiene issues: the `libfdb_c.so` handle
returned by `dlopen()` is now closed via `dlclose()` in
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ wget https://github.com/apple/foundationdb/releases/download/7.3.75/foundationdb
sudo dpkg -i foundationdb-clients_7.3.75-1_amd64.deb
```

### Pinning the client library path (recommended in production)

By default the library is loaded by its bare soname (`libfdb_c.so`), which is
resolved through the dynamic linker search path (`LD_LIBRARY_PATH`,
`RUNPATH`, …). A malicious library earlier on that path would execute
arbitrary code inside the PHP process. To eliminate this attack surface, pin
the absolute path of the library:

```bash
export FDB_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libfdb_c.so
```

The path must be absolute; relative paths are rejected with an
`InvalidArgumentException`. When the variable is not set, the bare soname is
used as before.

## Quick Start

```php
Expand Down
6 changes: 6 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ Before you begin, ensure you have the following:
- **FoundationDB server** (for running operations)
- **libfdb_c.so** (FoundationDB C client library)

> **Security note:** by default the library is loaded by bare soname, which is
> resolved through the dynamic linker search path. In production, pin the
> absolute path of the library with the `FDB_LIBRARY_PATH` environment
> variable (e.g. `FDB_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libfdb_c.so`)
> to prevent library search-path hijacking. See the README for details.

## Installing FoundationDB

### Ubuntu/Debian
Expand Down
67 changes: 62 additions & 5 deletions src/NativeClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,27 @@ final class NativeClient

private static ?self $instance = null;

/**
* Environment variable that can pin the absolute path of the FoundationDB
* client library loaded through FFI. When set, it takes precedence over
* the bare soname ("libfdb_c.so"), which is resolved through the dynamic
* linker search path and is therefore subject to library hijacking
* (see issue #49).
*/
public const LIBRARY_PATH_ENV = 'FDB_LIBRARY_PATH';

/** Bare soname used when no explicit path is configured. */
private const DEFAULT_LIBRARY = 'libfdb_c.so';

public readonly FFI $fdb;

/**
* Resolved library path/soname used for both FFI::cdef() and dlopen().
* Either an absolute path (pinned, recommended in production) or the
* bare soname.
*/
private readonly string $fdbLibraryPath;

private readonly FFI $pthread;

private readonly FFI $libdl;
Expand All @@ -267,7 +286,7 @@ final class NativeClient

private ?CData $networkThread = null;

/** @var \FFI\CData|null Handle returned by dlopen('libfdb_c.so'), closed in stopNetwork(). */
/** @var \FFI\CData|null Handle returned by dlopen() of the FDB library, closed in stopNetwork(). */
private ?CData $fdbLibraryHandle = null;

/**
Expand All @@ -285,13 +304,49 @@ final class NativeClient
*/
private array $networkCompletionHooks = [];

private function __construct()
private function __construct(?string $fdbLibraryPath = null)
{
$this->fdb = FFI::cdef(self::FDB_HEADER, 'libfdb_c.so');
$this->fdbLibraryPath = self::resolveLibraryPath($fdbLibraryPath);
$this->fdb = FFI::cdef(self::FDB_HEADER, $this->fdbLibraryPath);
$this->pthread = FFI::cdef(self::PTHREAD_HEADER, 'libpthread.so.0');
$this->libdl = FFI::cdef(self::LIBDL_HEADER, 'libdl.so.2');
}

/**
* Resolves the library to load for FFI::cdef()/dlopen().
*
* Precedence: explicit argument > FDB_LIBRARY_PATH environment variable
* > the bare soname ("libfdb_c.so"). An explicitly configured value must
* be an absolute path: loading by relative path would still traverse
* attacker-influenced directories, defeating the purpose of pinning.
*
* @throws \InvalidArgumentException when a configured path is not absolute
*/
public static function resolveLibraryPath(?string $configured = null): string
{
$path = $configured ?? getenv(self::LIBRARY_PATH_ENV);

if ($path === false || $path === '') {
return self::DEFAULT_LIBRARY;
}

if (!str_starts_with($path, '/')) {
throw new \InvalidArgumentException(sprintf(
'Configured %s must be an absolute path to libfdb_c, got: "%s"',
self::LIBRARY_PATH_ENV,
$path,
));
}

return $path;
}

/** The resolved library path/soname this client was loaded from. */
public function getLibraryPath(): string
{
return $this->fdbLibraryPath;
}

public static function getInstance(): self
{
return self::$instance ??= new self();
Expand Down Expand Up @@ -332,9 +387,11 @@ public function ensureNetwork(): void

$this->networkThread = $this->pthread->new('pthread_t');

$fdbHandle = $this->libdl->dlopen('libfdb_c.so', self::RTLD_LAZY);
$fdbHandle = $this->libdl->dlopen($this->fdbLibraryPath, self::RTLD_LAZY);
if ($fdbHandle === null || FFI::isNull($fdbHandle)) {
throw new \RuntimeException('Failed to dlopen libfdb_c.so: ' . $this->lastDlError());
throw new \RuntimeException(
sprintf('Failed to dlopen %s: ', $this->fdbLibraryPath) . $this->lastDlError(),
);
}
$this->fdbLibraryHandle = $fdbHandle;

Expand Down
136 changes: 136 additions & 0 deletions tests/Unit/NativeClientLibraryPathTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace CrazyGoat\FoundationDB\Tests\Unit;

use CrazyGoat\FoundationDB\NativeClient;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
* Unit tests for NativeClient library path resolution (issue #49).
*
* Loading the native library by bare soname ("libfdb_c.so") depends on the
* dynamic linker search path and is therefore subject to library hijacking.
* NativeClient now supports pinning an absolute path via the FDB_LIBRARY_PATH
* environment variable; these tests cover the resolution/selection logic.
*/
final class NativeClientLibraryPathTest extends TestCase
{
protected function tearDown(): void
{
putenv(NativeClient::LIBRARY_PATH_ENV);
}

#[Test]
public function defaultsToBareSonameWhenNothingIsConfigured(): void
{
putenv(NativeClient::LIBRARY_PATH_ENV);

self::assertSame('libfdb_c.so', NativeClient::resolveLibraryPath());
}

#[Test]
public function usesEnvironmentVariableWhenSet(): void
{
putenv(NativeClient::LIBRARY_PATH_ENV . '=/opt/fdb/lib/libfdb_c.so');

self::assertSame('/opt/fdb/lib/libfdb_c.so', NativeClient::resolveLibraryPath());
}

#[Test]
public function explicitArgumentTakesPrecedenceOverEnvironment(): void
{
putenv(NativeClient::LIBRARY_PATH_ENV . '=/opt/fdb/lib/libfdb_c.so');

self::assertSame(
'/usr/local/lib/libfdb_c.so',
NativeClient::resolveLibraryPath('/usr/local/lib/libfdb_c.so'),
);
}

#[Test]
public function emptyEnvironmentVariableFallsBackToSoname(): void
{
putenv(NativeClient::LIBRARY_PATH_ENV . '=');

self::assertSame('libfdb_c.so', NativeClient::resolveLibraryPath());
}

#[Test]
public function emptyExplicitArgumentFallsBackToSoname(): void
{
putenv(NativeClient::LIBRARY_PATH_ENV);

self::assertSame('libfdb_c.so', NativeClient::resolveLibraryPath(''));
}

#[Test]
public function relativeConfiguredPathIsRejected(): void
{
putenv(NativeClient::LIBRARY_PATH_ENV . '=lib/libfdb_c.so');

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage(NativeClient::LIBRARY_PATH_ENV);

NativeClient::resolveLibraryPath();
}

#[Test]
public function relativeExplicitPathIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);

NativeClient::resolveLibraryPath('lib/libfdb_c.so');
}

/**
* Functional check: when a pinned path is configured, that exact path is
* the one used for FFI::cdef(). Skipped when no real libfdb_c is installed
* (unit tests must not require FoundationDB).
*/
#[Test]
public function pinnedAbsolutePathIsUsedForLibraryLoading(): void
{
$realPath = $this->findRealLibraryPath();

if ($realPath === null) {
self::markTestSkipped('libfdb_c.so is not installed on this machine');
}

$client = (new \ReflectionClass(NativeClient::class))->newInstance($realPath);

self::assertSame($realPath, $client->getLibraryPath());
// The pinned path must be loadable by FFI::cdef() with the real header.
/** @var string $header */
$header = (new \ReflectionClass(NativeClient::class))->getConstant('FDB_HEADER');
$ffi = \FFI::cdef($header, $realPath);

self::assertInstanceOf(\FFI::class, $ffi);
// @phpstan-ignore method.notFound (dynamically bound C function)
self::assertGreaterThan(0, $ffi->fdb_get_max_api_version());
}

/**
* Locates a real libfdb_c on this machine, or returns null. Tries the
* environment first, then the common Linux install locations.
*/
private function findRealLibraryPath(): ?string
{
$candidates = [
'/usr/lib/x86_64-linux-gnu/libfdb_c.so',
'/usr/lib/aarch64-linux-gnu/libfdb_c.so',
'/usr/local/lib/libfdb_c.so',
'/usr/lib/libfdb_c.so',
];

foreach ($candidates as $candidate) {
if (is_file($candidate)) {
return $candidate;
}
}

return null;
}
}
1 change: 1 addition & 0 deletions tests/Unit/NativeClientPartialInitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ private function makeClient(): NativeClient
$this->initializeReadOnly($client, 'fdb', FFI::cdef(self::FDB_STUB_HEADER, $libraryPath));
$this->initializeReadOnly($client, 'pthread', FFI::cdef(self::PTHREAD_STUB_HEADER, $libraryPath));
$this->initializeReadOnly($client, 'libdl', FFI::cdef(self::LIBDL_STUB_HEADER, $libraryPath));
$this->initializeReadOnly($client, 'fdbLibraryPath', $libraryPath);

return $client;
}
Expand Down
Loading