VYPR
Medium severity6.5GHSA Advisory· Published May 19, 2026· Updated May 19, 2026

Coder: Unauthenticated SSRF via Azure Instance Identity Endpoint

CVE-2026-45796

Description

Summary

Unauthenticated semi-blind Server-Side Request Forgery (SSRF) via the Azure instance identity endpoint (POST /api/v2/workspaceagents/azure-instance-identity). An external attacker can force the Coder server to issue HTTP GET requests to arbitrary internal or external hosts by submitting a crafted PKCS#7 signature. The server does not return the target's response body, but error messages in the API response reveal whether the target is reachable and what type of failure occurred.

Details

The POST /api/v2/workspaceagents/azure-instance-identity endpoint accepts a PKCS#7 signature without authentication. During certificate chain verification, `azureidentity.Validate()` iterates over the signer certificate's IssuingCertificateURL extension and fetches each URL using http.DefaultClient with no host restriction, no private-IP blocking, and no response-size limit.

An attacker crafts a self-signed certificate whose Common Name matches *.metadata.azure.com (passing the allowedSigners regex) and whose IssuingCertificateURL points to an attacker-chosen target. The server fetches that URL and feeds the response body into x509.ParseCertificate. The parsed result is discarded, but the wrapped error string is returned verbatim in the JSON response via Detail: err.Error(). Connection-level errors ("connection refused", "i/o timeout", DNS failures) and certificate-parse errors give the attacker enough signal to infer host reachability and port state without seeing the actual response content.

Root causes:

  1. No allowlist on IssuingCertificateURL hosts. Any URL was accepted.
  2. http.DefaultClient was used. It follows redirects and connects to private, link-local, and loopback addresses.
  3. Unbounded io.ReadAll on the response body (memory exhaustion vector).
  4. Raw err.Error() was returned in the JSON response, leaking internal HTTP client errors to the caller.

Impact

