Skip to content

chore(deps): update dependency garminconnect to v0.3.5 [security]#262

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-garminconnect-vulnerability
Open

chore(deps): update dependency garminconnect to v0.3.5 [security]#262
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-garminconnect-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
garminconnect (changelog) 0.3.30.3.5 age confidence

garminconnect Has Insecure Permission Assignment for Garmin OAuth Token Store

CVE-2026-54447 / GHSA-wjhr-76vg-2hvc

More information

Details

Insecure Permission Assignment for Garmin OAuth Token Store
Summary

garminconnect (≤ 0.3.4) wrote its OAuth token store to disk without restricting file-system permissions. Under the default Linux umask (022) the token file garmin_tokens.json was created world-readable (0o644). The file contains the DI refresh token, so any other local user on a shared host could read it and obtain persistent, unauthorized access to the victim's Garmin Connect account.

  • Severity: High
  • Weakness: CWE-732 (Incorrect Permission Assignment for Critical Resource)
  • Affected versions: <= 0.3.4
  • Patched version: 0.3.5
Details

Client.dump() created the token directory and file with no mode argument, leaving permissions entirely to the process umask:

def dump(self, path: str) -> None:
    p = Path(path).expanduser()
    if p.is_dir() or not p.name.endswith(".json"):
        p = p / "garmin_tokens.json"
    p.parent.mkdir(parents=True, exist_ok=True)   # no mode=
    p.write_text(self.dumps())                     # no permission restriction

The serialized payload includes di_token, di_refresh_token, and di_client_id. The call is in the core library (Garmin.login(tokenstore=...) persists tokens this way), and all shipped usage examples default the token store to ~/.garminconnect.

Under umask 022 the resulting permissions were:

  • token directory → 0o755
  • garmin_tokens.json0o644 (world-readable)

A separate, unprivileged user on the same machine could read the file with a plain open() — no elevated privileges required — and extract the refresh token.

Impact

Local credential theft / privilege escalation on multi-user Linux or macOS hosts running under a permissive umask. The stolen refresh token can be exchanged for fresh access tokens via Garmin's OAuth endpoint, granting ongoing access to the victim's account (health/fitness data, activity history, device management) until the token is revoked.

Patch

Fixed in 0.3.5 (commit 77a3837). dump() now creates the directory as 0o700 and writes the token file as 0o600 regardless of umask — using os.open(..., O_CREAT|O_WRONLY|O_TRUNC, 0o600) with O_NOFOLLOW where available, plus a defensive chmod that also tightens a pre-existing loose file:

p.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with contextlib.suppress(OSError):
    p.parent.chmod(0o700)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
    flags |= os.O_NOFOLLOW
