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
2 changes: 1 addition & 1 deletion .stats.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
configured_endpoints: 239
openapi_spec_hash: 202e0c39463e4ad1b25a72b0da68a6ce
openapi_spec_hash: ea35154a418c5a3cabaedc30f766fcac
config_hash: 1ca082e374ef7000e2a5971e8da740e0
2 changes: 1 addition & 1 deletion scripts/mock

Large diffs are not rendered by default.

41 changes: 39 additions & 2 deletions src/Core/BaseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,46 @@ protected function followRedirect(
throw new APIConnectionException($req, message: 'Redirection without Location header');
}

$uri = Util::joinUri($req->getUri(), path: $location);
$oldUri = $req->getUri();
$parsed = parse_url($location);
if (false === $parsed) {
throw new APIConnectionException($req, message: 'Redirect to a malformed URL');
}

// A redirect's Location fully determines the query string and fragment.
// joinUri would merge in the original request's query -- leaking
// query-based credentials to the new host and appending params that break
// strictly-signed URLs -- and it never applies the Location's fragment, so
// set both from the Location (and don't carry over the prior fragment).
$uri = Util::joinUri($oldUri, path: $location)
->withQuery($parsed['query'] ?? '')
->withFragment($parsed['fragment'] ?? '')
;

if ('https' === $oldUri->getScheme() && 'http' === $uri->getScheme()) {
throw new APIConnectionException($req, message: 'Tried to redirect to an insecure URL');
}

$req = $req->withUri($uri);

// On a cross-origin redirect, drop credentials so we don't leak them to a
// different host. Mirrors undici and the other Stainless SDKs; withUri()
// already rewrites Host. Default ports are normalized so an implicit vs
// explicit :443/:80 doesn't read as a different origin.
$oldPort = $oldUri->getPort() ?? ('https' === $oldUri->getScheme() ? 443 : 80);
$newPort = $uri->getPort() ?? ('https' === $uri->getScheme() ? 443 : 80);
$crossOrigin = $oldUri->getScheme() !== $uri->getScheme()
|| $oldUri->getHost() !== $uri->getHost()
|| $oldPort !== $newPort;
if ($crossOrigin) {
foreach ([
'Authorization', 'Cookie', 'Proxy-Authorization',
] as $header) {
$req = $req->withoutHeader($header);
}
}

return $req->withUri($uri);
return $req;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ enum EventCategory: string

case PHYSICAL_CHECK_UPDATED = 'physical_check.updated';

case PHYSICAL_CHECK_BOOK_CREATED = 'physical_check_book.created';

case PHYSICAL_CHECK_BOOK_UPDATED = 'physical_check_book.updated';

case PROGRAM_CREATED = 'program.created';

case PROGRAM_UPDATED = 'program.updated';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ enum EventCategory: string

case PHYSICAL_CHECK_UPDATED = 'physical_check.updated';

case PHYSICAL_CHECK_BOOK_CREATED = 'physical_check_book.created';

case PHYSICAL_CHECK_BOOK_UPDATED = 'physical_check_book.updated';

case PROGRAM_CREATED = 'program.created';

case PROGRAM_UPDATED = 'program.updated';
Expand Down
4 changes: 4 additions & 0 deletions src/Events/Event/Category.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ enum Category: string

case PHYSICAL_CHECK_UPDATED = 'physical_check.updated';

case PHYSICAL_CHECK_BOOK_CREATED = 'physical_check_book.created';

case PHYSICAL_CHECK_BOOK_UPDATED = 'physical_check_book.updated';

case PROGRAM_CREATED = 'program.created';

case PROGRAM_UPDATED = 'program.updated';
Expand Down
4 changes: 4 additions & 0 deletions src/Events/EventListParams/Category/In.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ enum In: string

case PHYSICAL_CHECK_UPDATED = 'physical_check.updated';

case PHYSICAL_CHECK_BOOK_CREATED = 'physical_check_book.created';

case PHYSICAL_CHECK_BOOK_UPDATED = 'physical_check_book.updated';

case PROGRAM_CREATED = 'program.created';

case PROGRAM_UPDATED = 'program.updated';
Expand Down
4 changes: 4 additions & 0 deletions src/Events/UnwrapWebhookEvent/Category.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ enum Category: string

case PHYSICAL_CHECK_UPDATED = 'physical_check.updated';

case PHYSICAL_CHECK_BOOK_CREATED = 'physical_check_book.created';

case PHYSICAL_CHECK_BOOK_UPDATED = 'physical_check_book.updated';

case PROGRAM_CREATED = 'program.created';

case PROGRAM_UPDATED = 'program.updated';
Expand Down
86 changes: 86 additions & 0 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Http\Discovery\Psr17FactoryDiscovery;
use Http\Mock\Client;
use Increase\Core\Exceptions\APIConnectionException;
use Increase\Core\Util;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -41,4 +42,89 @@ public function testDefaultHeaders(): void
$this->assertNotEmpty($sent);
}
}

public function testRedirectMalformedLocationThrows(): void
{
$transporter = new Client;
$transporter->addResponse(
Psr17FactoryDiscovery::findResponseFactory()
->createResponse(307)
->withHeader('Location', 'https://example.com:invalid-port/redirected')
);

$client = new \Increase\Client(
baseUrl: 'http://localhost',
apiKey: 'My API Key',
requestOptions: ['transporter' => $transporter],
);

$this->expectException(APIConnectionException::class);

$client->accounts->create(name: 'New Account!');
}

public function testRedirectStripsAuthorizationCrossOrigin(): void
{
$transporter = new Client;
$transporter->addResponse(
Psr17FactoryDiscovery::findResponseFactory()
->createResponse(307)
->withHeader('Location', 'https://example.com/redirected?token=a%2Fb%2Bc%3D%3D#section')
);
$transporter->setDefaultResponse(
Psr17FactoryDiscovery::findResponseFactory()
->createResponse(200)
->withHeader('Content-Type', 'application/json')
->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream(json_encode([], flags: Util::JSON_ENCODE_FLAGS) ?: ''))
);

$client = new \Increase\Client(
baseUrl: 'http://localhost',
apiKey: 'My API Key',
requestOptions: ['transporter' => $transporter],
);

$client->accounts->create(
name: 'New Account!',
requestOptions: ['extraQueryParams' => ['leaked' => 'secret']],
);

$requests = $transporter->getRequests();
$this->assertGreaterThanOrEqual(2, count($requests));
$redirected = $requests[1];

$this->assertEmpty($redirected->getHeaderLine('Authorization'));
$this->assertSame('token=a%2Fb%2Bc%3D%3D', $redirected->getUri()->getQuery());
$this->assertSame('section', $redirected->getUri()->getFragment());
}

public function testRedirectKeepsAuthorizationSameOrigin(): void
{
$transporter = new Client;
$transporter->addResponse(
Psr17FactoryDiscovery::findResponseFactory()
->createResponse(307)
->withHeader('Location', '/redirected')
);
$transporter->setDefaultResponse(
Psr17FactoryDiscovery::findResponseFactory()
->createResponse(200)
->withHeader('Content-Type', 'application/json')
->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream(json_encode([], flags: Util::JSON_ENCODE_FLAGS) ?: ''))
);

$client = new \Increase\Client(
baseUrl: 'http://localhost',
apiKey: 'My API Key',
requestOptions: ['transporter' => $transporter],
);

$client->accounts->create(name: 'New Account!');

$requests = $transporter->getRequests();
$this->assertGreaterThanOrEqual(2, count($requests));
$redirected = $requests[1];

$this->assertNotEmpty($redirected->getHeaderLine('Authorization'));
}
}