Skip to content

Client-controlled {path} wildcard can escape base_uri's path scope via dot-segment traversal #96

Description

@morcen

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.phpregister(), ->where('path', '.*') (the wildcard capture)
  • src/Services/PassageService.phpcallService() (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity hardening

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions