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.
<= 0.3.40.3.5Client.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:
0o755garmin_tokens.json → 0o644 (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.
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...
0.3.5Exploitability
AV:LAC:LPR:LUI:NScope
S:CImpact
C:HI:HA:N8.4/CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N