fd = os.open(p, flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
    f.write(self.dumps())
with contextlib.suppress(OSError):
    p.chmod(0o600)

Verified under umask 022: directory 0o700, file 0o600, no group/other access.

Workarounds

If you cannot upgrade immediately, restrict the token store manually and keep it owner-only:

chmod 700 ~/.garminconnect
chmod 600 ~/.garminconnect/garmin_tokens.json
Remediation
  1. Upgrade to garminconnect >= 0.3.5:
    pip install --upgrade garminconnect
  2. Fix any token file already on disk — upgrading only tightens permissions
    on the next write, so an existing world-readable file stays exposed until
    then:
    chmod 600 ~/.garminconnect/garmin_tokens.json
    # or remove it and log in again to mint a fresh token store
  3. If the file was exposed on a shared host, treat the refresh token as
    compromised.
    Re-authenticate (delete the token store and log in again) so
    a new token is issued; consider the previously stored token potentially
    read by others until rotated.
Credit

Reported by EQSTLab via a private security advisory. garminconnect thanks them for the detailed, responsible disclosure.

Severity

  • CVSS Score: 8.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

cyberjunky/python-garminconnect (garminconnect)

v0.3.5

Compare Source

What's Changed

This release includes a security fix (please upgrade), major login
resilience improvements, two new activity-editing methods, and bug fixes.

🔒 Security
  • Token store hardening (GHSA-wjhr-76vg-2hvc, CWE-732, High). Client.dump()
    previously wrote garmin_tokens.json under the process umask, leaving it
    world-readable (0o644) on the default Linux umask. The file holds the DI
    refresh token, so any local user on a shared host could read it and gain
    persistent access to the account. Tokens are now written 0o600 inside a
    0o700 directory (with O_NOFOLLOW and a defensive chmod), regardless of
    umask. Affected: ≤ 0.3.4. Patched: 0.3.5. Upgrading is strongly advised
    for anyone storing tokens on a multi-user system.
✨ Login resilience (fixes #​369)
  • In-chain token validation. Each login strategy's token is now verified
    against the API before the chain accepts it. A token the API rejects
    (401/403 — an account/region-specific condition) is discarded and the next
    strategy is tried automatically. Only definitive auth rejections fall
    through; transient 5xx/network errors never block a working login.
  • Self-healing from poisoned cached tokens. If cached tokens load but the
    API rejects them, they're discarded and a fresh credential login runs
    automatically — fixing the long-standing footgun where a stale token cache
    silently short-circuited the login chain on every run.
  • logout() is now functional (was a deprecated no-op): clears in-memory
    auth state and removes cached tokens on disk. Callable as g.logout() or
    g.logout(tokenstore).
  • New verify_login option (Garmin(..., verify_login=False)) to restore
    the legacy "first token wins" behavior.
  • New skip_strategies on the client to force or skip specific login
    strategies — useful for diagnosing which auth path works on your account.
  • Cleaner failure handling: explicit Cloudflare 403 / CAPTCHA detection,
    child/family-account detection, 429 errors preserved through the login
    wrapper, and no more stack-trace dumps for expected login failures.
🆕 New API methods
  • set_activity_description(activity_id, description) (#​367)
  • set_activity_exercise_sets(activity_id, payload) (#​368)
  • CN accounts: domain-aware service URLs for authentication (#​366)

Both new methods are available in the interactive demo under the new
✏️ Activity Editing menu.

🐛 Fixes
  • get_training_readiness now correctly annotated list[dict[str, Any]] — the
    endpoint returns a list of snapshots, not a single dict (#​361). Downstream
    tooling that validates against the return type (e.g. pydantic-based MCP
    servers) no longer breaks.
  • get_morning_training_readiness keeps defensive handling for a single-dict
    response.
  • Typed wrapper: typed.get_training_readiness() normalizes an empty response
    to [].
🧪 Internal / tooling
  • New mocked test suites for login recovery and token-store permissions; no
    network required.
  • test_strategy.py — interactive diagnostic to run each login strategy in
    isolation and tee output to a log.
Upgrade
pip install --upgrade garminconnect

Full Changelog: cyberjunky/python-garminconnect@0.3.4...0.3.5

v0.3.4

Compare Source

What's Changed

Features
  • Typed response layer (g.typed accessor) — an optional Pydantic namespace that wraps high-value read endpoints with validated, IDE-friendly models. Requires pydantic>=2.0; zero impact on existing users.

    pip install garminconnect[typed]
    g = Garmin(email, password)
    raw   = g.get_stats(date)         # dict[str, Any] — unchanged
    stats = g.typed.get_stats(date)   # DailyStats (Pydantic model)
    print(stats.total_steps, stats.resting_heart_rate)

    Supported methods in this release:

    Method Typed return
    get_stats, get_user_summary DailyStats
    get_sleep_data SleepData
    get_hrv_data HrvData | None
    get_body_battery list[BodyBatteryEntry]
    get_training_readiness list[TrainingReadiness]
    get_activities_by_date list[Activity]

    Validation failures raise GarminConnectResponseValidationError with .raw preserved. Extra/missing fields are tolerated so schema drift doesn't break running apps. Marked experimental — the surface may evolve based on feedback.

Bug Fixes
  • CN account token refresh — DI OAuth2 token endpoint now routes to diauth.garmin.cn for is_cn=True accounts. Previously the .com endpoint was always used, causing refresh failures (invalid_grant) after the ~4-hour access token TTL expired. Cron-driven sync jobs and long-running sessions on CN accounts are now fixed. (PR #​360 by @​MidnightV1)

Contributors

Full Changelog: cyberjunky/python-garminconnect@0.3.3...0.3.4


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

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.

0 participants