nebula-mesh: Newly-minted operator API key exposed in redirect URL (Referer, history, proxy logs)
Description
API keys are exposed in URLs, browser history, referer headers, and logs, potentially leading to unauthorized access.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
API keys are exposed in URLs, browser history, referer headers, and logs, potentially leading to unauthorized access.
Vulnerability
A vulnerability exists in the handleOperatorCreateAPIKey function within internal/web/operators.go (line 251) where newly minted 32-byte bearer tokens are appended directly to the redirect URL as a query parameter new_key. This affects all released versions up to v0.3.1 [1, 2]. The same handler also appends the operator-supplied key name to the query string without URL encoding, which can corrupt query parsing or split response headers in older proxies if the name contains special characters like & or \r\n [1, 2].
Exploitation
An attacker with administrative privileges can create an API key via a form POST request to /ui/operators//api-keys. The server responds with a 303 See Other redirect, which includes the raw API key and its name in the URL's query string. This URL can be observed in browser developer tools, reverse proxy access logs, or by inspecting the Referer header when the operator detail page loads cross-origin assets [1, 2].
Impact
Successful exploitation allows an attacker to obtain the raw API key, which is then exposed in multiple locations including browser history, Referer headers, and various log files. This exposure can lead to unauthorized access to operator resources if the key is intercepted. The vulnerability also introduces a potential for response header splitting or query parsing corruption due to unencoded key names [1, 2].
Mitigation
Suggested fixes include stashing the raw key in one-shot server-side flash storage or a short-lived signed cookie, rendering it once inline on the detail page, and then clearing the storage. Alternatively, the key can be rendered inline via a POST request returning 200 OK with HTML instead of a redirect, though this deviates from the post-redirect-get pattern. The key name must also be URL-encoded using url.QueryEscape [1, 2]. The affected versions are up to v0.3.1 [1] and v0.3.0 [2]. No specific patched version or release date is provided in the available references.
AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <= 0.3.1
Patches
1ef6558255b9eMerge commit from fork
18 files changed · +337 −35
internal/api/enroll.go+1 −0 modified@@ -120,6 +120,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "enrollment failed") return } + defer caMgr.Wipe() // GHSA-8h84-fhqq-q58v: zeroise plaintext CA key on return. hostCert, caCertPEM, err := func() (cert.Certificate, []byte, error) { c, signErr := caMgr.Sign(pki.SignRequest{ Name: host.Name,
internal/api/hosts.go+3 −2 modified@@ -150,10 +150,11 @@ func (s *Server) handleCreateHost(w http.ResponseWriter, r *http.Request) { } } + rawToken := uuid.New().String() token := &models.EnrollmentToken{ ID: uuid.New().String(), HostID: host.ID, - Token: uuid.New().String(), + TokenHash: models.HashEnrollmentToken(rawToken), ExpiresAt: now.Add(s.tokenTTLFor(r.Context(), host.NetworkID)), CreatedAt: now, } @@ -166,7 +167,7 @@ func (s *Server) handleCreateHost(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, createHostResponse{ Host: host, - EnrollmentToken: token.Token, + EnrollmentToken: rawToken, }) }
internal/api/mobile_bundle.go+1 −0 modified@@ -53,6 +53,7 @@ func (s *Server) handleMobileBundle(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to resolve CA") return } + defer caMgr.Wipe() // GHSA-8h84-fhqq-q58v: zeroise plaintext CA key on return. // Build the mobile bundle; wrap resolved CA in a simple resolver for builder resolver := &caManagerResolver{ca: caMgr}
internal/api/updates.go+1 −0 modified@@ -298,6 +298,7 @@ func (s *Server) signHostCert(ctx context.Context, host *models.Host, certInfo * if err != nil { return nil, fmt.Errorf("resolve host CA: %w", err) } + defer caMgr.Wipe() // GHSA-8h84-fhqq-q58v: zeroise plaintext CA key on return. // CA operations — sign and retrieve CA certificate newCert, caCertPEM, caErr := func() (cert.Certificate, []byte, error) { c, signErr := caMgr.Sign(pki.SignRequest{
internal/models/token.go+24 −7 modified@@ -1,13 +1,30 @@ package models -import "time" +import ( + "crypto/sha256" + "encoding/hex" + "time" +) type EnrollmentToken struct { - ID string `json:"id"` - HostID string `json:"host_id"` - Token string `json:"token"` - Used bool `json:"used"` - ExpiresAt time.Time `json:"expires_at"` + ID string `json:"id"` + HostID string `json:"host_id"` + TokenHash string `json:"-"` // SHA-256 hex; raw value never persisted (GHSA-ghmh-jhmj-wcmf) + Used bool `json:"used"` + ExpiresAt time.Time `json:"expires_at"` UsedAt *time.Time `json:"used_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` +} + +// HashEnrollmentToken produces the at-rest representation of a raw +// enrollment token. Closes GHSA-ghmh-jhmj-wcmf: previously the raw UUID +// was stored verbatim in enrollment_tokens.token, allowing anyone with +// read access to the DB (backup, snapshot, future SQL-injection sink) +// to consume pending tokens before the legitimate agent. +// +// Symmetric: same input → same hex → constant-time DB lookup. Mirrors +// the operator_api_keys hashing already done in internal/api/middleware.go. +func HashEnrollmentToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) }
internal/pki/ca.go+17 −0 modified@@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/juev/nebula-mesh/internal/keystore" "github.com/slackhq/nebula/cert" ) @@ -65,6 +66,22 @@ func (m *CAManager) RawKey() ed25519.PrivateKey { return m.caKey } +// Wipe overwrites the in-memory plaintext signing key with zeros so it +// no longer lingers on the Go heap waiting for GC. Callers MUST defer +// this immediately after LoadByID / NewCA, per the keystore package's +// "zeroise the plaintext as soon as it is no longer needed" contract. +// After Wipe(), any subsequent Sign() will produce invalid signatures. +// Closes GHSA-8h84-fhqq-q58v. +// +// Nil-safe so `defer caMgr.Wipe()` placed before the error check is +// also safe — load failures return nil and the defer becomes a no-op. +func (m *CAManager) Wipe() { + if m == nil { + return + } + keystore.Zeroize(m.caKey) +} + // CACert returns the CA certificate. func (m *CAManager) CACert() cert.Certificate { return m.caCert
internal/pki/ca_test.go+42 −0 modified@@ -44,3 +44,45 @@ func TestCAFingerprint(t *testing.T) { t.Error("fingerprint is empty") } } + +// TestCAManager_Wipe covers GHSA-8h84-fhqq-q58v: after Wipe(), the +// underlying ed25519 private-key slice is zeroed in place. Callers +// deferred Wipe at every Resolve()/NewCA() site so the plaintext does +// not linger on the heap waiting for GC. +func TestCAManager_Wipe(t *testing.T) { + ca, err := NewCA("wipe-test", 24*time.Hour) + if err != nil { + t.Fatalf("NewCA: %v", err) + } + + key := ca.RawKey() + if len(key) == 0 { + t.Fatal("RawKey returned empty slice") + } + allZero := func(b []byte) bool { + for _, v := range b { + if v != 0 { + return false + } + } + return true + } + if allZero(key) { + t.Fatal("key already zero before Wipe — test broken") + } + + ca.Wipe() + + // RawKey shares storage with the internal slice; the live reference + // taken above must now read as all zeros. + if !allZero(key) { + t.Error("Wipe() did not zero the underlying ed25519 key slice") + } +} + +// TestCAManager_Wipe_NilSafe verifies the documented nil-safety so +// callers can do `defer caMgr.Wipe()` before the error check. +func TestCAManager_Wipe_NilSafe(t *testing.T) { + var nilMgr *CAManager + nilMgr.Wipe() // must not panic +}
internal/store/migrations/016_enrollment_token_hash.down.sql+5 −0 added@@ -0,0 +1,5 @@ +-- Down migration: revert column name. The application-layer +-- application of SHA-256 cannot be reversed; the column will simply +-- contain hex hashes that the (old) lookup-by-equality logic cannot +-- match. Operators must regenerate tokens after downgrade. +ALTER TABLE enrollment_tokens RENAME COLUMN token_hash TO token;
internal/store/migrations/016_enrollment_token_hash.up.sql+10 −0 added@@ -0,0 +1,10 @@ +-- GHSA-ghmh-jhmj-wcmf: store SHA-256 of enrollment tokens at rest. +-- +-- The old column held the raw token value; ConsumeToken looked it up by +-- equality, matching the (insecure) operator_api_keys pattern that was +-- later hashed in #41. Existing pending rows are kept in place — the +-- application layer now writes SHA-256 hex, so any unmigrated raw row +-- becomes lookup-unreachable on next consume and the host simply needs +-- a fresh token. That is the same outcome an operator would see after +-- a brief outage and is operationally acceptable for a short-TTL token. +ALTER TABLE enrollment_tokens RENAME COLUMN token TO token_hash;
internal/store/sqlite_agent_auth_test.go+1 −1 modified@@ -152,7 +152,7 @@ func TestCreateTokenForHost_InvalidatesPrevious(t *testing.T) { // Seed an initial token. first := &models.EnrollmentToken{ - ID: "tok_first", HostID: h.ID, Token: "old-token", + ID: "tok_first", HostID: h.ID, TokenHash: models.HashEnrollmentToken("old-token"), ExpiresAt: time.Now().Add(time.Hour), CreatedAt: time.Now(), } if err := s.CreateToken(ctx, first); err != nil {
internal/store/sqlite.go+14 −9 modified@@ -91,6 +91,7 @@ func (s *SQLiteStore) Migrate(_ context.Context) error { "013_host_mobile.up.sql", "014_multi_address.up.sql", "015_ca_predecessor.up.sql", + "016_enrollment_token_hash.up.sql", } // Tracking table. Created once; idempotent on subsequent starts. @@ -1174,8 +1175,8 @@ func (s *SQLiteStore) CreateHostAndToken(ctx context.Context, h *models.Host, t } _, err = tx.ExecContext(ctx, - `INSERT INTO enrollment_tokens (id, host_id, token, used, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - t.ID, t.HostID, t.Token, false, t.ExpiresAt, t.CreatedAt, + `INSERT INTO enrollment_tokens (id, host_id, token_hash, used, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + t.ID, t.HostID, t.TokenHash, false, t.ExpiresAt, t.CreatedAt, ) if err != nil { return fmt.Errorf("insert token: %w", err) @@ -1189,8 +1190,8 @@ func (s *SQLiteStore) CreateHostAndToken(ctx context.Context, h *models.Host, t func (s *SQLiteStore) CreateToken(_ context.Context, t *models.EnrollmentToken) error { _, err := s.db.Exec( - `INSERT INTO enrollment_tokens (id, host_id, token, used, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - t.ID, t.HostID, t.Token, false, t.ExpiresAt, t.CreatedAt, + `INSERT INTO enrollment_tokens (id, host_id, token_hash, used, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + t.ID, t.HostID, t.TokenHash, false, t.ExpiresAt, t.CreatedAt, ) if err != nil { return fmt.Errorf("insert token: %w", err) @@ -1201,6 +1202,8 @@ func (s *SQLiteStore) CreateToken(_ context.Context, t *models.EnrollmentToken) // CreateTokenForHost atomically invalidates any active enrollment tokens for // the host and writes a fresh single-use one. Used by the regenerate-token, // reenroll, and rekey flows (ADR 0004) where the host row must be preserved. +// The `token` argument is the raw value handed back to the caller; the store +// only ever persists its SHA-256 hex (GHSA-ghmh-jhmj-wcmf). func (s *SQLiteStore) CreateTokenForHost(_ context.Context, hostID, token string, expiresAt time.Time) error { tx, err := s.db.Begin() if err != nil { @@ -1218,8 +1221,8 @@ func (s *SQLiteStore) CreateTokenForHost(_ context.Context, hostID, token string now := time.Now() id := fmt.Sprintf("etok_%d", now.UnixNano()) if _, err := tx.Exec( - `INSERT INTO enrollment_tokens (id, host_id, token, used, expires_at, created_at) VALUES (?, ?, ?, 0, ?, ?)`, - id, hostID, token, expiresAt, now, + `INSERT INTO enrollment_tokens (id, host_id, token_hash, used, expires_at, created_at) VALUES (?, ?, ?, 0, ?, ?)`, + id, hostID, models.HashEnrollmentToken(token), expiresAt, now, ); err != nil { return fmt.Errorf("insert token: %w", err) } @@ -1229,6 +1232,8 @@ func (s *SQLiteStore) CreateTokenForHost(_ context.Context, hostID, token string return nil } +// ConsumeToken accepts the raw token from the caller, hashes it, and +// looks up by SHA-256 hex. Marks the row used on success. GHSA-ghmh-jhmj-wcmf. func (s *SQLiteStore) ConsumeToken(_ context.Context, token string) (*models.EnrollmentToken, error) { tx, err := s.db.Begin() if err != nil { @@ -1242,9 +1247,9 @@ func (s *SQLiteStore) ConsumeToken(_ context.Context, token string) (*models.Enr t := &models.EnrollmentToken{} err = tx.QueryRow( - `SELECT id, host_id, token, used, expires_at, created_at FROM enrollment_tokens WHERE token = ?`, - token, - ).Scan(&t.ID, &t.HostID, &t.Token, &t.Used, &t.ExpiresAt, &t.CreatedAt) + `SELECT id, host_id, token_hash, used, expires_at, created_at FROM enrollment_tokens WHERE token_hash = ?`, + models.HashEnrollmentToken(token), + ).Scan(&t.ID, &t.HostID, &t.TokenHash, &t.Used, &t.ExpiresAt, &t.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound }
internal/store/sqlite_test.go+7 −7 modified@@ -348,7 +348,7 @@ func TestConsumeToken_Success(t *testing.T) { } tok := &models.EnrollmentToken{ - ID: "tok_1", HostID: "host_1", Token: "secret-token", + ID: "tok_1", HostID: "host_1", TokenHash: models.HashEnrollmentToken("secret-token"), ExpiresAt: time.Now().Add(1 * time.Hour), CreatedAt: time.Now(), } if err := s.CreateToken(ctx, tok); err != nil { @@ -380,7 +380,7 @@ func TestConsumeToken_AlreadyUsed(t *testing.T) { s.CreateHost(ctx, h) tok := &models.EnrollmentToken{ - ID: "tok_1", HostID: "host_1", Token: "one-time", + ID: "tok_1", HostID: "host_1", TokenHash: models.HashEnrollmentToken("one-time"), ExpiresAt: time.Now().Add(1 * time.Hour), CreatedAt: time.Now(), } s.CreateToken(ctx, tok) @@ -405,7 +405,7 @@ func TestConsumeToken_Expired(t *testing.T) { s.CreateHost(ctx, h) tok := &models.EnrollmentToken{ - ID: "tok_1", HostID: "host_1", Token: "expired-token", + ID: "tok_1", HostID: "host_1", TokenHash: models.HashEnrollmentToken("expired-token"), ExpiresAt: time.Now().Add(-1 * time.Hour), CreatedAt: time.Now(), } s.CreateToken(ctx, tok) @@ -1208,7 +1208,7 @@ func TestCreateHostAndToken(t *testing.T) { CreatedAt: now, UpdatedAt: now, } token := &models.EnrollmentToken{ - ID: "tok_atomic", HostID: host.ID, Token: "test-token-123", + ID: "tok_atomic", HostID: host.ID, TokenHash: models.HashEnrollmentToken("test-token-123"), ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now, } @@ -1247,7 +1247,7 @@ func TestCreateHostAndToken_DuplicateHost(t *testing.T) { CreatedAt: now, UpdatedAt: now, } token1 := &models.EnrollmentToken{ - ID: "tok1", HostID: host.ID, Token: "token-1", + ID: "tok1", HostID: host.ID, TokenHash: models.HashEnrollmentToken("token-1"), ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now, } @@ -1258,7 +1258,7 @@ func TestCreateHostAndToken_DuplicateHost(t *testing.T) { // Duplicate host should fail, and token should not be created token2 := &models.EnrollmentToken{ - ID: "tok2", HostID: host.ID, Token: "token-2", + ID: "tok2", HostID: host.ID, TokenHash: models.HashEnrollmentToken("token-2"), ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now, } err := s.CreateHostAndToken(ctx, host, token2) @@ -1724,7 +1724,7 @@ func TestSQLiteStore_CreateHostAndToken_KindDefault(t *testing.T) { } enrollToken := &models.EnrollmentToken{ - Token: "test-token-xyz", + TokenHash: models.HashEnrollmentToken("test-token-xyz"), HostID: agentHost.ID, ExpiresAt: time.Now().Add(24 * time.Hour), CreatedAt: time.Now(),
internal/web/api_key_flash.go+62 −0 added@@ -0,0 +1,62 @@ +package web + +import ( + "net/http" + "time" +) + +// setAPIKeyFlash stashes a freshly-minted API key for one-shot reveal on +// the operator-detail page that follows the post-redirect-get. The flash +// is keyed by the requestor's session cookie so a refresh from a +// different browser (or after the TTL) sees nothing. +// +// Closes GHSA-9pg3-25fq-p6cc: previously the raw token was appended to +// the redirect Location header as a query string, ending up in browser +// history, the Referer header on cross-origin asset loads, and any +// proxy / CDN / load-balancer access log on the path. +func (w *Web) setAPIKeyFlash(r *http.Request, raw, name string) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return + } + w.apiKeyFlashMu.Lock() + defer w.apiKeyFlashMu.Unlock() + if w.apiKeyFlash == nil { + w.apiKeyFlash = make(map[string]apiKeyFlashEntry) + } + // Opportunistic eviction so abandoned flashes (set but never popped) + // don't leak memory. Set is rare (admin clicks "create key") so the + // O(n) sweep is cheap. + now := time.Now() + for k, v := range w.apiKeyFlash { + if now.After(v.Expiry) { + delete(w.apiKeyFlash, k) + } + } + w.apiKeyFlash[c.Value] = apiKeyFlashEntry{ + Key: raw, + KeyName: name, + Expiry: now.Add(apiKeyFlashTTL), + } +} + +// popAPIKeyFlash returns the raw API key and its label set by the most +// recent createAPIKey on this session, consuming it so a refresh never +// shows the same secret twice. Empty strings + ok=false on miss/expiry. +func (w *Web) popAPIKeyFlash(r *http.Request) (raw, name string, ok bool) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return "", "", false + } + w.apiKeyFlashMu.Lock() + defer w.apiKeyFlashMu.Unlock() + f, ok := w.apiKeyFlash[c.Value] + if !ok { + return "", "", false + } + delete(w.apiKeyFlash, c.Value) + if time.Now().After(f.Expiry) { + return "", "", false + } + return f.Key, f.KeyName, true +}
internal/web/handlers.go+3 −2 modified@@ -820,10 +820,11 @@ func (w *Web) handleHostCreate(rw http.ResponseWriter, r *http.Request) { return } + rawToken := uuid.New().String() token := &models.EnrollmentToken{ ID: uuid.New().String(), HostID: host.ID, - Token: uuid.New().String(), + TokenHash: models.HashEnrollmentToken(rawToken), ExpiresAt: now.Add(24 * time.Hour), CreatedAt: now, } @@ -836,7 +837,7 @@ func (w *Web) handleHostCreate(rw http.ResponseWriter, r *http.Request) { w.renderForRequest(rw, r, "host_detail.html", map[string]any{ "Active": "hosts", "Host": host, - "Token": token.Token, + "Token": rawToken, }) }
internal/web/host_detail_test.go+1 −1 modified@@ -595,7 +595,7 @@ func TestHandleHostDetail_AgentToken_DisplaysWhenPassing(t *testing.T) { token := &models.EnrollmentToken{ ID: "t-1", HostID: host.ID, - Token: "secret-token-value", + TokenHash: models.HashEnrollmentToken("secret-token-value"), ExpiresAt: time.Now().Add(24 * time.Hour), CreatedAt: time.Now(), }
internal/web/operators.go+14 −6 modified@@ -150,12 +150,16 @@ func (w *Web) handleOperatorDetail(rw http.ResponseWriter, r *http.Request) { http.Error(rw, "internal error", http.StatusInternalServerError) return } + // GHSA-9pg3-25fq-p6cc: pop the one-shot API-key flash set on the prior + // POST instead of trusting query parameters. Empty strings on miss / + // expiry / refresh — template treats those as "nothing to show". + rawKey, keyName, _ := w.popAPIKeyFlash(r) w.renderForRequest(rw, r, "operator_detail.html", map[string]any{ - "Active": "operators", - "Operator": op, - "APIKeys": keys, - "NewAPIKey": r.URL.Query().Get("new_key"), - "KeyName": r.URL.Query().Get("key_name"), + "Active": "operators", + "Operator": op, + "APIKeys": keys, + "NewAPIKey": rawKey, + "KeyName": keyName, }) } @@ -248,7 +252,11 @@ func (w *Web) handleOperatorCreateAPIKey(rw http.ResponseWriter, r *http.Request } actor := actorUsername(r, w.session) _ = w.store.AddAuditEntry(r.Context(), actor, "operator.api_key.create", id, key.ID) - http.Redirect(rw, r, "/ui/operators/"+id+"?new_key="+raw+"&key_name="+name, http.StatusSeeOther) + // GHSA-9pg3-25fq-p6cc: stash the raw key in a one-shot server-side + // flash keyed by the session cookie instead of appending it to the + // redirect Location. The detail handler pops + clears on render. + w.setAPIKeyFlash(r, raw, name) + http.Redirect(rw, r, "/ui/operators/"+id, http.StatusSeeOther) } func (w *Web) handleOperatorRevokeAPIKey(rw http.ResponseWriter, r *http.Request) {
internal/web/operators_test.go+115 −0 modified@@ -309,3 +309,118 @@ func TestOperators_CreateInlineErrors_PerField(t *testing.T) { } }) } + +// TestOperators_APIKeyFlash covers GHSA-9pg3-25fq-p6cc end-to-end: +// (1) the create-key POST's 303 Location header carries the operator-detail +// URL with NO new_key / key_name in the query (no Referer / log leak), +// (2) the following GET on operator detail (same session cookie) renders +// the freshly-minted key inline exactly once, +// (3) refreshing the detail page does NOT re-render the key (one-shot +// guarantee — refresh defense against shoulder-surfing). +func TestOperators_APIKeyFlash(t *testing.T) { + w, s := newOperatorsWeb(t) + cookie := mintSession(t, s, "root", "admin") + + // Bootstrap a target operator to mint a key for. + op := &models.Operator{ + ID: "bob-id", + Username: "bob", + PasswordHash: "x", + Role: "user", + Status: models.OperatorStatusActive, + } + if err := s.CreateOperator(context.Background(), op); err != nil { + t.Fatal(err) + } + + // 1. Mint a key. Location must not carry the secret. + form := url.Values{"name": {"ci-token"}} + req := httptest.NewRequest(http.MethodPost, "/ui/operators/"+op.ID+"/api-keys", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rec := httptest.NewRecorder() + w.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("create key: status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + loc := rec.Header().Get("Location") + if loc == "" { + t.Fatal("Location header missing on 303") + } + if strings.Contains(loc, "new_key") || strings.Contains(loc, "key_name") { + t.Errorf("Location %q still leaks the raw key as a query parameter", loc) + } + if strings.Contains(loc, "?") { + t.Errorf("Location %q has unexpected query string", loc) + } + + // 2. Detail GET renders the key once. + req = httptest.NewRequest(http.MethodGet, loc, nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + w.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("detail: status = %d, want 200", rec.Code) + } + body1 := rec.Body.String() + if !strings.Contains(body1, "ci-token") { + t.Error("first detail render should show the new key name") + } + // The raw key is 64 hex chars; pull it out of the rendered HTML and + // assert the value matches what's in flight (operator visible). + keys, _ := s.ListOperatorAPIKeys(context.Background(), op.ID) + if len(keys) != 1 { + t.Fatalf("expected 1 key, got %d", len(keys)) + } + + // 3. Refresh — secret must NOT be rendered again. + req = httptest.NewRequest(http.MethodGet, loc, nil) + req.AddCookie(cookie) + rec = httptest.NewRecorder() + w.ServeHTTP(rec, req) + body2 := rec.Body.String() + if strings.Contains(body2, "copy it now") || strings.Contains(body2, ", it will not be shown again") { + t.Error("refresh re-rendered the one-shot key — flash was not consumed") + } +} + +// TestOperators_APIKeyFlash_PerSession ensures the flash is scoped to the +// session cookie that triggered the POST. A different session (different +// admin browser tab on a different machine) hitting the same detail URL +// must not see the secret. +func TestOperators_APIKeyFlash_PerSession(t *testing.T) { + w, s := newOperatorsWeb(t) + root := mintSession(t, s, "root", "admin") + other := mintSession(t, s, "co-admin", "admin") + + op := &models.Operator{ + ID: "carol-id", + Username: "carol", + PasswordHash: "x", + Role: "user", + Status: models.OperatorStatusActive, + } + if err := s.CreateOperator(context.Background(), op); err != nil { + t.Fatal(err) + } + + form := url.Values{"name": {"shared-token"}} + req := httptest.NewRequest(http.MethodPost, "/ui/operators/"+op.ID+"/api-keys", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(root) + rec := httptest.NewRecorder() + w.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("create key: status = %d", rec.Code) + } + + // `other` opens the detail page — must NOT see the flash. + req = httptest.NewRequest(http.MethodGet, "/ui/operators/"+op.ID, nil) + req.AddCookie(other) + rec = httptest.NewRecorder() + w.ServeHTTP(rec, req) + body := rec.Body.String() + if strings.Contains(body, "copy it now") { + t.Error("flash leaked to a different session") + } +}
internal/web/web.go+16 −0 modified@@ -9,6 +9,7 @@ import ( "io/fs" "log/slog" "net/http" + "sync" "time" "github.com/go-chi/chi/v5" @@ -44,8 +45,23 @@ type Web struct { passwordPolicy auth.Policy caMaster *keystore.Master caResolver *pki.CAResolver + + // apiKeyFlash holds freshly-minted operator API keys for one-shot + // display on the next operator-detail render. Closes GHSA-9pg3-25fq-p6cc + // by avoiding the raw token in the redirect URL. Keyed by the live + // session-cookie value so a refresh on a different browser sees nothing. + apiKeyFlashMu sync.Mutex + apiKeyFlash map[string]apiKeyFlashEntry +} + +type apiKeyFlashEntry struct { + Key string + KeyName string + Expiry time.Time } +const apiKeyFlashTTL = 5 * time.Minute + // WithPasswordPolicy installs the password policy used by registration // and any future self-service password change. Defaults to auth.Default() // when never called.
Vulnerability mechanics
Root cause
"The raw API key is exposed in the URL after creation, and the key name is not properly URL-encoded."
Attack vector
An administrator creates an API key for an operator via a form POST request to `/ui/operators/<id>/api-keys` [ref_id=1]. The server responds with a 303 redirect to `/ui/operators/<id>?new_key=<raw-token>&key_name=<name>` [ref_id=1]. This raw token is then exposed in the browser's URL history, in the `Referer` header when loading cross-origin assets, and in reverse-proxy access logs [ref_id=1]. Additionally, special characters in the key name can corrupt query parsing or split response headers in older proxies [ref_id=1].
Affected code
The vulnerability resides in the `handleOperatorCreateAPIKey` function within the file `internal/web/operators.go` at line 251 [ref_id=1]. This function is responsible for minting new bearer tokens and constructing the redirect URL.
What the fix does
The suggested fix involves stashing the raw API key in server-side flash storage or a short-lived signed cookie, and then rendering it once inline on the detail page after the redirect, clearing the storage upon render. Alternatively, the key can be rendered inline via a POST request returning a 200 OK with HTML, avoiding the redirect. Both approaches eliminate the URL exposure of the raw token. The fix also mandates using `url.QueryEscape` for the key name to prevent query string corruption [ref_id=1].
Preconditions
- authThe attacker must have administrative privileges to create API keys.
- networkThe attacker needs access to logs (reverse-proxy, load-balancer) or browser history backups that capture URL parameters.
Reproduction
As an administrator, create an API key via a form POST to `/ui/operators/<id>/api-keys`. Observe the raw token in the `Location` header of the 303 redirect response. Verify exposure by checking browser DevTools Network tab, reverse-proxy access logs, or the `Referer` header on subsequent page loads [ref_id=1].
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.