mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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.
128 lines
4.7 KiB
Python
128 lines
4.7 KiB
Python
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,
|
|
VERTEX_COUNT_TOKENS,
|
|
VERTEX_GENERATE_CONTENT,
|
|
VERTEX_GOOGLE_PROVIDER_NAME,
|
|
VERTEX_RAW_PREDICT,
|
|
VERTEX_STREAM_GENERATE_CONTENT,
|
|
VERTEX_STREAM_RAW_PREDICT,
|
|
VertexPublisherAction,
|
|
is_vertex_anthropic_publisher,
|
|
is_vertex_google_publisher,
|
|
vertex_anthropic_target,
|
|
vertex_publisher_provider_name,
|
|
vertex_target_for_location,
|
|
)
|
|
|
|
|
|
def test_vertex_publisher_classification_is_explicit() -> None:
|
|
assert is_vertex_google_publisher("google") is True
|
|
assert is_vertex_google_publisher("anthropic") is False
|
|
assert is_vertex_anthropic_publisher("anthropic") is True
|
|
assert is_vertex_anthropic_publisher("google") is False
|
|
|
|
|
|
def test_vertex_provider_names_are_provider_owned() -> None:
|
|
assert VERTEX_GOOGLE_PROVIDER_NAME == "vertex:google"
|
|
assert VERTEX_ANTHROPIC_PROVIDER_NAME == "vertex:anthropic"
|
|
assert vertex_publisher_provider_name("mistral") == "vertex:mistral"
|
|
|
|
|
|
def test_vertex_publisher_actions_are_named_values() -> None:
|
|
assert VERTEX_GENERATE_CONTENT == VertexPublisherAction("generateContent")
|
|
assert VERTEX_STREAM_GENERATE_CONTENT == VertexPublisherAction("streamGenerateContent")
|
|
assert VERTEX_COUNT_TOKENS == VertexPublisherAction("countTokens")
|
|
assert VERTEX_RAW_PREDICT == VertexPublisherAction("rawPredict")
|
|
assert VERTEX_STREAM_RAW_PREDICT == VertexPublisherAction(
|
|
"streamRawPredict",
|
|
force_stream=True,
|
|
)
|
|
|
|
|
|
def test_vertex_anthropic_target_adds_v1_only_for_versionless_routes() -> None:
|
|
assert vertex_anthropic_target("https://europe-west1-aiplatform.googleapis.com") == (
|
|
"https://europe-west1-aiplatform.googleapis.com"
|
|
)
|
|
assert (
|
|
vertex_anthropic_target(
|
|
"https://europe-west1-aiplatform.googleapis.com/",
|
|
versionless_route=True,
|
|
)
|
|
== "https://europe-west1-aiplatform.googleapis.com/v1"
|
|
)
|
|
|
|
|
|
def test_vertex_target_for_location_derives_regional_hosts_from_default_target() -> None:
|
|
assert vertex_target_for_location(DEFAULT_VERTEX_API_URL, "europe-west1") == (
|
|
"https://europe-west1-aiplatform.googleapis.com"
|
|
)
|
|
assert vertex_target_for_location(DEFAULT_VERTEX_API_URL, "global") == (
|
|
"https://aiplatform.googleapis.com"
|
|
)
|
|
assert vertex_target_for_location(DEFAULT_VERTEX_API_URL, "") == (
|
|
"https://aiplatform.googleapis.com"
|
|
)
|
|
|
|
|
|
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"
|
|
)
|