Skip to content
Open
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
21 changes: 18 additions & 3 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -557,15 +557,15 @@ public function isXml(): bool
*
* @return resource
*/
public function getRawStream(): mixed
public function getRawStream(bool $validatePath = false): mixed
{
$temporaryResource = fopen('php://temp', 'wb+');

if ($temporaryResource === false) {
throw new LogicException('Unable to create a temporary resource for the stream.');
}

$this->saveBodyToFile($temporaryResource, false);
$this->saveBodyToFile($temporaryResource, false, $validatePath);

return $temporaryResource;
}
Expand All @@ -575,12 +575,16 @@ public function getRawStream(): mixed
*
* @param string|resource $resourceOrPath
*/
public function saveBodyToFile(mixed $resourceOrPath, bool $closeResource = true): void
public function saveBodyToFile(mixed $resourceOrPath, bool $closeResource = true, bool $validatePath = false): void
{
if (! is_string($resourceOrPath) && ! is_resource($resourceOrPath)) {
throw new InvalidArgumentException('The $resourceOrPath argument must be either a file path or a resource.');
}

if (is_string($resourceOrPath) && $validatePath) {
$this->assertSafePath($resourceOrPath);
}

$resource = is_string($resourceOrPath) ? fopen($resourceOrPath, 'wb+') : $resourceOrPath;

if ($resource === false) {
Expand Down Expand Up @@ -689,4 +693,15 @@ public function getFakeResponse(): ?FakeResponse
{
return $this->fakeResponse;
}

private function assertSafePath(string $path): void
{
if (str_contains($path, '../') || str_contains($path, '..\\')) {
throw new InvalidArgumentException('Path traversal detected.');
}

if (str_contains($path, '://') || str_starts_with(mb_strtolower($path), 'phar://') || str_starts_with(mb_strtolower($path), 'php://') || str_starts_with(mb_strtolower($path), 'data://')) {
throw new InvalidArgumentException('Stream wrappers are not allowed.');
}
}
}
94 changes: 94 additions & 0 deletions tests/Unit/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,100 @@
fn () => fopen('tests/Fixtures/Saloon/Testing/streamToFile2.json', 'wb+'),
]);

test('saveBodyToFile is backwards compatible when validatePath is disabled', function () {
$mockClient = new MockClient([
MockResponse::make(['foo' => 'bar']),
]);

$response = connector()->send(new UserRequest, $mockClient);
$path = sys_get_temp_dir().'/saloon_bc_'.uniqid('', true).'.json';

$response->saveBodyToFile($path);

expect(file_get_contents($path))->toEqual('{"foo":"bar"}');

unlink($path);
});

test('saveBodyToFile does not validate paths when validatePath is disabled', function () {
$mockClient = new MockClient([
MockResponse::make(['foo' => 'bar']),
]);

$response = connector()->send(new UserRequest, $mockClient);

expect(fn () => $response->saveBodyToFile('../unvalidated-path.json'))
->not->toThrow(\InvalidArgumentException::class);
});

test('saveBodyToFile saves to a safe path when validatePath is enabled', function () {
$mockClient = new MockClient([
MockResponse::make(['foo' => 'bar']),
]);

$response = connector()->send(new UserRequest, $mockClient);
$path = sys_get_temp_dir().'/saloon_safe_'.uniqid('', true).'.json';

$response->saveBodyToFile($path, true, true);

expect(file_get_contents($path))->toEqual('{"foo":"bar"}');

unlink($path);
});

test('saveBodyToFile rejects path traversal when validatePath is enabled', function () {
$mockClient = new MockClient([
MockResponse::make(['foo' => 'bar']),
]);

$response = connector()->send(new UserRequest, $mockClient);
$path = sys_get_temp_dir().'/../../../etc/passwd';

expect(fn () => $response->saveBodyToFile($path, true, true))
->toThrow(InvalidArgumentException::class, 'Path traversal detected.');
});

test('saveBodyToFile rejects stream wrappers when validatePath is enabled', function (string $path, string $message) {
$mockClient = new MockClient([
MockResponse::make(['foo' => 'bar']),
]);

$response = connector()->send(new UserRequest, $mockClient);

expect(fn () => $response->saveBodyToFile($path, true, true))
->toThrow(InvalidArgumentException::class, $message);
})->with([
['phar://malicious.phar/payload.php', 'Stream wrappers are not allowed.'],
['php://filter/read=convert.base64-encode/resource=index.php', 'Stream wrappers are not allowed.'],
['data://text/plain,test', 'Stream wrappers are not allowed.'],
]);

test('getRawStream remains backwards compatible when validatePath is disabled', function () {
$mockClient = new MockClient([
MockResponse::make(['foo' => 'bar']),
]);

$response = connector()->send(new UserRequest, $mockClient);

$resource = $response->getRawStream();

expect($resource)->toBeResource();
expect(stream_get_contents($resource))->toEqual('{"foo":"bar"}');
});

test('getRawStream works when validatePath is enabled', function () {
$mockClient = new MockClient([
MockResponse::make(['foo' => 'bar']),
]);

$response = connector()->send(new UserRequest, $mockClient);

$resource = $response->getRawStream(validatePath: true);

expect($resource)->toBeResource();
expect(stream_get_contents($resource))->toEqual('{"foo":"bar"}');
});

test('the response is macroable', function () {
SaloonResponse::macro('yee', fn () => 'haw');

Expand Down
Loading