Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions .github/workflows/php-cs-fixer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- 'v1'
- 'v2'
- 'v3'
- 'v4'
pull_request:
branches:
- '*'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/phpstan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- 'v1'
- 'v2'
- 'v3'
- 'v4'
pull_request:
branches:
- '*'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- 'v1'
- 'v2'
- 'v3'
- 'v4'
pull_request:
branches:
- '*'
Expand Down
1 change: 1 addition & 0 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ parameters:

ignoreErrors:
- "#^Unsafe usage of new static\\(\\)\\.$#"
- "#^Trait Saloon\\\\Traits\\\\Request\\\\HasConnector is used zero times#"

# Rules

Expand Down
70 changes: 68 additions & 2 deletions src/Helpers/Storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Saloon\Helpers;

use InvalidArgumentException;
use Saloon\Exceptions\DirectoryNotFoundException;
use Saloon\Exceptions\UnableToCreateFileException;
use Saloon\Exceptions\UnableToCreateDirectoryException;
Expand Down Expand Up @@ -51,12 +52,73 @@ protected function buildPath(string $path): string
return rtrim($this->baseDirectory, $trimRules) . DIRECTORY_SEPARATOR . ltrim($path, $trimRules);
}

/**
* Normalize a path by resolving . and .. segments (no filesystem access).
*/
protected function normalizePath(string $path): string
{
$leadingSlash = $path !== '' && $path[0] === DIRECTORY_SEPARATOR;
$leadingDrive = mb_strlen($path) >= 2 && $path[1] === ':';

$segments = [];
foreach (preg_split('#[/\\\\]+#', $path, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $segment) {
if ($segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($segments);
continue;
}
$segments[] = $segment;
}

$result = implode(DIRECTORY_SEPARATOR, $segments);
if ($leadingSlash && $result !== '') {
$result = DIRECTORY_SEPARATOR . $result;
}
if ($leadingDrive && $result !== '' && $result[0] !== ':') {
$result = $path[0] . ':' . $result;
}

return $result;
}

/**
* Ensure the resolved path is under the base directory to prevent path traversal.
*
* @throws InvalidArgumentException
*/
protected function ensurePathUnderBase(string $fullPath): void
{
$baseReal = realpath($this->baseDirectory);

if ($baseReal === false) {
throw new InvalidArgumentException('Unable to determine the realpath of the base directory.');
}

if (str_contains($fullPath, '~')) {
throw new InvalidArgumentException('Path must remain inside the storage base directory.');
}

$baseTrimmed = rtrim($this->baseDirectory, DIRECTORY_SEPARATOR . ' ');
$pathSuffix = $baseTrimmed === '' ? $fullPath : ltrim(mb_substr($fullPath, mb_strlen($baseTrimmed)), DIRECTORY_SEPARATOR . ' ');
$normalizedAbsolute = $this->normalizePath($baseReal . DIRECTORY_SEPARATOR . $pathSuffix);

$baseWithSeparator = $baseReal . DIRECTORY_SEPARATOR;
if ($normalizedAbsolute !== $baseReal && ! str_starts_with($normalizedAbsolute, $baseWithSeparator)) {
throw new InvalidArgumentException('Path must remain inside the storage base directory.');
}
}

/**
* Check if the file exists
*/
public function exists(string $path): bool
{
return file_exists($this->buildPath($path));
$fullPath = $this->buildPath($path);
$this->ensurePathUnderBase($fullPath);

return file_exists($fullPath);
}

/**
Expand All @@ -72,7 +134,10 @@ public function missing(string $path): bool
*/
public function get(string $path): bool|string
{
return file_get_contents($this->buildPath($path));
$fullPath = $this->buildPath($path);
$this->ensurePathUnderBase($fullPath);

return file_get_contents($fullPath);
}

/**
Expand All @@ -85,6 +150,7 @@ public function get(string $path): bool|string
public function put(string $path, string $contents): static
{
$fullPath = $this->buildPath($path);
$this->ensurePathUnderBase($fullPath);

$directoryWithoutFilename = implode(DIRECTORY_SEPARATOR, explode(DIRECTORY_SEPARATOR, $fullPath, -1));

Expand Down
12 changes: 11 additions & 1 deletion src/Helpers/URLHelper.php
Comment thread
Sammyjo20 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Saloon\Helpers;

use InvalidArgumentException;

/**
* @internal
*/
Expand All @@ -19,11 +21,19 @@ public static function matches(string $pattern, string $value): bool

/**
* Join a base url and an endpoint together.
*
* The endpoint must be a relative path (e.g. "/users" or "users"). Absolute URLs
* are not allowed in the endpoint for security (SSRF / credential leakage).
* To send requests to another host, use a connector with that host as the base URL.
*
* @throws InvalidArgumentException When the endpoint is an absolute URL
*/
public static function join(string $baseUrl, string $endpoint): string
{
if (static::isValidUrl($endpoint)) {
return $endpoint;
throw new InvalidArgumentException(
'Absolute URLs are not allowed in the endpoint. The endpoint must be a relative path to prevent SSRF and credential leakage. To request a different host, use a connector with that host as the base URL.'
);
}

if ($endpoint !== '/') {
Expand Down
17 changes: 1 addition & 16 deletions src/Http/Auth/AccessTokenAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Saloon\Http\Auth;

use DateTimeImmutable;
use InvalidArgumentException;
use Saloon\Http\PendingRequest;
use Saloon\Contracts\OAuthAuthenticator;

Expand Down Expand Up @@ -88,20 +89,4 @@ public function isNotRefreshable(): bool
{
return ! $this->isRefreshable();
}

/**
* Serialize the access token.
*/
public function serialize(): string
{
return serialize($this);
}

/**
* Unserialize the access token.
*/
public static function unserialize(string $string): static
{
return unserialize($string, ['allowed_classes' => true]);
}
}
18 changes: 0 additions & 18 deletions src/Http/Connectors/NullConnector.php

This file was deleted.

8 changes: 8 additions & 0 deletions src/Http/Faking/Fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ public function store(RecordedResponse $recordedResponse): static
/**
* Get the fixture path
*
* The fixture name must not contain path components (/, \, ..) to prevent path traversal.
* Only alphanumeric characters, hyphens, and underscores are allowed.
*
* @throws \Saloon\Exceptions\FixtureException
*/
public function getFixturePath(): string
Expand All @@ -169,6 +172,11 @@ public function getFixturePath(): string
throw new FixtureException('The fixture must have a name');
}

if (str_contains($name, "\0") || str_contains($name, '..') || str_contains($name, '/') || str_contains($name, '\\')
|| ! preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
throw new FixtureException('The fixture name must not contain path components or invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.');
}

return sprintf('%s.%s', $name, $this::$fixtureExtension);
}

