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
17 changes: 15 additions & 2 deletions src/Services/PassageService.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ public function callService(Request $request, PendingRequest $service, string $u
$headers = array_merge($headers, ForwardedHeaderResolver::forwardedHeaders($request));
}

$contentType = $request->header('Content-Type', '');

if (count($request->allFiles()) > 0 || str_contains(strtolower($contentType), 'multipart/form-data')) {
// The multipart branch below builds a brand new multipart body via
// attach(), and Guzzle generates its own random boundary for it and
// sets a matching Content-Type header — but only when no Content-Type
// header is already present on the request (see Guzzle\Client's
// "_conditional" header handling). Forwarding the client's original
// Content-Type here (with its own, different boundary) would win over
// Guzzle's, so the boundary declared in the header would never match
// the one actually used in the body, corrupting every multipart part
// for the upstream to parse.
unset($headers['Content-Type']);
}

$service = $service->withHeaders($headers);

if (in_array($method, ['get', 'head'])) {
Expand All @@ -39,8 +54,6 @@ public function callService(Request $request, PendingRequest $service, string $u
return $this->dispatch($service, $method, $uri);
}

$contentType = $request->header('Content-Type', '');

// PHP consumes php://input while populating $_POST/$_FILES for
// multipart/form-data requests, so getContent() is always empty for
// them — even when the request has no file fields, only text ones.
Expand Down
256 changes: 256 additions & 0 deletions tests/Feature/PassageRealServerIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
<?php

use Illuminate\Http\UploadedFile;
use Morcen\Passage\Facades\Passage;
use Morcen\Passage\PassageHandler;

/**
* Real-HTTP-transport integration lane.
*
* Every other Feature/Unit test proxies through Illuminate\Support\Facades\Http::fake(),
* which never touches Guzzle's real URI resolution, redirects, multipart
* encoding, or streaming — exactly where the riskiest bugs live. These tests
* instead proxy through a real PHP built-in web server (see
* tests/Fixtures/real-server-router.php) so body encoding, header/query
* forwarding, streaming, and the allowed-hosts guard are exercised against
* an actual socket.
*/
class RealServerProcess
{
private static ?self $instance = null;

/** @var resource|null */
private $process = null;

private ?string $logFile = null;

private int $port = 0;

private bool $ready = false;

public static function instance(): self
{
return self::$instance ??= new self;
}

public function start(): void
{
if ($this->process !== null) {
return;
}

$this->port = $this->findFreePort();

if ($this->port === 0) {
return;
}

$this->logFile = tempnam(sys_get_temp_dir(), 'passage-real-server-');
$router = __DIR__.'/../Fixtures/real-server-router.php';

$process = @proc_open(
[PHP_BINARY, '-S', "127.0.0.1:{$this->port}", $router],
[1 => ['file', $this->logFile, 'w'], 2 => ['file', $this->logFile, 'w']],
$pipes,
);

if ($process === false) {
return;
}

$this->process = $process;
$this->ready = $this->waitUntilAcceptingConnections();
}

public function stop(): void
{
if ($this->process !== null) {
proc_terminate($this->process);
proc_close($this->process);
$this->process = null;
}

if ($this->logFile !== null && file_exists($this->logFile)) {
unlink($this->logFile);
}
}

public function isReady(): bool
{
return $this->ready;
}

public function baseUri(): string
{
return "http://127.0.0.1:{$this->port}/";
}

private function findFreePort(): int
{
$socket = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);

if ($socket === false) {
return 0;
}

$name = stream_socket_get_name($socket, false);
fclose($socket);

return (int) substr($name, strrpos($name, ':') + 1);
}

private function waitUntilAcceptingConnections(): bool
{
$deadline = microtime(true) + 5;

while (microtime(true) < $deadline) {
$connection = @fsockopen('127.0.0.1', $this->port, $errno, $errstr, 0.1);

if ($connection !== false) {
fclose($connection);

return true;
}

usleep(50_000);
}

return false;
}
}

class RealServerEchoHandler extends PassageHandler
{
public function getOptions(): array
{
return ['base_uri' => RealServerProcess::instance()->baseUri()];
}
}

class RealServerStreamingHandler extends PassageHandler
{
public function getOptions(): array
{
return [
'base_uri' => RealServerProcess::instance()->baseUri(),
'passage_streaming' => true,
];
}
}

class RealServerAllowedHostHandler extends PassageHandler
{
public function getOptions(): array
{
return ['base_uri' => RealServerProcess::instance()->baseUri()];
}
}

class RealServerDisallowedHostHandler extends PassageHandler
{
public function getOptions(): array
{
return ['base_uri' => 'http://upstream.invalid.test:1/'];
}
}

beforeAll(function () {
RealServerProcess::instance()->start();
});

afterAll(function () {
RealServerProcess::instance()->stop();
});

beforeEach(function () {
if (! RealServerProcess::instance()->isReady()) {
$this->markTestSkipped('Could not start the PHP built-in server used for real-server integration tests.');
}
});

