Skip to content

security: validate JWT exp claim to prevent client DoS - #408

Merged
cyberjunky merged 2 commits into
masterfrom
security/jwt-exp-validation
Aug 10, 2026
Merged

security: validate JWT exp claim to prevent client DoS#408
cyberjunky merged 2 commits into
masterfrom
security/jwt-exp-validation

Conversation

@cyberjunky

@cyberjunky cyberjunky commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes a client-side denial-of-service reported externally (CVSS 4.0 8.2 / High).

_token_expires_soon() called int(exp) on the exp claim taken directly from _decode_jwt_payload() — an unverified, server-controlled JWT payload — with no type guard anywhere in the chain.

int(exp) raises:

  • ValueError for a non-numeric string (e.g. "not-a-number")
  • TypeError for a non-empty container (e.g. {"a": 1}, [1])

Impact

  • A hostile / compromised / MITM server returning a crafted access_token poisons self.di_token. Every public method routed through _run_request (get/post/download/connectapi/…) then raised the raw exception before any HTTP request.
  • The exception is not a GarminConnect* subclass, so callers catching by library type don't handle it — can crash the host app.
  • The poisoned token is persisted by dump() / restored by load(), so a single crafted response leaves the client unusable across restarts until the token file is manually deleted.

Fix

Coerce exp with float() inside a try/except (TypeError, ValueError), returning False (treat as "not expiring soon", client stays usable) when it can't be parsed. This also transparently supports legitimate numeric-string exp values. A genuinely expired token is still caught by the normal 401 path.

Tests

Added to tests/test_garmin_unit.py::TestJwtHandling:

  • test_token_expires_soon_accepts_numeric_string_exp
  • test_token_expires_soon_survives_malformed_exp — parametrized over "not-a-number", {"a":1}, [1], None, "", {}, []

15/15 JWT tests pass; no regressions in the unit suite.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of invalid or missing token expiration values.
    • Prevented errors when expiration data is non-numeric, empty, null, boolean, non-finite, overflowing, or provided in an unexpected format.
    • Numeric-string expiration timestamps are now processed correctly.
    • Correctly identifies tokens with expired or zero-valued expiration timestamps.
    • Maintained the existing 15-minute warning threshold for valid expiration timestamps.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 648cefc2-0957-4c3d-a8f8-55d51ccc0280

📥 Commits

Reviewing files that changed from the base of the PR and between 5528e38 and bc173b4.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

Walkthrough

The JWT expiry check validates exp claims before comparison. Invalid claims return False without raising exceptions. Unit tests cover valid numeric strings and malformed values.

Changes

JWT expiry validation

Layer / File(s) Summary
Defensive expiry check and test coverage
garminconnect/client.py, tests/test_garmin_unit.py
_token_expires_soon() handles numeric, boolean, non-finite, overflowing, null, and collection exp claims. Tests verify valid numeric strings and invalid values without exceptions.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: tamcore

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: validating the JWT exp claim to prevent client denial-of-service.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/jwt-exp-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@garminconnect/client.py`:
- Around line 1316-1323: Update _token_expires_soon() to reject boolean exp
values before conversion, catch OverflowError alongside TypeError and ValueError
from float(), and return False for non-finite results using an appropriate
finiteness check. Preserve the existing expiry comparison for valid finite
numeric values.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2b9fe587-70c3-467b-aa0b-235524e1528d

📥 Commits

Reviewing files that changed from the base of the PR and between 76452a5 and b818d2b.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

Comment thread garminconnect/client.py
@cyberjunky cyberjunky changed the title security: validate JWT exp claim to prevent client DoS (#3997) security: validate JWT exp claim to prevent client DoS Aug 10, 2026
@cyberjunky
cyberjunky force-pushed the security/jwt-exp-validation branch 3 times, most recently from 0f39ab1 to ac24eb4 Compare August 10, 2026 12:22
@cyberjunky
cyberjunky force-pushed the security/jwt-exp-validation branch from ac24eb4 to 5475ea2 Compare August 10, 2026 12:27

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_garmin_unit.py`:
- Around line 1750-1769: Extend test_token_expires_soon_survives_malformed_exp
with boolean values and non-finite numeric strings such as "-inf", asserting
_token_expires_soon() remains False; alternatively, update
Client._token_expires_soon to reject booleans and non-finite expiry values
before comparison.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 03a875bb-34b5-4b5e-82c0-857679fc1d77

📥 Commits

Reviewing files that changed from the base of the PR and between b818d2b and 5475ea2.

📒 Files selected for processing (1)
  • tests/test_garmin_unit.py

Comment thread tests/test_garmin_unit.py
@cyberjunky
cyberjunky force-pushed the security/jwt-exp-validation branch from 5475ea2 to 5528e38 Compare August 10, 2026 13:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
garminconnect/client.py (2)

1317-1324: ⚠️ Potential issue | 🟠 Major

Reapply the unresolved non-finite and overflow guards.

float() still accepts True and False, accepts values such as "-inf", and raises OverflowError for a very large JSON integer. These values can force repeated refresh attempts or abort _run_request(). Reject booleans, catch OverflowError, and require math.isfinite(exp). This repeats the previous review finding.