Expand Down
21 changes: 0 additions & 21 deletions src/Http/SoloRequest.php

This file was deleted.

64 changes: 64 additions & 0 deletions tests/Feature/FixturePathTraversalTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

use Saloon\Helpers\Storage;
use Saloon\Http\Faking\Fixture;
use Saloon\Data\RecordedResponse;
use Saloon\Exceptions\FixtureException;

beforeEach(function () {
$this->fixtureBaseDir = sys_get_temp_dir() . '/saloon_fixture_traversal_' . uniqid('', true);
mkdir($this->fixtureBaseDir, 0777, true);
});

afterEach(function () {
if (isset($this->fixtureBaseDir) && is_dir($this->fixtureBaseDir)) {
array_map('unlink', glob($this->fixtureBaseDir . '/*') ?: []);
rmdir($this->fixtureBaseDir);
}
$escapeWritePath = sys_get_temp_dir() . '/traversal_write_test.json';
if (file_exists($escapeWritePath)) {
unlink($escapeWritePath);
}
$escapeReadPath = sys_get_temp_dir() . '/traversal_read_target.json';
if (file_exists($escapeReadPath)) {
unlink($escapeReadPath);
}
});

test('fixture name with path traversal throws when getting mock response and does not read outside base', function () {
$storage = new Storage($this->fixtureBaseDir, true);

$externalPath = sys_get_temp_dir() . '/traversal_read_target.json';
$secretContent = 'read_from_outside';
file_put_contents($externalPath, json_encode([
'statusCode' => 200,
'headers' => [],
'data' => '{"secret":"' . $secretContent . '"}',
'context' => [],
]));

$traversalName = '..' . DIRECTORY_SEPARATOR . 'traversal_read_target';
$fixture = new Fixture($traversalName, $storage);

expect(fn () => $fixture->getMockResponse())
->toThrow(FixtureException::class, 'The fixture name must not contain path components');

expect(file_get_contents($externalPath))->toContain($secretContent);
});

test('fixture name with path traversal throws when storing and does not write outside base', function () {
$storage = new Storage($this->fixtureBaseDir, true);

$traversalName = '..' . DIRECTORY_SEPARATOR . 'traversal_write_test';
$fixture = new Fixture($traversalName, $storage);

$recordedResponse = new RecordedResponse(200, [], '{"pwned":true}');

expect(fn () => $fixture->store($recordedResponse))
->toThrow(FixtureException::class, 'The fixture name must not contain path components');

$escapePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'traversal_write_test.json';
expect(file_exists($escapePath))->toBeFalse();
});
64 changes: 0 additions & 64 deletions tests/Feature/SoloRequestTest.php

This file was deleted.

Loading
Loading