Problem
Passage routes are registered with a catch-all {path} segment (->where('path', '.*') in Passage::register()), and the raw path is forwarded upstream as a relative URI against the handler's base_uri Guzzle option. Guzzle resolves relative URIs against base_uri using GuzzleHttp\Psr7\UriResolver::resolve(), which performs full RFC 3986 §5.3 merge + dot-segment normalization.
This means a client can supply .. segments in the wildcard path to escape any sub-path scoping baked into base_uri. Verified directly against the actual Guzzle resolver used by this package:
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils;
$base = Utils::uriFor('https://internal.corp/api/public/');
$rel = Utils::uriFor('../private/admin-secrets');
echo (string) UriResolver::resolve($base, $rel);
// => https://internal.corp/api/private/admin-secrets
Any handler that relies on a path segment in base_uri as an implicit access boundary — e.g. https://internal.example.com/tenant-a/, https://api.example.com/public/, or a versioned prefix like /v1/ meant to exclude an internal /v1-internal/ — can be bypassed by a client sending GET /proxy-route/../tenant-b/secret (or the URL-encoded form %2e%2e/..., which many front-end web servers do not normalize, unlike literal ..). The resolved request still targets the same host, so this isn't a full SSRF to arbitrary hosts, but it is a real path-scoping / access-control bypass for any handler that assumed base_uri's path component was an enforced boundary.
Location
src/Passage.php — register(), ->where('path', '.*') (the wildcard capture)
src/Services/PassageService.php — callService() (the path segment $uri is forwarded as-is)
- Guzzle's
Client::buildUri() (vendor/guzzlehttp/guzzle/src/Client.php) performs the dot-segment resolution once base_uri + the relative $uri reach the HTTP client
Why it matters
This is silent and easy to miss: nothing in Passage validates or normalizes the captured {path} before it's used to build the upstream request, and nothing in the README warns handler authors that base_uri's path component is not a safe access boundary. Any Passage deployment that put a scoping segment (tenant ID, environment, internal-vs-public prefix) into base_uri is silently exposed.
Suggested fix
Reject or normalize .. (including percent-encoded %2e%2e) segments in the captured path before it is used to build the upstream request — e.g. run it through a canonicalization check and 400 the request if the resolved path segment count would go negative, or explicitly document that base_uri must not be relied on as an access-control boundary and provide a built-in guard (similar to AllowedHostsGuard) that validates the final resolved path stays under base_uri's path prefix.
Problem
Passage routes are registered with a catch-all
{path}segment (->where('path', '.*')inPassage::register()), and the raw path is forwarded upstream as a relative URI against the handler'sbase_uriGuzzle option. Guzzle resolves relative URIs againstbase_uriusingGuzzleHttp\Psr7\UriResolver::resolve(), which performs full RFC 3986 §5.3 merge + dot-segment normalization.This means a client can supply
..segments in the wildcard path to escape any sub-path scoping baked intobase_uri. Verified directly against the actual Guzzle resolver used by this package:Any handler that relies on a path segment in
base_urias an implicit access boundary — e.g.https://internal.example.com/tenant-a/,https://api.example.com/public/, or a versioned prefix like/v1/meant to exclude an internal/v1-internal/— can be bypassed by a client sendingGET /proxy-route/../tenant-b/secret(or the URL-encoded form%2e%2e/..., which many front-end web servers do not normalize, unlike literal..). The resolved request still targets the same host, so this isn't a full SSRF to arbitrary hosts, but it is a real path-scoping / access-control bypass for any handler that assumedbase_uri's path component was an enforced boundary.Location
src/Passage.php—register(),->where('path', '.*')(the wildcard capture)src/Services/PassageService.php—callService()(the path segment$uriis forwarded as-is)Client::buildUri()(vendor/guzzlehttp/guzzle/src/Client.php) performs the dot-segment resolution oncebase_uri+ the relative$urireach the HTTP clientWhy it matters
This is silent and easy to miss: nothing in Passage validates or normalizes the captured
{path}before it's used to build the upstream request, and nothing in the README warns handler authors thatbase_uri's path component is not a safe access boundary. Any Passage deployment that put a scoping segment (tenant ID, environment, internal-vs-public prefix) intobase_uriis silently exposed.Suggested fix
Reject or normalize
..(including percent-encoded%2e%2e) segments in the capturedpathbefore it is used to build the upstream request — e.g. run it through a canonicalization check and 400 the request if the resolved path segment count would go negative, or explicitly document thatbase_urimust not be relied on as an access-control boundary and provide a built-in guard (similar toAllowedHostsGuard) that validates the final resolved path stays underbase_uri's path prefix.