Proposed fix
+import math
+
+        raw_exp = payload.get("exp")
+        if isinstance(raw_exp, bool):
+            return False
         try:
-            exp = float(payload.get("exp"))  # type: ignore[arg-type]
-        except (TypeError, ValueError):
+            exp = float(raw_exp)
+        except (TypeError, ValueError, OverflowError):
             return False
+        if not math.isfinite(exp):
+            return False
#!/bin/bash
set -euo pipefail

python - <<'PY'
import math

for value in (True, False, "-inf", "inf", "nan", 10**400):
    try:
        parsed = float(value)
    except Exception as exc:
        print(repr(value), type(exc).__name__)
    else:
        print(repr(value), parsed, math.isfinite(parsed))
PY
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@garminconnect/client.py` around lines 1317 - 1324, Update the expiration
parsing in the JWT validation logic around payload.get("exp") to reject boolean
values, catch OverflowError alongside TypeError and ValueError, and return False
for non-finite results using math.isfinite(exp). Preserve the existing
refresh-threshold comparison only for finite numeric expiration values.

1317-1324: ⚠️ Potential issue | 🟠 Major

Keep malformed exp handling and regression coverage consistent. float() still accepts booleans and non-finite strings and can overflow on very large JSON integers.

  • garminconnect/client.py#L1317-L1324: reject booleans, catch OverflowError, and reject non-finite values with math.isfinite.
  • tests/test_garmin_unit.py#L1752-L1771: add True, False, "-inf", and 10**400 cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@garminconnect/client.py` around lines 1317 - 1324, Update the JWT expiration
parsing in garminconnect/client.py lines 1317-1324 to reject boolean values
before conversion, catch OverflowError alongside TypeError and ValueError, and
return False for non-finite results using math.isfinite. Extend the expiration
regression tests in tests/test_garmin_unit.py lines 1752-1771 with True, False,
"-inf", and 10**400 cases, verifying each malformed value is rejected without
raising.
tests/test_garmin_unit.py (1)

1752-1771: ⚠️ Potential issue | 🟡 Minor

Add regression cases for values that float() accepts or overflows on.

The current parameter list covers TypeError and ValueError, but not boolean coercion, non-finite values, or OverflowError. Add True, False, "-inf", and 10**400 so the unresolved paths in garminconnect/client.py remain covered. This repeats the previous review finding.

Proposed test cases
         [
+            True,
+            False,
+            "-inf",
+            10**400,
             "not-a-number",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_garmin_unit.py` around lines 1752 - 1771, Extend the parameter
list in test_token_expires_soon_survives_malformed_exp with True, False, "-inf",
and 10**400, preserving the existing assertion that _token_expires_soon()
returns False for each malformed expiration value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@garminconnect/client.py`:
- Around line 1317-1324: Update the expiration parsing in the JWT validation
logic around payload.get("exp") to reject boolean values, catch OverflowError
alongside TypeError and ValueError, and return False for non-finite results
using math.isfinite(exp). Preserve the existing refresh-threshold comparison
only for finite numeric expiration values.
- Around line 1317-1324: Update the JWT expiration parsing in
garminconnect/client.py lines 1317-1324 to reject boolean values before
conversion, catch OverflowError alongside TypeError and ValueError, and return
False for non-finite results using math.isfinite. Extend the expiration
regression tests in tests/test_garmin_unit.py lines 1752-1771 with True, False,
"-inf", and 10**400 cases, verifying each malformed value is rejected without
raising.

In `@tests/test_garmin_unit.py`:
- Around line 1752-1771: Extend the parameter list in
test_token_expires_soon_survives_malformed_exp with True, False, "-inf", and
10**400, preserving the existing assertion that _token_expires_soon() returns
False for each malformed expiration value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0ce0c394-bdcc-46e3-a36b-97cf39bdb77d

📥 Commits

Reviewing files that changed from the base of the PR and between 5475ea2 and 5528e38.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

_token_expires_soon() called int(exp) on the 'exp' claim from an
unverified, server-controlled JWT payload with no type guard. A hostile
or MITM server returning a non-numeric exp (string -> ValueError, or a
non-empty container -> TypeError) crashed every _run_request call before
any HTTP request. The raw exception is not a GarminConnect* subclass, and
the poisoned token is persisted by dump()/load(), so a single crafted
response makes the client unusable across restarts (report 3997).

Coerce exp with float() inside try/except, returning False (treat as not
expiring soon) when it cannot be parsed. This also supports legitimate
numeric-string exp values. Adds parametrized tests covering all PoC
payloads.
@cyberjunky
cyberjunky force-pushed the security/jwt-exp-validation branch from 5528e38 to c8c83d0 Compare August 10, 2026 13:18
Addresses CodeRabbit review on PR #408: float() coercion still accepted
booleans (True -> 1.0, always 'expiring soon'), non-finite strings like
'inf' (repeated refresh attempts), and raised uncaught OverflowError for
huge JSON integers -- the same crash-before-every-request DoS class the
original fix targeted.
@cyberjunky
cyberjunky merged commit ea72bdb into master Aug 10, 2026
4 checks passed
@cyberjunky
cyberjunky deleted the security/jwt-exp-validation branch August 10, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant