fix(vertex): validate location region to close a path-parameter SSRF (#3304)

## Description

Closes #3280.

`vertex_target_for_location` interpolated the user-controlled `location`
path segment straight into the upstream **hostname** with no validation:

```python
return f"https://{location}-aiplatform.googleapis.com"
```

`location` comes from the Vertex route path
`/{api_version}/projects/{project}/locations/{location}/publishers/...`,
so it is fully attacker-controlled. A `location` of `169.254.169.254#`
(decoded from a percent-encoded `%23` in the path) produces:

```
https://169.254.169.254#-aiplatform.googleapis.com
```

which an HTTP client parses as host `169.254.169.254` with the remainder
treated as a URL fragment — a server-side request forgery (CWE-918) to
the cloud metadata endpoint. A `host:port` payload (`127.0.0.1:44919#`)
reaches an arbitrary internal port the same way. I confirmed the pre-fix
formula against the reported PoC:

```
'169.254.169.254#'  -> 'https://169.254.169.254#-aiplatform.googleapis.com'  host='169.254.169.254' port=None
'127.0.0.1:44919#'  -> 'https://127.0.0.1:44919#-aiplatform.googleapis.com'  host='127.0.0.1'      port=44919
```

The fix validates `location` against a strict GCP region shape
(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) before interpolation. Anything that is
not a well-formed region — including port, path, userinfo, and
fragment-delimiter payloads — falls back to the default public
`aiplatform.googleapis.com` endpoint, which can never resolve to an
attacker-chosen host. Real regions (`us-central1`, `europe-west4`, ...),
`global`, empty, and an explicitly configured gateway target are all
unaffected.

Root-cause input validation on the pure routing formula fully closes the
reported vector; it is the same place every Vertex route derives its
target from, so there is one choke point rather than a per-route guard.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/providers/vertex/runtime.py`: added `_VERTEX_REGION_RE`
(anchored `^[a-z0-9]+(?:-[a-z0-9]+)*$`) and `_VERTEX_GLOBAL_API_URL`.
`vertex_target_for_location` now falls back to the public endpoint for
any `location` that is empty, `global`, or not a well-formed region, and
only interpolates a validated region into the hostname.
- `tests/test_provider_vertex_runtime.py`: added a parametrized
region-acceptance test, a parametrized SSRF-payload test
(fragment/host:port/path/userinfo/underscore/uppercase/malformed-hyphen
— asserts the fallback endpoint **and** that the parsed host is
`aiplatform.googleapis.com` with no port), and a test that an explicit
gateway target is still returned verbatim.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality

### Test Output

```text
tests/test_provider_vertex_runtime.py tests/test_provider_proxy_targets.py tests/test_vertex_claude_compression.py  ->  39 passed
uvx ruff@0.16.2 check headroom/providers/vertex/runtime.py tests/test_provider_vertex_runtime.py  ->  All checks passed!
uvx mypy@1.20.2 headroom/providers/vertex/runtime.py  ->  Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.16.2 and mypy 1.20.2 via uvx.
- Exact command / steps: reproduced the SSRF against the pre-fix formula
(the two lines above, showing `urlsplit(...).hostname` = the injected
`169.254.169.254` / `127.0.0.1:44919`); applied the fix and re-ran,
confirming both fall back to `https://aiplatform.googleapis.com` with
host `aiplatform.googleapis.com`. Red/green: with the fix reverted (`git
stash`) the 11 SSRF-payload cases fail; restored, all 27 vertex-runtime
tests pass.
- Observed result: a malicious `location` can no longer place a host,
port, path, or fragment delimiter into the upstream hostname; legitimate
regions and configured gateways are unchanged.
- Not tested: a live end-to-end request against a real metadata endpoint
(would require a network SSRF target); the URL-construction root cause
is covered by unit tests, including host/port parsing of the constructed
URL.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — no feature flag or rollout channel
involved.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: only for malformed `location` values,
which previously produced a broken/attacker-controlled host and now
resolve to the public Vertex endpoint. Valid regions, `global`, empty,
and configured gateways are byte-for-byte unchanged.
- Kill switch / disable path: N/A (no config surface added).
- Unsafe override required: no.
- Qualification impact: none.
- Rollback path: revert this commit; `location` goes back to being
interpolated unvalidated (reintroducing the SSRF).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A: no
user-facing surface change for valid input)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`

## Additional Notes

The region allowlist is intentionally strict (lowercase alphanumeric
groups joined by single hyphens), matching the shape of every GCP Vertex
region; `global`/empty keep their existing public-endpoint behavior. If
a defense-in-depth `is_safe_upstream_url` check at the route layer is
also wanted (as the issue suggests), that can follow as a separate
change — this PR fixes the root cause at the single point where the
hostname is built.
This commit is contained in:
Abhay Singh 2026-08-27 15:02:32 +05:30 committed by GitHub
parent 8884d87378
commit 7c0b886004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 85 additions and 3 deletions

View file

@ -2,10 +2,22 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from headroom.providers.registry import DEFAULT_VERTEX_API_URL
# The public (multi-region) Vertex endpoint, used for ``global`` and for any
# location that is not a well-formed region.
_VERTEX_GLOBAL_API_URL = "https://aiplatform.googleapis.com"
# A GCP region label: lowercase alphanumeric groups joined by single hyphens
# (e.g. ``us-central1``, ``europe-west4``, ``asia-northeast1``). Anchored and
# deliberately strict — no dots, colons, slashes, ``#``, ``@``, uppercase, or
# empty groups — so a user-controlled ``location`` can never carry a host,
# port, path, or URL-fragment delimiter into the interpolated hostname.
_VERTEX_REGION_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
VERTEX_GOOGLE_PUBLISHER = "google"
VERTEX_ANTHROPIC_PUBLISHER = "anthropic"
VERTEX_GOOGLE_PROVIDER_NAME = "vertex:google"
@ -50,9 +62,24 @@ def vertex_anthropic_target(base_url: str, *, versionless_route: bool = False) -
def vertex_target_for_location(configured_target: str, location: str) -> str:
"""Return the Vertex upstream target for a request location."""
"""Return the Vertex upstream target for a request location.
``location`` is a user-controlled URL path segment that is interpolated into
the upstream hostname, so it must be validated against the GCP region shape
before use. Without that check a value such as ``169.254.169.254#`` (decoded
from a percent-encoded ``%23`` in the path) produces
``https://169.254.169.254#-aiplatform.googleapis.com``, which an HTTP client
parses as host ``169.254.169.254`` with the remainder treated as a URL
fragment a server-side request forgery to the cloud metadata endpoint
(CWE-918). Any ``location`` that is not a well-formed region (including port,
path, or fragment-delimiter payloads) falls back to the default public
endpoint, which can never resolve to an attacker-chosen host.
An explicitly configured gateway target still wins outright; region
derivation only applies when running against the default Vertex endpoint.
"""
if configured_target and configured_target != DEFAULT_VERTEX_API_URL:
return configured_target
if not location or location == "global":
return "https://aiplatform.googleapis.com"
if not location or location == "global" or not _VERTEX_REGION_RE.match(location):
return _VERTEX_GLOBAL_API_URL
return f"https://{location}-aiplatform.googleapis.com"

View file

@ -1,5 +1,9 @@
from __future__ import annotations
from urllib.parse import urlsplit
import pytest
from headroom.providers.registry import DEFAULT_VERTEX_API_URL
from headroom.providers.vertex import (
VERTEX_ANTHROPIC_PROVIDER_NAME,
@ -71,3 +75,54 @@ def test_vertex_target_for_location_honors_explicit_gateway() -> None:
assert vertex_target_for_location("https://vertex-gateway.internal", "europe-west1") == (
"https://vertex-gateway.internal"
)
_VERTEX_PUBLIC_ENDPOINT = "https://aiplatform.googleapis.com"
@pytest.mark.parametrize(
"region",
["us-central1", "europe-west4", "asia-northeast1", "me-central1", "us-east5"],
)
def test_vertex_target_for_location_accepts_real_regions(region: str) -> None:
assert vertex_target_for_location(DEFAULT_VERTEX_API_URL, region) == (
f"https://{region}-aiplatform.googleapis.com"
)
@pytest.mark.parametrize(
"malicious",
[
"169.254.169.254#", # fragment delimiter -> cloud metadata IP (the reported PoC)
"127.0.0.1:44919#", # host:port + fragment
"169.254.169.254/latest/meta-data/iam#", # path injection
"169.254.169.254:80", # port injection
"evil.example", # dotted host
"foo@evil.example", # userinfo delimiter
"us_central1", # underscore (not a region)
"US-CENTRAL1", # uppercase
"-leading", # leading hyphen
"trailing-", # trailing hyphen
"a--b", # empty hyphen group
],
)
def test_vertex_target_for_location_rejects_ssrf_payloads(malicious: str) -> None:
"""A non-region ``location`` must never carry an attacker-chosen host into
the interpolated Vertex hostname (CWE-918). It falls back to the public
endpoint, and the parsed host is always the legitimate Vertex host never
a metadata IP, loopback, or injected authority.
"""
target = vertex_target_for_location(DEFAULT_VERTEX_API_URL, malicious)
assert target == _VERTEX_PUBLIC_ENDPOINT
parsed = urlsplit(target)
assert parsed.hostname == "aiplatform.googleapis.com"
assert parsed.port is None
def test_vertex_target_for_location_ssrf_fallback_only_on_default_target() -> None:
"""A validated non-region value still cannot override an explicitly
configured gateway (that path returns the operator's target verbatim and
is not user-derived)."""
assert vertex_target_for_location("https://gw.internal", "169.254.169.254#") == (
"https://gw.internal"
)