Authorization bypass / Access control

MEDIUM
victoriametrics/victoriametrics
Commit: ebb0b5cb87f2
Affected: <= 1.139.0
2026-07-03 14:30 UTC

Description

The patch changes how JWTs without a vm_access claim are handled by the vmauth request path. Previously, if a token had no vm_access claim and there was no DefaultVMAccessClaim configured, the request was rejected with 401 Unauthorized immediately. The change makes the JWT handling align with other authentication paths by deferring to the unauthorized_user flow in this case, allowing requests to be handled by the unauthorized_user backend if configured. This corrects an inconsistent authorization flow and reduces the risk of misrouted or prematurely rejected requests due to missing vm_access claims. However, in misconfigured environments, forwarding unauthenticated requests to unauthorized_user could expose backends that rely on additional claim checks. Overall, this is a security-relevant fix to access-control logic rather than a benign code cleanup.

Proof of Concept

PoC steps to reproduce (conceptual, requires a test/development setup): Prerequisites: - VictoriaMetrics vmauth configured with an unauthorized_user backend, e.g.: - unauthorized_user: url_prefix: http://BACKEND/bar - A backend service listening on BACKEND/bar that responds 200 OK to requests for /abc - A public key used to validate JWTs, and a token configured to be signed with the corresponding private key - A JWT token that does NOT contain the vm_access claim (and does not rely on a default vm_access claim in the config) 1) Start vmauth with a configuration that includes an unauthorized_user path (as in the test changes) so that requests can be forwarded to the BACKEND/bar backend when there is no vm_access claim in the JWT. 2) Generate a JWT that is valid (signature checks out) but intentionally omits the vm_access claim. Example payload (conceptual): {"iss":"test","sub":"tester"} (sign with the private key corresponding to the public key configured in vmauth) Save as token.jwt 3) Send an authenticated request to vmauth, using the token without vm_access claim: curl -sS \ -H "Authorization: Bearer <token_without_vm_access>" \ http://127.0.0.1:8428/vmauth/some/resource 4) Expected outcome with the patched behavior: - Since the token lacks vm_access and there is no DefaultVMAccessClaim configured, vmauth will fall through to the unauthorized_user path, forwarding to the BACKEND/bar backend. If that backend is configured to allow the operation, you should see a 200 OK response from the backend (mirrored by vmauth). 5) For contrast, in a pre-patch environment (or by temporarily simulating the old flow), the same request would typically yield HTTP 401 Unauthorized from vmauth before hitting the unauthorized_user backend. Notes: - The actual token generation requires the proper RSA/ECDSA key material used by vmauth for JWT validation. The payload here is illustrative; you must sign with the corresponding private key and validate against the configured public_keys in vmauth. - This PoC demonstrates the authorization path behavior difference caused by the patch, not a universal vulnerability that applies to all deployments. The impact depends on how the unauthorized_user backend is configured and what access it exposes.

Commit Details

Author: Max Kotliar

Date: 2026-07-03 11:57 UTC

Message:

app/vmauth: fall through to unauthorized_user when JWT token has no vm_access claim (#11210) Previously, if a JWT token had no vm_access claim and no default vm_access claim was configured, vmauth returned 401 immediately. Other authentication paths fall through to the unauthorized_user section in this case, so requests can be handled by it if configured. This commit aligns JWT handling with the same pattern. Also, move `defer putToken(tkn)` earlier so the token is actually reused. Related to: - https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5740, - https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7543 PR https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11210

Triage Assessment

Vulnerability Type: Authorization bypass / Access control

Confidence: MEDIUM

Reasoning:

The change alters how JWTs without vm_access claims are handled: instead of returning 401 immediately, it now falls through to the configured unauthorized_user path. This aligns with other authentication paths and prevents inconsistent authorization behavior. It mitigates a potential authorization path issue where certain requests could be mishandled or prematurely rejected, effectively fixing an access-control handling inconsistency.

Verification Assessment

Vulnerability Type: Authorization bypass / Access control

Confidence: MEDIUM

Affected Versions: <= 1.139.0

Code Diff

diff --git a/app/vmauth/main.go b/app/vmauth/main.go index a020110b92b57..4c09231b98bd8 100644 --- a/app/vmauth/main.go +++ b/app/vmauth/main.go @@ -193,14 +193,13 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool { if tkn == nil { logger.Panicf("BUG: unexpected nil jwt token for user %q", ui.name()) } - if !tkn.HasVMAccessClaim() && ui.JWT.DefaultVMAccessClaim == nil { - ui.logRequest(r, `unauthorized`, http.StatusUnauthorized, 0) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + defer putToken(tkn) + // Call processUserRequest only if the token contains the vm_access claim + // or a default claim is configured; otherwise fall through to unauthorized_user. + if tkn.HasVMAccessClaim() || ui.JWT.DefaultVMAccessClaim != nil { + processUserRequest(w, r, ui, tkn) return true } - defer putToken(tkn) - processUserRequest(w, r, ui, tkn) - return true } uu := authConfig.Load().UnauthorizedUser diff --git a/app/vmauth/main_test.go b/app/vmauth/main_test.go index 4f9ff213161b8..2d1310eba9e48 100644 --- a/app/vmauth/main_test.go +++ b/app/vmauth/main_test.go @@ -785,7 +785,26 @@ statusCode=401 Unauthorized` f(simpleCfgStr, request, responseExpected) - // token without vm_access claim is accepted when it + // token without vm_access claim should fall through to unauthorized_user + request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil) + request.Header.Set(`Authorization`, `Bearer `+noVMAccessClaimToken) + responseExpected = ` +statusCode=200 +path: /bar/abc +query: +headers:` + f(fmt.Sprintf(` +unauthorized_user: + url_prefix: {BACKEND}/bar +users: +- jwt: + public_keys: + - %q + match_claims: + role: admin + url_prefix: {BACKEND}/foo`, string(publicKeyPEM)), request, responseExpected) + + // token without vm_access claim is accepted when default_vm_access_claim configured request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil) request.Header.Set(`Authorization`, `Bearer `+roleToken) responseExpected = `
← Back to Alerts View on GitHub →