describe('real HTTP transport', function () {
it('forwards a JSON body, custom headers, and query parameters over a real socket', function () {
Passage::post('real/json/{path?}', RealServerEchoHandler::class);

$response = $this->withHeaders(['X-Client-Header' => 'from-client'])
->postJson('/real/json/echo?filter=active', ['name' => 'Ada']);

$response->assertOk();
$payload = $response->json();

expect($payload['method'])->toBe('POST')
->and($payload['path'])->toBe('/echo')
->and($payload['query'])->toBe(['filter' => 'active'])
->and(json_decode($payload['body'], true))->toBe(['name' => 'Ada'])
->and($payload['headers'])->toHaveKey('X-CLIENT-HEADER', 'from-client');
});

it('forwards a urlencoded form body over a real socket', function () {
Passage::post('real/form/{path?}', RealServerEchoHandler::class);

// PassageService forwards this branch's raw request body verbatim
// (see its comment on why it doesn't re-encode $request->post()), so
// the raw content must be set explicitly here — Laravel's post()
// helper only populates the parsed request/post array, not the raw
// body a real client's request would carry.
$response = $this->call(
'POST',
'/real/form/echo',
content: http_build_query(['username' => 'ada', 'role' => 'admin']),
);

$response->assertOk();
$payload = $response->json();

expect($payload['post'])->toBe(['username' => 'ada', 'role' => 'admin']);
});

it('forwards a multipart file upload byte-for-byte over a real socket', function () {
Passage::post('real/upload/{path?}', RealServerEchoHandler::class);

$file = UploadedFile::fake()->createWithContent('avatar.txt', str_repeat('a', 1024));

$response = $this->post('/real/upload/echo', ['label' => 'profile', 'avatar' => $file]);

$response->assertOk();
$payload = $response->json();

expect($payload['post'])->toBe(['label' => 'profile'])
->and($payload['files'])->toHaveKey('avatar')
->and($payload['files']['avatar']['name'])->toBe('avatar.txt')
->and($payload['files']['avatar']['size'])->toBe(1024);
});

it('streams a chunked upstream response back to the client over a real socket', function () {
Passage::get('real/stream/{path?}', RealServerStreamingHandler::class);

$response = $this->get('/real/stream/stream');

$response->assertOk();
expect($response->streamedContent())
->toBe("chunk-0\nchunk-1\nchunk-2\nchunk-3\nchunk-4\n");
});

it('rejects an upstream host outside allowed_hosts before making a real network call', function () {
config()->set('passage.security.enforce_allowed_hosts', true);
config()->set('passage.security.allowed_hosts', ['127.0.0.1']);

Passage::get('real/disallowed/{path?}', RealServerDisallowedHostHandler::class);

$this->get('/real/disallowed/echo')
->assertForbidden()
->assertJson(['error' => 'Upstream host is not permitted.']);
});

it('allows an upstream host in allowed_hosts over a real socket', function () {
config()->set('passage.security.enforce_allowed_hosts', true);
config()->set('passage.security.allowed_hosts', ['127.0.0.1']);

Passage::get('real/allowed/{path?}', RealServerAllowedHostHandler::class);

$response = $this->get('/real/allowed/echo?ok=1');

$response->assertOk();
expect($response->json('query'))->toBe(['ok' => '1']);
});
});
55 changes: 55 additions & 0 deletions tests/Fixtures/real-server-router.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/**
* Router script for PHP's built-in web server, used by
* tests/Feature/PassageRealServerIntegrationTest.php to exercise Passage
* against a real HTTP connection instead of Illuminate\Support\Facades\Http::fake().
*
* `/stream` returns a chunked, flushed body so the streaming code path can be
* exercised for real. Every other path echoes back everything PHP itself
* parsed from the request (method, path, query, headers, raw body, parsed
* POST fields, and uploaded files) as JSON, so the test can assert on what
* actually arrived over the wire rather than what Passage intended to send.
*/
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);

if ($uri === '/stream') {
header('Content-Type: text/plain');

for ($i = 0; $i < 5; $i++) {
echo "chunk-{$i}\n";
if (function_exists('ob_flush')) {
@ob_flush();
}
flush();
usleep(10_000);
}

return;
}

$headers = [];

foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$headers[str_replace('_', '-', substr($key, 5))] = $value;
} elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true) && $value !== '') {
$headers[str_replace('_', '-', $key)] = $value;
}
}

$files = array_map(
fn (array $file) => ['name' => $file['name'], 'size' => $file['size']],
$_FILES
);

header('Content-Type: application/json');
echo json_encode([
'method' => $_SERVER['REQUEST_METHOD'],
'path' => $uri,
'query' => $_GET,
'headers' => $headers,
'body' => file_get_contents('php://input'),
'post' => $_POST,
'files' => $files,
]);
27 changes: 27 additions & 0 deletions tests/Unit/PassageServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,33 @@ public function all(?string $key = null): array
expect($this->service->callService($request, $pending, 'upload'))->toBe($mockResponse);
});

it('excludes the client Content-Type header for a multipart request, so Guzzle can set its own boundary', function () {
// The attach()-built multipart body below gets a fresh, randomly
// generated boundary from Guzzle. Guzzle only sets a matching
// Content-Type header for it when none is already present on the
// request — forwarding the client's own Content-Type here (with its
// own, different boundary) would silently win instead, so the
// boundary declared in the header would never match the one the
// body actually uses, corrupting every multipart part for the
// upstream to parse.
$file = UploadedFile::fake()->create('avatar.png', 10, 'image/png');
$request = Request::create('/test', 'POST', server: [
'CONTENT_TYPE' => 'multipart/form-data; boundary=----ClientBoundary',
]);
$request->files->set('avatar', $file);

$mockResponse = Mockery::mock(Response::class);
$pending = Mockery::mock(PendingRequest::class);
$pending->shouldReceive('withHeaders')
->once()
->withArgs(fn (array $headers) => ! array_key_exists('Content-Type', $headers))
->andReturn($pending);
$pending->shouldReceive('attach')->once()->andReturn($pending);
$pending->shouldReceive('post')->once()->with('upload', [])->andReturn($mockResponse);

expect($this->service->callService($request, $pending, 'upload'))->toBe($mockResponse);
});

it('forwards multiple files uploaded under the same field name without crashing', function () {
$file1 = UploadedFile::fake()->create('one.txt', 5, 'text/plain');
$file2 = UploadedFile::fake()->create('two.txt', 5, 'text/plain');
Expand Down