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
27 changes: 27 additions & 0 deletions src/Helpers/OAuth2/OAuthConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ class OAuthConfig
*/
protected array $defaultScopes = [];

/**
* When true, OAuth endpoints (authorize, token, user) may be full URLs that differ from the connector base.
* Do not enable with user-controlled endpoint values (SSRF / credential leakage).
*/
protected bool $allowBaseUrlOverride = false;

/**
* Get the Client ID
*/
Expand Down Expand Up @@ -120,6 +126,27 @@ public function setRedirectUri(string $redirectUri): static
return $this;
}

/**
* Whether absolute URLs are allowed for OAuth authorize, token, and user endpoints.
*/
public function getAllowBaseUrlOverride(): bool
{
return $this->allowBaseUrlOverride;
}

/**
* Allow OAuth endpoints to be absolute URLs (different host than the connector base).
* Do not enable when endpoint values are user-controlled.
*
* @return $this
*/
public function setAllowBaseUrlOverride(bool $allow = true): static
{
$this->allowBaseUrlOverride = $allow;

return $this;
}

/**
* Get the authorization endpoint.
*/
Expand Down
15 changes: 10 additions & 5 deletions src/Helpers/URLHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,23 @@ public static function matches(string $pattern, string $value): bool
/**
* Join a base url and an endpoint together.
*
* When the connector has a base URL, the endpoint must be a relative path (e.g. "/users" or "users").
* Absolute URLs in the endpoint are not allowed in that case (SSRF / credential leakage).
* When the connector has a base URL, the endpoint must be a relative path (e.g. "/users" or "users"),
* unless $allowBaseUrlOverride is true (e.g. OAuth provider URLs). Allowing override with user-controlled
* endpoints reintroduces SSRF and credential leakage.
* When the base URL is empty (e.g. Solo Request), the endpoint may be an absolute URL.
*
* @throws InvalidArgumentException When the endpoint is an absolute URL and the base URL is not empty
* @throws InvalidArgumentException When the endpoint is an absolute URL, the base URL is not empty, and override is not allowed
*/
public static function join(string $baseUrl, string $endpoint): string
public static function join(string $baseUrl, string $endpoint, bool $allowBaseUrlOverride = false): string
{
$baseTrimmed = trim($baseUrl, '/ ');
if ($baseTrimmed !== '' && static::isValidUrl($endpoint)) {
if ($allowBaseUrlOverride) {
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.'
'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, or enable allowBaseUrlOverride on the connector, request, or OAuth configuration when the endpoint is trusted.'
);
}

Expand Down
7 changes: 7 additions & 0 deletions src/Http/Connector.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ abstract class Connector
use Makeable;
use HasTries;

/**
* When true, resolveEndpoint() may return an absolute URL (different host than base).
* Set on the connector instance or declare e.g. `public bool $allowBaseUrlOverride = true` on your subclass.
* Enabling with user-controlled endpoints risks SSRF and credential leakage.
*/
public bool $allowBaseUrlOverride = false;

/**
* Define the base URL of the API.
*/
Expand Down
43 changes: 42 additions & 1 deletion src/Http/PendingRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,27 @@
use Saloon\Contracts\FakeResponse;
use Saloon\Http\Faking\MockClient;
use Saloon\Contracts\Authenticator;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Http\OAuth2\GetUserRequest;
use Saloon\Contracts\Body\BodyRepository;
use Saloon\Http\PendingRequest\MergeBody;
use Saloon\Http\PendingRequest\MergeDelay;
use Saloon\Http\Middleware\DelayMiddleware;
use Saloon\Http\PendingRequest\BootPlugins;
use Saloon\Http\OAuth2\GetAccessTokenRequest;
use Saloon\Traits\Auth\AuthenticatesRequests;
use Saloon\Http\Middleware\ValidateProperties;
use Saloon\Http\OAuth2\GetRefreshTokenRequest;
use Saloon\Http\Middleware\DetermineMockResponse;
use Saloon\Exceptions\InvalidResponseClassException;
use Saloon\Exceptions\Request\FatalRequestException;
use Saloon\Traits\PendingRequest\ManagesPsrRequests;
use Saloon\Http\PendingRequest\MergeRequestProperties;
use Saloon\Http\PendingRequest\BootConnectorAndRequest;
use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
use Saloon\Traits\RequestProperties\HasRequestProperties;
use Saloon\Http\PendingRequest\AuthenticatePendingRequest;
use Saloon\Http\OAuth2\GetClientCredentialsTokenBasicAuthRequest;

class PendingRequest
{
Expand Down Expand Up @@ -89,7 +95,11 @@ public function __construct(Connector $connector, Request $request, ?MockClient
$this->connector = $connector;
$this->request = $request;
$this->method = $request->getMethod();
$this->url = URLHelper::join($this->connector->resolveBaseUrl(), $this->request->resolveEndpoint());
$this->url = URLHelper::join(
$this->connector->resolveBaseUrl(),
$this->request->resolveEndpoint(),
$this->resolveAllowBaseUrlOverrideForUrl(),
);
$this->authenticator = $request->getAuthenticator() ?? $connector->getAuthenticator();
$this->mockClient = $mockClient ?? $request->getMockClient() ?? $connector->getMockClient() ?? MockClient::getGlobal();

Expand Down Expand Up @@ -316,4 +326,35 @@ protected function tap(callable $callable): static

return $this;
}

/**
* Resolve whether an absolute URL may be used when joining the connector base with the request endpoint.
* Uses the request flag when set, else OAuth config for token/user internal requests, else the connector flag.
*/
protected function resolveAllowBaseUrlOverrideForUrl(): bool
{
if ($this->request->allowBaseUrlOverride !== null) {
return $this->request->allowBaseUrlOverride;
}

if ($this->usesOAuthConfigTokenOrUserEndpoint() && method_exists($this->connector, 'oauthConfig') && $this->connector->oauthConfig() instanceof OAuthConfig) {
return $this->connector->oauthConfig()->getAllowBaseUrlOverride();
}

return $this->connector->allowBaseUrlOverride;
}

/**
* True for internal OAuth2 requests (token exchange, refresh, client credentials, or user info).
*/
protected function usesOAuthConfigTokenOrUserEndpoint(): bool
{
$request = $this->request;

return $request instanceof GetAccessTokenRequest
|| $request instanceof GetRefreshTokenRequest
|| $request instanceof GetUserRequest
|| $request instanceof GetClientCredentialsTokenRequest
|| $request instanceof GetClientCredentialsTokenBasicAuthRequest;
}
}
6 changes: 6 additions & 0 deletions src/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ abstract class Request
use Bootable;
use Makeable;

/**
* When non-null, overrides connector / OAuth resolution for absolute endpoints in resolveEndpoint().
* null = inherit. Set true/false on the instance or declare on your request subclass.
*/
public ?bool $allowBaseUrlOverride = null;

/**
* Define the HTTP method.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/Http/SoloRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ abstract class SoloRequest extends Request
{
use HasConnector;

/**
* When true, OAuth endpoints (authorize, token, user) may be full URLs that differ from the connector base.
* Do not enable with user-controlled endpoint values (SSRF / credential leakage).
*/
public ?bool $allowBaseUrlOverride = true;

/**
* Create a new connector instance.
*/
Expand Down
3 changes: 2 additions & 1 deletion src/Traits/OAuth2/AuthorizationCodeGrant.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ public function getAuthorizationUrl(array $scopes = [], ?string $state = null, s
$query = http_build_query($queryParameters, '', '&', PHP_QUERY_RFC3986);
$query = trim($query, '?&');

$url = URLHelper::join($this->resolveBaseUrl(), $config->getAuthorizeEndpoint());
$allows = $config->getAllowBaseUrlOverride() || $this->allowBaseUrlOverride;
$url = URLHelper::join($this->resolveBaseUrl(), $config->getAuthorizeEndpoint(), $allows);

$glue = str_contains($url, '?') ? '&' : '?';

Expand Down
49 changes: 49 additions & 0 deletions tests/Feature/Oauth2/AbsoluteOAuthEndpointTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
use Saloon\Tests\Fixtures\Connectors\AuthorizeUrlOAuthConnector;
use Saloon\Tests\Fixtures\Connectors\AbsoluteTokenOAuthConnector;
use Saloon\Tests\Fixtures\Connectors\AbsoluteTokenOAuthConnectorWithoutAllow;
use Saloon\Tests\Fixtures\Connectors\AuthorizeUrlOAuthConnectorWithConnectorAllowOnly;

test('OAuth client credentials pending request uses absolute token URL when OAuthConfig allows base override', function () {
$connector = new AbsoluteTokenOAuthConnector;
$request = new GetClientCredentialsTokenRequest($connector->oauthConfig());

$pending = $connector->createPendingRequest($request);

expect($pending->getUrl())->toBe('https://auth.external.com/oauth/token');
});

test('OAuth client credentials throws when token endpoint is absolute and OAuthConfig does not allow override', function () {
$connector = new AbsoluteTokenOAuthConnectorWithoutAllow;
$request = new GetClientCredentialsTokenRequest($connector->oauthConfig());

expect(fn () => $connector->createPendingRequest($request))
->toThrow(InvalidArgumentException::class);
});

test('request allowBaseUrlOverride false overrides OAuthConfig allow for token URL', function () {
$connector = new AbsoluteTokenOAuthConnector;
$request = new GetClientCredentialsTokenRequest($connector->oauthConfig());
$request->allowBaseUrlOverride = false;

expect(fn () => $connector->createPendingRequest($request))
->toThrow(InvalidArgumentException::class);
});

test('getAuthorizationUrl works with absolute authorize endpoint when OAuthConfig allows override', function () {
$connector = new AuthorizeUrlOAuthConnector;
$url = $connector->getAuthorizationUrl();

expect($url)->toStartWith('https://login.provider.com/authorize');
});

test('getAuthorizationUrl works with absolute authorize when connector allows override only', function () {
$connector = new AuthorizeUrlOAuthConnectorWithConnectorAllowOnly;
$url = $connector->getAuthorizationUrl();

expect($url)->toStartWith('https://login.provider.com/authorize');
});
28 changes: 28 additions & 0 deletions tests/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Connectors;

use Saloon\Http\Connector;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\OAuth2\ClientCredentialsGrant;

class AbsoluteTokenOAuthConnector extends Connector
{
use ClientCredentialsGrant;

public function resolveBaseUrl(): string
{
return 'https://api.service.com';
}

protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make()
->setClientId('id')
->setClientSecret('secret')
->setTokenEndpoint('https://auth.external.com/oauth/token')
->setAllowBaseUrlOverride();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Connectors;

use Saloon\Http\Connector;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\OAuth2\ClientCredentialsGrant;

class AbsoluteTokenOAuthConnectorWithoutAllow extends Connector
{
use ClientCredentialsGrant;

public function resolveBaseUrl(): string
{
return 'https://api.service.com';
}

protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make()
->setClientId('id')
->setClientSecret('secret')
->setTokenEndpoint('https://auth.external.com/oauth/token');
}
}
29 changes: 29 additions & 0 deletions tests/Fixtures/Connectors/AuthorizeUrlOAuthConnector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Connectors;

use Saloon\Http\Connector;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\OAuth2\AuthorizationCodeGrant;

class AuthorizeUrlOAuthConnector extends Connector
{
use AuthorizationCodeGrant;

public function resolveBaseUrl(): string
{
return 'https://api.app.com';
}

protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make()
->setClientId('id')
->setClientSecret('secret')
->setRedirectUri('https://app.com/cb')
->setAuthorizeEndpoint('https://login.provider.com/authorize')
->setAllowBaseUrlOverride();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Connectors;

use Saloon\Http\Connector;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Traits\OAuth2\AuthorizationCodeGrant;

/**
* Absolute authorize URL; only the connector allowBaseUrlOverride flag is set (not OAuthConfig).
*/
class AuthorizeUrlOAuthConnectorWithConnectorAllowOnly extends Connector
{
use AuthorizationCodeGrant;

public function __construct()
{
$this->allowBaseUrlOverride = true;
}

public function resolveBaseUrl(): string
{
return 'https://api.app.com';
}

protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make()
->setClientId('id')
->setClientSecret('secret')
->setRedirectUri('https://app.com/cb')
->setAuthorizeEndpoint('https://login.provider.com/authorize');
}
}
6 changes: 6 additions & 0 deletions tests/Unit/Oauth2/OAuthConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
expect($config->getTokenEndpoint())->toEqual('token');
expect($config->getUserEndpoint())->toEqual('user');
expect($config->getDefaultScopes())->toEqual([]);
expect($config->getAllowBaseUrlOverride())->toBeFalse();

$clientId = 'client-id';
$clientSecret = 'client-secret';
Expand All @@ -31,6 +32,7 @@
expect($config->setTokenEndpoint($tokenEndpoint))->toEqual($config);
expect($config->setUserEndpoint($userEndpoint))->toEqual($config);
expect($config->setDefaultScopes($defaultScopes))->toEqual($config);
expect($config->setAllowBaseUrlOverride(true))->toEqual($config);

expect($config->getClientId())->toEqual($clientId);
expect($config->getClientSecret())->toEqual($clientSecret);
Expand All @@ -39,6 +41,10 @@
expect($config->getTokenEndpoint())->toEqual($tokenEndpoint);
expect($config->getUserEndpoint())->toEqual($userEndpoint);
expect($config->getDefaultScopes())->toEqual($defaultScopes);
expect($config->getAllowBaseUrlOverride())->toBeTrue();

$config->setAllowBaseUrlOverride(false);
expect($config->getAllowBaseUrlOverride())->toBeFalse();
});

test('make method creates an instance of OAuthConfig', function () {
Expand Down
Loading
Loading