diff --git a/garminconnect/client.py b/garminconnect/client.py index 55c92b9..a08084d 100644 --- a/garminconnect/client.py +++ b/garminconnect/client.py @@ -13,6 +13,7 @@ import http.cookiejar import json import logging +import math import os import random import re @@ -1314,8 +1315,20 @@ def _token_expires_soon(self) -> bool: payload = _decode_jwt_payload(str(token)) if not payload: return False - exp = payload.get("exp") - return bool(exp and time.time() > int(exp) - 900) + # 'exp' is a server-controlled claim from an unverified JWT payload, so + # coerce it defensively: a non-numeric string, container, boolean, + # non-finite or overflowing value must not raise (TypeError/ValueError/ + # OverflowError) and take down every request. + raw_exp = payload.get("exp") + if isinstance(raw_exp, bool): + return False + try: + exp = float(raw_exp) # type: ignore[arg-type] + except (TypeError, ValueError, OverflowError): + return False + if not math.isfinite(exp): + return False + return time.time() > exp - 900 def _refresh_session(self) -> None: """Refresh auth — DI token refresh or legacy JWT_WEB CAS refresh.""" diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 29c19c8..7a1ac85 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -1740,6 +1740,43 @@ def test_token_expires_soon_falls_back_to_jwt_web(self): c.jwt_web = token assert c._token_expires_soon() is True + def test_token_expires_soon_accepts_numeric_string_exp(self): + # Some tokens encode 'exp' as a numeric string; it must still parse. + c = client_mod.Client(verify_login=False) + token = _make_jwt( + {"alg": "RS256"}, {"exp": str(int(time.time()) + 60)} + ) + c.di_token = token + assert c._token_expires_soon() is True + + @pytest.mark.parametrize( + "exp", + [ + "not-a-number", # non-numeric string -> would raise ValueError + {"a": 1}, # non-empty dict -> would raise TypeError + [1], # non-empty list -> would raise TypeError + None, # missing / null + "", # empty string + {}, # empty dict + [], # empty list + True, # bool is an int subclass; must be rejected + False, + "inf", # non-finite string -> parses, must be rejected + "-inf", + "nan", + 10**400, # huge JSON int -> would raise OverflowError + ], + ) + def test_token_expires_soon_survives_malformed_exp(self, exp: Any): + # A hostile/MITM server can return an access token whose unverified + # 'exp' claim is non-numeric, boolean, non-finite or overflowing. + # int()/float() coercion must not raise and take down every + # subsequent request. Malformed 'exp' is treated as "not expiring + # soon" so the client stays usable. + c = client_mod.Client(verify_login=False) + c.di_token = _make_jwt({"alg": "RS256"}, {"exp": exp}) + assert c._token_expires_soon() is False + # --------------------------------------------------------------------------- # update_workout (in-place PUT)