Skip to content

Commit a13915a

Browse files
fix(security): replace timing-unsafe token comparisons with hmac.Equal (#426)
Bearer and admin token comparisons used plain == / != operators, which are not constant-time and leak timing information via early-exit string comparison. Replace all four comparison sites with hmac.Equal([]byte(a), []byte(b)) as required by issue #350. Add correctness guard tests for each site to ensure accept/reject behavior is preserved.
1 parent d0e7e21 commit a13915a

4 files changed

Lines changed: 142 additions & 4 deletions

File tree

internal/cloud/auth/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ func (s *Service) Authorize(r *http.Request) error {
250250
if token == "" {
251251
return fmt.Errorf("bearer token is required")
252252
}
253-
if token != s.expectedToken {
253+
if !hmac.Equal([]byte(token), []byte(s.expectedToken)) {
254254
return fmt.Errorf("invalid bearer token")
255255
}
256256
return nil

internal/cloud/auth/auth_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,44 @@ func TestDashboardSessionTokenRejectsWhenConfiguredBearerChanges(t *testing.T) {
252252
}
253253
}
254254

255+
// TestAuthorizeBearerTokenConstantTimeComparison is a correctness guard for the
256+
// constant-time token comparison at auth.go:253. A correct token must be accepted
257+
// and any wrong token must be rejected, preserving behavior after the
258+
// timing-safe hmac.Equal replacement (security issue #350).
259+
func TestAuthorizeBearerTokenConstantTimeComparison(t *testing.T) {
260+
svc, err := NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
261+
if err != nil {
262+
t.Fatalf("new service: %v", err)
263+
}
264+
svc.SetBearerToken("correct-token")
265+
266+
correct := httptest.NewRequest("GET", "/sync/pull", nil)
267+
correct.Header.Set("Authorization", "Bearer correct-token")
268+
if err := svc.Authorize(correct); err != nil {
269+
t.Fatalf("correct token must be accepted, got %v", err)
270+
}
271+
272+
wrong := httptest.NewRequest("GET", "/sync/pull", nil)
273+
wrong.Header.Set("Authorization", "Bearer wrong-token")
274+
if err := svc.Authorize(wrong); err == nil {
275+
t.Fatal("wrong token must be rejected")
276+
}
277+
278+
// A token that is a prefix of the correct one must also be rejected.
279+
prefix := httptest.NewRequest("GET", "/sync/pull", nil)
280+
prefix.Header.Set("Authorization", "Bearer correct-toke")
281+
if err := svc.Authorize(prefix); err == nil {
282+
t.Fatal("prefix of correct token must be rejected")
283+
}
284+
285+
// A token that is a superset of the correct one must also be rejected.
286+
super := httptest.NewRequest("GET", "/sync/pull", nil)
287+
super.Header.Set("Authorization", "Bearer correct-token-extra")
288+
if err := svc.Authorize(super); err == nil {
289+
t.Fatal("superset of correct token must be rejected")
290+
}
291+
}
292+
255293
func TestDashboardSessionTokenSupportsAdditionalDashboardCredential(t *testing.T) {
256294
svc, err := NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
257295
if err != nil {

internal/cloud/cloudserver/cloudserver.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cloudserver
22

33
import (
44
"context"
5+
"crypto/hmac"
56
"encoding/json"
67
"errors"
78
"fmt"
@@ -157,7 +158,7 @@ func (s *CloudServer) routes() {
157158
if token == "" {
158159
return fmt.Errorf("bearer token is required")
159160
}
160-
if adminToken := strings.TrimSpace(s.dashboardAdmin); adminToken != "" && token == adminToken {
161+
if adminToken := strings.TrimSpace(s.dashboardAdmin); adminToken != "" && hmac.Equal([]byte(token), []byte(adminToken)) {
161162
return nil
162163
}
163164
if s.auth == nil {
@@ -258,7 +259,7 @@ func (s *CloudServer) authorizeDashboardRequest(r *http.Request) error {
258259
if strings.TrimSpace(bearerToken) == "" {
259260
return fmt.Errorf("dashboard session token is empty")
260261
}
261-
if adminToken := strings.TrimSpace(s.dashboardAdmin); adminToken != "" && bearerToken == adminToken {
262+
if adminToken := strings.TrimSpace(s.dashboardAdmin); adminToken != "" && hmac.Equal([]byte(bearerToken), []byte(adminToken)) {
262263
return nil
263264
}
264265
req, _ := http.NewRequest(http.MethodGet, "/dashboard", nil)
@@ -317,7 +318,7 @@ func (s *CloudServer) isDashboardAdmin(r *http.Request) bool {
317318
if err != nil {
318319
return false
319320
}
320-
return token == adminToken
321+
return hmac.Equal([]byte(token), []byte(adminToken))
321322
}
322323

323324
func (s *CloudServer) handlePullManifest(w http.ResponseWriter, r *http.Request) {

internal/cloud/cloudserver/cloudserver_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,6 +1497,105 @@ func TestAuditLogE2E_MutationPushPausedThenListRendered(t *testing.T) {
14971497
}
14981498
}
14991499

1500+
// TestValidateLoginTokenAdminComparisonGuard is a correctness guard for the
1501+
// constant-time admin token comparison inside validateLoginToken (cloudserver.go:160).
1502+
// A correct admin token must be accepted and a wrong one rejected, preserving
1503+
// behavior after the timing-safe hmac.Equal replacement (security issue #350).
1504+
func TestValidateLoginTokenAdminComparisonGuard(t *testing.T) {
1505+
authSvc, err := cloudauth.NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
1506+
if err != nil {
1507+
t.Fatalf("new auth service: %v", err)
1508+
}
1509+
authSvc.SetBearerToken("sync-token")
1510+
authSvc.SetDashboardSessionTokens([]string{"admin-token"})
1511+
srv := New(&fakeStore{}, authSvc, 0, WithDashboardAdminToken("admin-token"))
1512+
1513+
// Correct admin token must authenticate and redirect to dashboard.
1514+
goodLogin := httptest.NewRecorder()
1515+
goodReq := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader("token=admin-token"))
1516+
goodReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1517+
srv.Handler().ServeHTTP(goodLogin, goodReq)
1518+
if goodLogin.Code != http.StatusSeeOther {
1519+
t.Fatalf("correct admin token must authenticate, got %d body=%q", goodLogin.Code, goodLogin.Body.String())
1520+
}
1521+
1522+
// Wrong token must be rejected (re-render login form).
1523+
badLogin := httptest.NewRecorder()
1524+
badReq := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader("token=wrong-token"))
1525+
badReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1526+
srv.Handler().ServeHTTP(badLogin, badReq)
1527+
if badLogin.Code == http.StatusSeeOther {
1528+
t.Fatalf("wrong admin token must be rejected, got %d", badLogin.Code)
1529+
}
1530+
for _, cookie := range badLogin.Result().Cookies() {
1531+
if cookie.Name == dashboardSessionCookieName {
1532+
t.Fatal("wrong admin token must not set session cookie")
1533+
}
1534+
}
1535+
}
1536+
1537+
// TestIsDashboardAdminComparisonGuard is a correctness guard for the
1538+
// constant-time admin token comparison inside isDashboardAdmin (cloudserver.go:320).
1539+
// A valid admin session cookie must grant admin access and a wrong one must not,
1540+
// preserving behavior after the timing-safe hmac.Equal replacement (security issue #350).
1541+
func TestIsDashboardAdminComparisonGuard(t *testing.T) {
1542+
authSvc, err := cloudauth.NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
1543+
if err != nil {
1544+
t.Fatalf("new auth service: %v", err)
1545+
}
1546+
authSvc.SetBearerToken("sync-token")
1547+
authSvc.SetDashboardSessionTokens([]string{"admin-token"})
1548+
srv := New(&fakeStore{}, authSvc, 0, WithDashboardAdminToken("admin-token"))
1549+
1550+
// Establish a valid admin session cookie via login.
1551+
loginRec := httptest.NewRecorder()
1552+
loginReq := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader("token=admin-token"))
1553+
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1554+
srv.Handler().ServeHTTP(loginRec, loginReq)
1555+
if loginRec.Code != http.StatusSeeOther {
1556+
t.Fatalf("setup: expected admin login to succeed, got %d body=%q", loginRec.Code, loginRec.Body.String())
1557+
}
1558+
adminCookies := loginRec.Result().Cookies()
1559+
1560+
// Admin session must grant access to /dashboard/admin.
1561+
adminRec := httptest.NewRecorder()
1562+
adminReq := httptest.NewRequest(http.MethodGet, "/dashboard/admin", nil)
1563+
for _, c := range adminCookies {
1564+
adminReq.AddCookie(c)
1565+
}
1566+
srv.Handler().ServeHTTP(adminRec, adminReq)
1567+
if adminRec.Code != http.StatusOK {
1568+
t.Fatalf("valid admin session must access /dashboard/admin, got %d body=%q", adminRec.Code, adminRec.Body.String())
1569+
}
1570+
1571+
// Non-admin session (sync-token login) must not access /dashboard/admin.
1572+
authSvc2, err := cloudauth.NewService(&cloudstore.CloudStore{}, strings.Repeat("x", 32))
1573+
if err != nil {
1574+
t.Fatalf("new auth service 2: %v", err)
1575+
}
1576+
authSvc2.SetBearerToken("sync-token")
1577+
authSvc2.SetDashboardSessionTokens([]string{"admin-token"})
1578+
srv2 := New(&fakeStore{}, authSvc2, 0, WithDashboardAdminToken("admin-token"))
1579+
1580+
syncLoginRec := httptest.NewRecorder()
1581+
syncLoginReq := httptest.NewRequest(http.MethodPost, "/dashboard/login", strings.NewReader("token=sync-token"))
1582+
syncLoginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1583+
srv2.Handler().ServeHTTP(syncLoginRec, syncLoginReq)
1584+
if syncLoginRec.Code != http.StatusSeeOther {
1585+
t.Fatalf("setup: sync-token login must succeed, got %d body=%q", syncLoginRec.Code, syncLoginRec.Body.String())
1586+
}
1587+
1588+
nonAdminRec := httptest.NewRecorder()
1589+
nonAdminReq := httptest.NewRequest(http.MethodGet, "/dashboard/admin", nil)
1590+
for _, c := range syncLoginRec.Result().Cookies() {
1591+
nonAdminReq.AddCookie(c)
1592+
}
1593+
srv2.Handler().ServeHTTP(nonAdminRec, nonAdminReq)
1594+
if nonAdminRec.Code == http.StatusOK {
1595+
t.Fatalf("non-admin session must not access /dashboard/admin, got %d", nonAdminRec.Code)
1596+
}
1597+
}
1598+
15001599
// TestInsecureModeLoginRedirects asserts that GET /dashboard/login with auth==nil
15011600
// returns 303 to /dashboard/ (login is a no-op in insecure mode). Satisfies REQ-110.
15021601
func TestInsecureModeLoginRedirects(t *testing.T) {

0 commit comments

Comments
 (0)