From bb175cac9eda8b45b997c0c6f24ec21dc05ede5a Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Thu, 27 Aug 2026 20:19:20 +0800 Subject: [PATCH] fix(auth): validate OAuth expiry conversions --- internal/auth/auth.go | 15 ++----------- internal/auth/auth_test.go | 38 +++++++++++++++++++++++++++++++ internal/auth/oauth.go | 46 +++++++++++++++++++++++--------------- 3 files changed, 68 insertions(+), 31 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0106919..b54f270 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -238,19 +238,8 @@ func refreshTokenRequest(ctx context.Context, client *http.Client, endpoint, cli } func tokenExpiresInFromRaw(raw map[string]any) int { - switch v := raw["expires_in"].(type) { - case int: - return v - case int64: - return int(v) - case float64: - if v > 0 { - return int(v) - } - case string: - if n, err := parseIntString(v); err == nil && n > 0 { - return int(n) - } + if seconds, ok := oauthExpiresInSeconds(raw["expires_in"]); ok { + return seconds } return 0 } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index fb4a4c5..8f00fa4 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "math" "net" "net/http" "net/http/httptest" @@ -371,6 +372,43 @@ func TestEffectiveTokenScopePrefersGrantedScope(t *testing.T) { } } +func TestOAuthExpiresInSecondsRejectsUnsafeValues(t *testing.T) { + tests := []struct { + name string + value any + want int + ok bool + }{ + {name: "int", value: 3600, want: 3600, ok: true}, + {name: "int64", value: int64(3600), want: 3600, ok: true}, + {name: "float", value: float64(3600), want: 3600, ok: true}, + {name: "string", value: " 3600 ", want: 3600, ok: true}, + {name: "zero", value: 0}, + {name: "negative", value: int64(-1)}, + {name: "fractional", value: 1.5}, + {name: "duration overflow", value: int64(math.MaxInt64/int64(time.Second) + 1)}, + {name: "integer overflow string", value: "9223372036854775808"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := oauthExpiresInSeconds(tt.value) + if got != tt.want || ok != tt.ok { + t.Fatalf("oauthExpiresInSeconds(%v) = (%d, %t), want (%d, %t)", tt.value, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestTokenExpiresInUsesFallbackForUnsafeExtra(t *testing.T) { + token := (&oauth2.Token{}).WithExtra(map[string]any{ + "expires_in": "9223372036854775808", + }) + if got := tokenExpiresIn(token, 900); got != 900 { + t.Fatalf("tokenExpiresIn() = %d, want fallback 900", got) + } +} + func TestRefreshTokenDoesNotRequireNewIDToken(t *testing.T) { var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 086edaa..e83d3a8 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "math" "net/http" "strconv" "strings" @@ -45,12 +46,32 @@ func tokenExtraString(tok *oauth2.Token, key string) string { return "" } -func parseIntString(s string) (int64, error) { - s = strings.TrimSpace(s) - if s == "" { - return 0, errors.New("empty") +func oauthExpiresInSeconds(value any) (int, bool) { + var seconds int64 + switch v := value.(type) { + case int: + seconds = int64(v) + case int64: + seconds = v + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) || v > float64(math.MaxInt) { + return 0, false + } + seconds = int64(v) + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, strconv.IntSize) + if err != nil { + return 0, false + } + seconds = parsed + default: + return 0, false + } + + if seconds <= 0 || seconds > math.MaxInt || seconds > math.MaxInt64/int64(time.Second) { + return 0, false } - return strconv.ParseInt(s, 10, 64) + return int(seconds), true } func effectiveTokenScope(token *oauthToken, fallback string) string { @@ -104,19 +125,8 @@ func tokenExpiresIn(tok *oauth2.Token, fallback int) int { return fallback } if extra := tok.Extra("expires_in"); extra != nil { - switch v := extra.(type) { - case int: - return v - case int64: - return int(v) - case float64: - if v > 0 { - return int(v) - } - case string: - if n, err := parseIntString(v); err == nil && n > 0 { - return int(n) - } + if seconds, ok := oauthExpiresInSeconds(extra); ok { + return seconds } } if !tok.Expiry.IsZero() {