This is a semi-blind SSRF: the server makes the outbound request but the HTTP response body is consumed by x509.ParseCertificate and never returned to the attacker.

  • Internal network reconnaissance. The attacker can map internal hosts and ports by observing error differentiation in the API response: "connection refused" (port closed), "i/o timeout" (host unreachable or firewalled), DNS failure (host does not exist), or certificate-parse error (port open and responding). This enables systematic scanning of the internal network from the Coder server's vantage point.
  • Requests to sensitive endpoints. The server can be directed to hit cloud metadata services (e.g. http://169.254.169.254/), internal admin interfaces, or other services. The attacker cannot read the response content, but the request itself may have side effects depending on the target.
  • Error-based information disclosure. Wrapped Go HTTP client errors in the Detail field expose internal hostnames, IP addresses, port numbers, and network topology details.
  • Memory exhaustion. The unbounded io.ReadAll on the response body allows an attacker to point IssuingCertificateURL at a large resource, forcing the server to buffer it entirely in memory.

Patches

Fixed in #25274 (commit `57b11d405`):

The fix was backported to all supported release lines:

| Release line | Patched version | |---|---| | 2.33 | v2.33.3 | | 2.32 | v2.32.2 | | 2.31 | v2.31.12 | | 2.30 | v2.30.8 | | 2.29 | v2.29.13 | | 2.24 (ESR) | v2.24.5 |

Workarounds

If the Azure identity-auth mechanism is not being used then restrict access to the corresponding endpoint (/api/v2/workspaceagents/azure-instance-identity) using ingress firewall and/or proxy ACLs.

Recognition

We'd like to thank Ben Tran of calif.io and Anthropic's Security Team (ANT-2026-22447) for independently disclosing this issue!

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Unauthenticated semi-blind SSRF in Coder's Azure identity endpoint lets attackers probe internal hosts by submitting a crafted PKCS#7 signature.

Vulnerability

The POST /api/v2/workspaceagents/azure-instance-identity endpoint accepts a PKCS#7 signature without authentication. During certificate chain verification, azureidentity.Validate() iterates over the signer certificate's IssuingCertificateURL extension and fetches each URL using http.DefaultClient with no host restriction, no private-IP blocking, and no response-size limit [1][3][4]. This allows an attacker to craft a self-signed certificate whose Common Name matches *.metadata.azure.com and whose IssuingCertificateURL points to an arbitrary host.

Exploitation

The server fetches the attacker-chosen URL and feeds the response body into x509.ParseCertificate. The parsed result is discarded, but the wrapped error string is returned verbatim in the JSON response via Detail: err.Error() [3][4]. Connection-level errors (e.g., "connection refused", "i/o timeout") and certificate-parse errors reveal whether the target is reachable and what type of failure occurred, enabling semi-blind network probing.

Impact

This is a semi-blind SSRF: the server makes outbound requests but the response body is never returned. However, error messages leak reachability information, and the unbounded io.ReadAll on the response body creates a memory exhaustion vector [1][3][4]. An attacker can scan internal networks, including private and link-local addresses, without authentication.

Mitigation

The issue is fixed in commit 57b11d4 [1] and released in Coder v2.24.5 [2]. The fix adds an allowed host list for certificate URLs, blocks private and special-use IP ranges, limits response body size to 1 MiB, and sanitizes error messages to prevent information leakage [1][2][3][4].

AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Coder/CoderGHSA2 versions
    <= 0.27.3+ 1 more
    • (no CPE)range: <= 0.27.3
    • (no CPE)range: <2.33.3 || <2.32.2 || <2.31.12 || <2.30.8 || <2.29.13 || <2.24.5

Patches

1
57b11d405f17

fix(coderd): harden Azure identity certificate fetch (#25274)

https://github.com/coder/coderJakub DomerackiMay 13, 2026via ghsa
4 files changed · +296 10
  • coderd/azureidentity/azureidentity.go+176 9 modified
    @@ -8,7 +8,9 @@ import (
     	"encoding/pem"
     	"errors"
     	"io"
    +	"net"
     	"net/http"
    +	"net/url"
     	"regexp"
     	"sync"
     	"time"
    @@ -25,6 +27,158 @@ var allowedSigners = regexp.MustCompile(`^(.*\.)?metadata\.(azure\.(com|us|cn)|m
     // each time a parse occurs.
     var pkcs7Mutex sync.Mutex
     
    +// allowedCertHosts contains the hosts Azure intermediate
    +// certificates are served from. Only these hosts are permitted
    +// when fetching issuing certificates referenced in the signer
    +// certificate. This prevents SSRF via crafted
    +// IssuingCertificateURL values.
    +//
    +// Source: https://learn.microsoft.com/en-us/azure/security/fundamentals/azure-ca-details
    +var allowedCertHosts = map[string]bool{
    +	"www.microsoft.com":    true,
    +	"cacerts.digicert.com": true,
    +}
    +
    +// maxCertResponseBytes is the maximum size of a certificate
    +// response body we will read. Azure intermediate certificates
    +// are typically under 4 KiB; 1 MiB is a generous upper bound
    +// that prevents memory exhaustion from malicious responses.
    +const maxCertResponseBytes = 1 << 20 // 1 MiB
    +
    +// extraBlockedNetworks lists special-use CIDR ranges that the
    +// stdlib classification methods (IsLoopback, IsPrivate, etc.) do
    +// not cover. Blocking these prevents SSRF against carrier-grade
    +// NAT, network-benchmarking, documentation, discard-only, and
    +// the all-zeros "this network" range.
    +//
    +// IPv6 ranges already handled by stdlib:
    +//   - ::1/128        (IsLoopback)
    +//   - fc00::/7       (IsPrivate, ULA)
    +//   - fe80::/10      (IsLinkLocalUnicast)
    +//   - ff00::/8       (IsMulticast)
    +//   - ::/128         (IsUnspecified)
    +var extraBlockedNetworks []*net.IPNet
    +
    +func init() {
    +	for _, cidr := range []string{
    +		// IPv4 special-use ranges.
    +		"0.0.0.0/8",     // RFC 1122 "this network".
    +		"100.64.0.0/10", // RFC 6598 carrier-grade NAT.
    +		"198.18.0.0/15", // RFC 2544 benchmarking.
    +
    +		// IPv6 special-use ranges not covered by stdlib.
    +		"64:ff9b:1::/48", // RFC 8215 IPv4/IPv6 translation.
    +		"100::/64",       // RFC 6666 discard-only.
    +		"2001:2::/48",    // RFC 5180 benchmarking.
    +		"2001:db8::/32",  // RFC 3849 documentation.
    +	} {
    +		_, network, _ := net.ParseCIDR(cidr)
    +		extraBlockedNetworks = append(extraBlockedNetworks, network)
    +	}
    +}
    +
    +// isPrivateIP reports whether the IP is on a network that must
    +// not be reachable when fetching certificates. IPv4-mapped IPv6
    +// addresses are canonicalized to IPv4 first so a literal like
    +// ::ffff:169.254.169.254 cannot bypass the IPv4 ranges.
    +func isPrivateIP(ip net.IP) bool {
    +	if v4 := ip.To4(); v4 != nil {
    +		ip = v4
    +	}
    +	if ip.IsLoopback() ||
    +		ip.IsPrivate() ||
    +		ip.IsLinkLocalUnicast() ||
    +		ip.IsLinkLocalMulticast() ||
    +		ip.IsMulticast() ||
    +		ip.IsUnspecified() ||
    +		ip.IsInterfaceLocalMulticast() {
    +		return true
    +	}
    +	for _, network := range extraBlockedNetworks {
    +		if network.Contains(ip) {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
    +// certFetchClient is an HTTP client that refuses to connect
    +// to private or link-local IP addresses. This provides
    +// defense-in-depth against SSRF even if the host allowlist is
    +// somehow bypassed (e.g. via DNS rebinding).
    +var certFetchClient = &http.Client{
    +	Timeout: 5 * time.Second,
    +	Transport: &http.Transport{
    +		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
    +			host, port, err := net.SplitHostPort(addr)
    +			if err != nil {
    +				return nil, xerrors.Errorf("split host/port: %w", err)
    +			}
    +			ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
    +			if err != nil {
    +				return nil, xerrors.Errorf("resolve host: %w", err)
    +			}
    +			if len(ips) == 0 {
    +				return nil, xerrors.Errorf("no addresses for %q", host)
    +			}
    +			// Reject up front so a single tainted answer
    +			// short-circuits the dial rather than racing it.
    +			for _, ip := range ips {
    +				if isPrivateIP(ip.IP) {
    +					return nil, xerrors.Errorf(
    +						"certificate fetch blocked: %q resolved to private IP %s",
    +						host, ip.IP,
    +					)
    +				}
    +			}
    +			// Dial the validated IP directly. If we dialed by
    +			// hostname here, Go's stdlib would re-resolve and a
    +			// hostile resolver could swap in a private IP after
    +			// validation (DNS rebinding). TLS verification still
    +			// uses the URL host via the Transport's TLS config.
    +			var d net.Dialer
    +			var firstErr error
    +			for _, ip := range ips {
    +				conn, derr := d.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
    +				if derr == nil {
    +					return conn, nil
    +				}
    +				if firstErr == nil {
    +					firstErr = derr
    +				}
    +			}
    +			return nil, firstErr
    +		},
    +	},
    +}
    +
    +// IsAllowedCertificateURL reports whether rawURL points to a
    +// host on the allowlist, uses http or https, and targets a
    +// standard PKI distribution port. Microsoft and DigiCert serve
    +// these artifacts on 80/443 only; any other port is rejected to
    +// keep the SSRF surface as narrow as the hostname itself.
    +func IsAllowedCertificateURL(rawURL string) bool {
    +	if rawURL == "" {
    +		return false
    +	}
    +	u, err := url.Parse(rawURL)
    +	if err != nil {
    +		return false
    +	}
    +	if u.Scheme != "http" && u.Scheme != "https" {
    +		return false
    +	}
    +	if !allowedCertHosts[u.Hostname()] {
    +		return false
    +	}
    +	switch u.Port() {
    +	case "", "80", "443":
    +		return true
    +	default:
    +		return false
    +	}
    +}
    +
     type metadata struct {
     	VMID string `json:"vmId"`
     }
    @@ -95,29 +249,42 @@ func Validate(ctx context.Context, signature string, options Options) (string, e
     		ctx, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
     		defer cancelFunc()
     		for _, certURL := range signer.IssuingCertificateURL {
    +			if !IsAllowedCertificateURL(certURL) {
    +				return "", xerrors.New("issuing certificate URL not on allowlist")
    +			}
     			req, err := http.NewRequestWithContext(ctx, "GET", certURL, nil)
     			if err != nil {
    -				return "", xerrors.Errorf("new request %q: %w", certURL, err)
    +				return "", xerrors.New("construct certificate request")
     			}
    -			res, err := http.DefaultClient.Do(req)
    +			res, err := certFetchClient.Do(req)
     			if err != nil {
    -				return "", xerrors.Errorf("no cached certificate for %q found. error fetching: %w", certURL, err)
    +				return "", xerrors.New("certificate fetch unsuccessful")
     			}
    -			data, err := io.ReadAll(res.Body)
    +			limited := io.LimitReader(res.Body, maxCertResponseBytes+1)
    +			data, err := io.ReadAll(limited)
    +			_ = res.Body.Close()
     			if err != nil {
    -				_ = res.Body.Close()
    -				return "", xerrors.Errorf("read body %q: %w", certURL, err)
    +				return "", xerrors.New("read certificate response body")
    +			}
    +			if int64(len(data)) > maxCertResponseBytes {
    +				return "", xerrors.New(
    +					"certificate response exceeds maximum size",
    +				)
     			}
    -			_ = res.Body.Close()
     			cert, err := x509.ParseCertificate(data)
     			if err != nil {
    -				return "", xerrors.Errorf("parse certificate %q: %w", certURL, err)
    +				// Do not wrap the parse error; it may contain
    +				// fragments of the HTTP response body, which
    +				// could leak internal data to the caller.
    +				return "", xerrors.New(
    +					"fetched data is not a valid certificate",
    +				)
     			}
     			options.Intermediates.AddCert(cert)
     		}
     		_, err = signer.Verify(options.VerifyOptions)
     		if err != nil {
    -			return "", err
    +			return "", xerrors.New("signature verification failed after fetching issuing certificates")
     		}
     	}
     
    
  • coderd/azureidentity/azureidentity_internal_test.go+76 0 added
    @@ -0,0 +1,76 @@
    +package azureidentity
    +
    +import (
    +	"context"
    +	"net"
    +	"net/http"
    +	"net/http/httptest"
    +	"testing"
    +
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestIsPrivateIP(t *testing.T) {
    +	t.Parallel()
    +	cases := []struct {
    +		name    string
    +		ip      string
    +		blocked bool
    +	}{
    +		{"loopback v4", "127.0.0.1", true},
    +		{"loopback v6", "::1", true},
    +		{"link local v4 (azure metadata)", "169.254.169.254", true},
    +		{"link local v6", "fe80::1", true},
    +		{"rfc1918 10/8", "10.0.0.1", true},
    +		{"rfc1918 172.16/12", "172.16.0.1", true},
    +		{"rfc1918 192.168/16", "192.168.0.1", true},
    +		{"ipv6 ula", "fc00::1", true},
    +		{"unspecified v4", "0.0.0.0", true},
    +		{"unspecified v6", "::", true},
    +		{"this-network 0.0.0.0/8", "0.1.2.3", true},
    +		{"cgnat 100.64/10", "100.64.0.1", true},
    +		{"benchmarking 198.18/15", "198.18.0.1", true},
    +		{"multicast v4", "224.0.0.1", true},
    +		{"ipv6 nat64 well-known", "64:ff9b:1::1", true},
    +		{"ipv6 discard-only", "100::1", true},
    +		{"ipv6 benchmarking", "2001:2::1", true},
    +		{"ipv6 documentation", "2001:db8::1", true},
    +		// IPv4-mapped IPv6: must canonicalize to v4 before
    +		// classification, otherwise an attacker could bypass
    +		// the metadata block via ::ffff:169.254.169.254.
    +		{"ipv4-mapped metadata", "::ffff:169.254.169.254", true},
    +		{"ipv4-mapped rfc1918", "::ffff:10.0.0.1", true},
    +
    +		{"public v4", "8.8.8.8", false},
    +		{"public v6", "2606:4700:4700::1111", false},
    +	}
    +	for _, tc := range cases {
    +		t.Run(tc.name, func(t *testing.T) {
    +			t.Parallel()
    +			ip := net.ParseIP(tc.ip)
    +			require.NotNil(t, ip, "parse %q", tc.ip)
    +			require.Equal(t, tc.blocked, isPrivateIP(ip))
    +		})
    +	}
    +}
    +
    +// TestCertFetchClientRejectsLoopback proves the dialer refuses
    +// to connect even when the URL itself would have passed an
    +// allowlist (httptest.Server always binds to 127.0.0.1, so a
    +// successful fetch here would mean the SSRF guard had failed).
    +func TestCertFetchClientRejectsLoopback(t *testing.T) {
    +	t.Parallel()
    +	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
    +		_, _ = w.Write([]byte("should never be reached"))
    +	}))
    +	t.Cleanup(srv.Close)
    +
    +	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil)
    +	require.NoError(t, err)
    +	resp, err := certFetchClient.Do(req)
    +	if resp != nil {
    +		defer resp.Body.Close()
    +	}
    +	require.Error(t, err)
    +	require.Contains(t, err.Error(), "private IP")
    +}
    
  • coderd/azureidentity/azureidentity_test.go+34 0 modified
    @@ -116,3 +116,37 @@ func TestExpiresSoon(t *testing.T) {
     		}
     	}
     }
    +
    +func TestIsAllowedCertificateURL(t *testing.T) {
    +	t.Parallel()
    +	tests := []struct {
    +		name    string
    +		url     string
    +		allowed bool
    +	}{
    +		{"microsoft http", "http://www.microsoft.com/pki/mscorp/cert.crt", true},
    +		{"microsoft https", "https://www.microsoft.com/pkiops/certs/cert.crt", true},
    +		{"digicert http", "http://cacerts.digicert.com/DigiCertGlobalRootG2.crt", true},
    +		{"digicert https", "https://cacerts.digicert.com/DigiCertGlobalRootG3.crt", true},
    +		{"evil domain", "http://evil.example.com/cert.crt", false},
    +		{"metadata endpoint", "http://169.254.169.254/latest/meta-data/", false},
    +		{"localhost", "http://localhost/secret", false},
    +		{"subdomain trick", "http://www.microsoft.com.evil.com/cert.crt", false},
    +		{"empty string", "", false},
    +		{"ftp scheme", "ftp://www.microsoft.com/cert.crt", false},
    +		{"no scheme", "www.microsoft.com/cert.crt", false},
    +		{"javascript scheme", "javascript:alert(1)", false},
    +		{"microsoft with path", "http://www.microsoft.com/pkiops/certs/cert.crt", true},
    +		{"microsoft explicit port 80", "http://www.microsoft.com:80/cert.crt", true},
    +		{"microsoft explicit port 443", "https://www.microsoft.com:443/cert.crt", true},
    +		{"microsoft non-standard port", "http://www.microsoft.com:8080/cert.crt", false},
    +		{"microsoft port 22", "http://www.microsoft.com:22/cert.crt", false},
    +	}
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			t.Parallel()
    +			result := azureidentity.IsAllowedCertificateURL(tc.url)
    +			require.Equal(t, tc.allowed, result, "URL: %s", tc.url)
    +		})
    +	}
    +}
    
  • coderd/workspaceresourceauth.go+10 1 modified
    @@ -8,6 +8,7 @@ import (
     
     	"github.com/mitchellh/mapstructure"
     
    +	"cdr.dev/slog/v3"
     	"github.com/coder/coder/v2/coderd/awsidentity"
     	"github.com/coder/coder/v2/coderd/azureidentity"
     	"github.com/coder/coder/v2/coderd/database/dbauthz"
    @@ -38,9 +39,17 @@ func (api *API) postWorkspaceAuthAzureInstanceIdentity(rw http.ResponseWriter, r
     		VerifyOptions: api.AzureCertificates,
     	})
     	if err != nil {
    +		// Log the full error for operators but return only a
    +		// generic message to the caller. Errors from the
    +		// certificate fetch path may contain fragments of
    +		// internal HTTP responses, so exposing them would be
    +		// an information disclosure risk.
    +		api.Logger.Warn(ctx, "azure identity validation failed",
    +			slog.Error(err),
    +		)
     		httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
     			Message: "Invalid Azure identity.",
    -			Detail:  err.Error(),
    +			Detail:  "Signature verification failed.",
     		})
     		return
     	}
    

Vulnerability mechanics

Root cause

"Missing host allowlist and private-IP blocking on the IssuingCertificateURL fetch in Azure identity certificate chain validation allows an attacker to force the server to make HTTP GET requests to arbitrary hosts."

Attack vector

An unauthenticated attacker sends a POST request to `/api/v2/workspaceagents/azure-instance-identity` with a crafted PKCS#7 signature. The signature contains a self-signed certificate whose Common Name matches the `allowedSigners` regex (`*.metadata.azure.com`) and whose `IssuingCertificateURL` extension points to an attacker-chosen target host. The server's `Validate()` function [patch_id=646461] iterates over these URLs and fetches each one using `http.DefaultClient` with no host restriction, no private-IP blocking, and no response-size limit. The response body is fed to `x509.ParseCertificate` and discarded, but the wrapped error string is returned verbatim in the JSON `Detail` field. Connection-level errors ("connection refused", "i/o timeout", DNS failures) and certificate-parse errors allow the attacker to infer host reachability and port state without seeing the actual response content.

Affected code

The vulnerability is in `coderd/azureidentity/azureidentity.go` in the `Validate()` function, which iterates over `signer.IssuingCertificateURL` and fetches each URL using `http.DefaultClient` with no host restriction, private-IP blocking, or response-size limit. The error is returned verbatim in `coderd/workspaceresourceauth.go` via `Detail: err.Error()`.

What the fix does

The patch [patch_id=646461] introduces a host allowlist (`allowedCertHosts`) restricted to `www.microsoft.com` and `cacerts.digicert.com` on ports 80/443 only, enforced by `IsAllowedCertificateURL()`. A custom `certFetchClient` with a `DialContext` function resolves the host once, validates that none of the resolved IPs are private or link-local (via `isPrivateIP()`), and dials the validated IP directly to prevent DNS rebinding. The response body is capped at 1 MiB via `io.LimitReader`. All error messages are replaced with generic strings (e.g., "certificate fetch unsuccessful") and the real error is logged server-side via `slog`, preventing information disclosure through the API response.

Preconditions

  • networkAttacker must be able to send HTTP POST requests to the Coder server's `/api/v2/workspaceagents/azure-instance-identity` endpoint.
  • inputAttacker must craft a PKCS#7 signature containing a self-signed certificate whose Common Name matches `*.metadata.azure.com` and whose IssuingCertificateURL points to the desired target.

Generated on May 19, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

10

News mentions

0

No linked articles in our index yet.