diff --git a/src/Http/Response.php b/src/Http/Response.php index 1500d2da..5aa3e644 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -541,7 +541,7 @@ public function isXml(): bool * * @return resource */ - public function getRawStream(): mixed + public function getRawStream(bool $validatePath = false): mixed { $temporaryResource = fopen('php://temp', 'wb+'); @@ -549,7 +549,7 @@ public function getRawStream(): mixed throw new LogicException('Unable to create a temporary resource for the stream.'); } - $this->saveBodyToFile($temporaryResource, false); + $this->saveBodyToFile($temporaryResource, false, $validatePath); return $temporaryResource; } @@ -559,12 +559,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) { @@ -673,4 +677,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.'); + } + } } diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php index 95fbc452..cf66f478 100644 --- a/tests/Unit/ResponseTest.php +++ b/tests/Unit/ResponseTest.php @@ -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');