Summary
The JWKS key fetching mechanism in uaa_jwt.erl does not validate the HTTP response status code when downloading signing keys from the OAuth2 provider's JWKS endpoint. Non-200 responses (including 4xx and 5xx errors) are processed identically to successful responses. When the JWKS endpoint returns an error response with a valid-JSON body that lacks a keys field, all previously cached signing keys are destroyed, causing a persistent authentication denial of service.
Vulnerability Details
Files:
deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt.erl, lines 50-63
deps/rabbitmq_auth_backend_oauth2/src/uaa_jwks.erl, lines 5-7
deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_provider.erl, lines 98-107
Bug 1: HTTP status code ignored (uaa_jwt.erl:50-63):
case uaa_jwks:get(JwksUrl, SslOptions) of
{ok, {_, _, JwksBody}} -> %% BUG: pattern ignores status code
KeyList = maps:get(<<"keys">>,
jose:decode(erlang:iolist_to_binary(JwksBody)), []),
Keys = maps:from_list(lists:map(fun(Key) ->
{maps:get(<<"kid">>, Key, undefined), {json, Key}} end, KeyList)),
case replace_signing_keys(Keys, Id) of
{error, _} = Err -> Err;
_ -> ok
end;
The Erlang httpc module returns {ok, {{HttpVersion, StatusCode, ReasonPhrase}, Headers, Body}}. The pattern {ok, {_, _, JwksBody}} matches ANY successful HTTP transaction regardless of status code — a 500 Internal Server Error is processed exactly like a 200 OK.
Bug 2: No HTTP response validation in JWKS client (uaa_jwks.erl:5-7):
get(JwksUrl, SslOptions) ->
Options = [{timeout, 60000}] ++ [{ssl, SslOptions}],
httpc:request(get, {JwksUrl, []}, Options, []).
The raw httpc response is returned directly with no status code validation. Additionally, httpc defaults to autoredirect = true, meaning HTTP redirects are silently followed.
Bug 3: Key replacement destroys cached keys (rabbit_oauth2_provider.erl:98-107):
do_replace_signing_keys(SigningKeys, root) ->
KeyConfig = get_env(key_config, []),
KeyConfig1 = proplists:delete(jwks, KeyConfig), %% deletes ALL cached JWKS keys
KeyConfig2 = [{jwks, maps:merge(
proplists:get_value(signing_keys, KeyConfig1, #{}),
SigningKeys)} | KeyConfig1],
The existing jwks entry (containing all previously-fetched dynamic keys) is deleted via proplists:delete(jwks, ...) and replaced with maps:merge(static_signing_keys, new_keys). If new_keys is empty (from an error response), all dynamic JWKS keys are lost.
Attack Scenario
Precondition
RabbitMQ is configured with OAuth2 authentication using JWKS key discovery (the standard setup for Keycloak, Auth0, Azure AD, etc.).
Attack Chain
- The JWKS endpoint is temporarily degraded (maintenance, overload, or attacker-initiated DoS on the IdP)
- The degraded endpoint returns HTTP 500 with JSON body:
{"error": "service unavailable"}
- Attacker sends a JWT to RabbitMQ with an unknown
kid header value
- RabbitMQ's
get_jwk/3 function doesn't find the key locally, triggers JWKS refresh (uaa_jwt.erl:120-127)
- JWKS fetch returns
{ok, {{"HTTP/1.1", 500, "Internal Server Error"}, _, "{\"error\":\"service unavailable\"}"}}
- Pattern
{ok, {_, _, JwksBody}} matches — status code 500 is ignored
jose:decode(...) returns #{<<"error">> => <<"service unavailable">>}
maps:get(<<"keys">>, ..., []) returns [] (no keys field in error response)
maps:from_list([]) returns #{} (empty map)
replace_signing_keys(#{}, Id) deletes all cached JWKS keys and stores only static keys (usually none)
- Result: All subsequent JWT authentication fails — legitimate tokens are rejected because their signing keys no longer exist in the cache
Key Insight: Attacker-Triggered Refresh
The JWKS refresh is triggered automatically when a JWT arrives with an unknown kid (uaa_jwt.erl:120). An attacker needs only:
- Craft a JWT with a non-existent
kid header
- Send it as the password in an AMQP SASL PLAIN authentication
- The server automatically attempts JWKS refresh
By repeatedly sending JWTs with unknown kid values, the attacker can trigger continuous JWKS refreshes. If ANY of these refreshes occur while the JWKS endpoint is degraded, all cached keys are destroyed.
Impact
- Persistent authentication DoS: Once keys are destroyed, ALL OAuth2/JWT authentication fails for all users until a new successful JWKS refresh occurs
- Amplification: A single attacker can deny access to all legitimate OAuth2 users across the entire RabbitMQ cluster
- Cascade risk: During IdP maintenance windows, a single unauthenticated request with an unknown
kid triggers key destruction
- Recovery delay: Keys remain destroyed until another JWT with an unknown
kid arrives (triggering another refresh) AND the JWKS endpoint has recovered
Steps to Reproduce
- Configure RabbitMQ with OAuth2 authentication (JWKS-based key discovery)
- Verify JWT authentication works normally
- Simulate JWKS endpoint degradation (return HTTP 500 with body
{"error":"down"})
- Send AMQP connection with SASL PLAIN auth, username=
unused, password=eyJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2V5LWlkIn0.eyJzdWIiOiJ0ZXN0In0.fake (a JWT with unknown kid)
- Check RabbitMQ logs: "Downloaded 0 signing keys"
- Attempt legitimate JWT authentication — it fails (signing key not found)
Suggested Fix
Fix 1: Validate HTTP status code in uaa_jwt.erl:
case uaa_jwks:get(JwksUrl, SslOptions) of
{ok, {{_, 200, _}, _, JwksBody}} ->
%% Only process 200 OK responses
KeyList = maps:get(<<"keys">>,
jose:decode(erlang:iolist_to_binary(JwksBody)), []),
...;
{ok, {{_, StatusCode, _}, _, _}} ->
{error, {unexpected_status_code, StatusCode}};
{error, _} = Err -> ...
end.
Fix 2: Disable auto-redirect in JWKS fetch (uaa_jwks.erl):
Options = [{timeout, 60000}, {autoredirect, false}] ++ [{ssl, SslOptions}],
Fix 3: Don't replace keys with empty set (uaa_jwt.erl):
Keys = maps:from_list(...),
case maps:size(Keys) of
0 -> {error, empty_jwks_response};
_ -> replace_signing_keys(Keys, Id)
end
References
- CWE-252: Unchecked Return Value
- CWE-754: Improper Check for Unusual or Exceptional Conditions
- RFC 7517: JSON Web Key (JWK) — JWKS endpoint should return proper HTTP status codes
- Commit tested: a155ee5
Summary
The JWKS key fetching mechanism in
uaa_jwt.erldoes not validate the HTTP response status code when downloading signing keys from the OAuth2 provider's JWKS endpoint. Non-200 responses (including 4xx and 5xx errors) are processed identically to successful responses. When the JWKS endpoint returns an error response with a valid-JSON body that lacks akeysfield, all previously cached signing keys are destroyed, causing a persistent authentication denial of service.Vulnerability Details
Files:
deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt.erl, lines 50-63deps/rabbitmq_auth_backend_oauth2/src/uaa_jwks.erl, lines 5-7deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_provider.erl, lines 98-107Bug 1: HTTP status code ignored (
uaa_jwt.erl:50-63):The Erlang
httpcmodule returns{ok, {{HttpVersion, StatusCode, ReasonPhrase}, Headers, Body}}. The pattern{ok, {_, _, JwksBody}}matches ANY successful HTTP transaction regardless of status code — a 500 Internal Server Error is processed exactly like a 200 OK.Bug 2: No HTTP response validation in JWKS client (
uaa_jwks.erl:5-7):The raw httpc response is returned directly with no status code validation. Additionally,
httpcdefaults toautoredirect = true, meaning HTTP redirects are silently followed.Bug 3: Key replacement destroys cached keys (
rabbit_oauth2_provider.erl:98-107):The existing
jwksentry (containing all previously-fetched dynamic keys) is deleted viaproplists:delete(jwks, ...)and replaced withmaps:merge(static_signing_keys, new_keys). Ifnew_keysis empty (from an error response), all dynamic JWKS keys are lost.Attack Scenario
Precondition
RabbitMQ is configured with OAuth2 authentication using JWKS key discovery (the standard setup for Keycloak, Auth0, Azure AD, etc.).
Attack Chain
{"error": "service unavailable"}kidheader valueget_jwk/3function doesn't find the key locally, triggers JWKS refresh (uaa_jwt.erl:120-127){ok, {{"HTTP/1.1", 500, "Internal Server Error"}, _, "{\"error\":\"service unavailable\"}"}}{ok, {_, _, JwksBody}}matches — status code 500 is ignoredjose:decode(...)returns#{<<"error">> => <<"service unavailable">>}maps:get(<<"keys">>, ..., [])returns[](nokeysfield in error response)maps:from_list([])returns#{}(empty map)replace_signing_keys(#{}, Id)deletes all cached JWKS keys and stores only static keys (usually none)Key Insight: Attacker-Triggered Refresh
The JWKS refresh is triggered automatically when a JWT arrives with an unknown
kid(uaa_jwt.erl:120). An attacker needs only:kidheaderBy repeatedly sending JWTs with unknown
kidvalues, the attacker can trigger continuous JWKS refreshes. If ANY of these refreshes occur while the JWKS endpoint is degraded, all cached keys are destroyed.Impact
kidtriggers key destructionkidarrives (triggering another refresh) AND the JWKS endpoint has recoveredSteps to Reproduce
{"error":"down"})unused, password=eyJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2V5LWlkIn0.eyJzdWIiOiJ0ZXN0In0.fake(a JWT with unknown kid)Suggested Fix
Fix 1: Validate HTTP status code in
uaa_jwt.erl:Fix 2: Disable auto-redirect in JWKS fetch (
uaa_jwks.erl):Fix 3: Don't replace keys with empty set (
uaa_jwt.erl):References