Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions garminconnect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import http.cookiejar
import json
import logging
import math
import os
import random
import re
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def _refresh_session(self) -> None:
"""Refresh auth — DI token refresh or legacy JWT_WEB CAS refresh."""
Expand Down
37 changes: 37 additions & 0 deletions tests/test_garmin_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# ---------------------------------------------------------------------------
# update_workout (in-place PUT)
Expand Down