Skip to content

Commit f74174a

Browse files
authored
Merge commit from fork
fix: write token store with owner-only permissions (GHSA-wjhr-76vg-2hvc)
2 parents d8cf8f7 + 77a3837 commit f74174a

3 files changed

Lines changed: 105 additions & 3 deletions

File tree

garminconnect/client.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import http.cookiejar
1414
import json
1515
import logging
16+
import os
1617
import random
1718
import re
1819
import time
@@ -1191,12 +1192,33 @@ def dumps(self) -> str:
11911192
return json.dumps(data)
11921193

11931194
def dump(self, path: str) -> None:
1194-
"""Write tokens safely to disk."""
1195+
"""Write tokens safely to disk with owner-only permissions.
1196+
1197+
The token file contains the DI refresh token, which grants persistent
1198+
account access. It is written as 0o600 inside a 0o700 directory so a
1199+
permissive process umask can't leave it world-readable on a shared host
1200+
(GHSA-wjhr-76vg-2hvc).
1201+
"""
11951202
p = Path(path).expanduser()
11961203
if p.is_dir() or not p.name.endswith(".json"):
11971204
p = p / "garmin_tokens.json"
1198-
p.parent.mkdir(parents=True, exist_ok=True)
1199-
p.write_text(self.dumps())
1205+
p.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
1206+
# mkdir's mode is subject to umask and a no-op if the dir already
1207+
# exists; chmod enforces 0o700 unconditionally.
1208+
with contextlib.suppress(OSError):
1209+
p.parent.chmod(0o700)
1210+
# Open with O_CREAT mode 0o600 (and O_NOFOLLOW where available so a
1211+
# pre-planted symlink can't redirect the write) instead of write_text,
1212+
# which would create the file under the umask first.
1213+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
1214+
if hasattr(os, "O_NOFOLLOW"):
1215+
flags |= os.O_NOFOLLOW
1216+
fd = os.open(p, flags, 0o600)
1217+
with os.fdopen(fd, "w", encoding="utf-8") as token_file:
1218+
token_file.write(self.dumps())
1219+
# Enforce 0o600 even if the file pre-existed with looser permissions.
1220+
with contextlib.suppress(OSError):
1221+
p.chmod(0o600)
12001222

12011223
def load(self, path: str) -> None:
12021224
try:

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ module = [
5656
"test_retry_decorator",
5757
"test_workout_constants",
5858
"test_login_recovery",
59+
"test_token_permissions",
5960
"conftest",
6061
]
6162
disallow_untyped_defs = false
@@ -195,6 +196,7 @@ unfixable = [] # Allow all fixes, including unsafe ones
195196
"demo.py" = ["T20", "S101", "ERA", "RUF001", "PTH", "PERF401", "PERF203", "PLR0911", "D103"] # Demo script - various user-facing patterns
196197
"example.py" = ["S110", "PLR0911", "T20", "PERF203"] # Example script - simple error handling is intentional
197198
"test_strategy.py" = ["T20", "D103"] # Interactive login-strategy diagnostic script
199+
"tests/test_token_permissions.py" = ["S105"] # synthetic token literals in fixtures
198200

199201
[tool.coverage.run]
200202
source = ["garminconnect"]

tests/test_token_permissions.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Security regression tests for token-store file permissions.
2+
3+
Guards against GHSA-wjhr-76vg-2hvc: ``Client.dump()`` must write the token
4+
file (which holds the DI refresh token) owner-only (0o600) inside an
5+
owner-only directory (0o700), regardless of the process umask, so it can't be
6+
left world-readable on a shared host.
7+
8+
POSIX-only — file mode bits are not meaningful on Windows.
9+
"""
10+
11+
import os
12+
import stat
13+
import sys
14+
from pathlib import Path
15+
16+
import pytest
17+
18+
from garminconnect.client import Client
19+
20+
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes only")
21+
22+
23+
def _make_client() -> Client:
24+
c = Client()
25+
c.di_token = "ACCESS_TOKEN_EXAMPLE"
26+
c.di_refresh_token = "REFRESH_TOKEN_EXAMPLE"
27+
c.di_client_id = "CLIENT_ID_EXAMPLE"
28+
return c
29+
30+
31+
def _mode(path) -> int:
32+
return stat.S_IMODE(Path(path).stat().st_mode)
33+
34+
35+
def test_dump_creates_owner_only_token_file(tmp_path):
36+
"""Under a permissive umask the token file must still be 0o600."""
37+
old_umask = os.umask(0o022)
38+
try:
39+
token_dir = tmp_path / "tokens"
40+
_make_client().dump(str(token_dir))
41+
token_file = token_dir / "garmin_tokens.json"
42+
43+
assert _mode(token_file) == 0o600, oct(_mode(token_file))
44+
assert _mode(token_dir) == 0o700, oct(_mode(token_dir))
45+
# World/group bits must be clear
46+
assert not (_mode(token_file) & (stat.S_IRWXG | stat.S_IRWXO))
47+
assert not (_mode(token_dir) & (stat.S_IRWXG | stat.S_IRWXO))
48+
finally:
49+
os.umask(old_umask)
50+
51+
52+
def test_dump_tightens_preexisting_loose_permissions(tmp_path):
53+
"""A pre-existing world-readable dir/file is tightened on dump."""
54+
old_umask = os.umask(0o022)
55+
try:
56+
token_dir = tmp_path / "tokens"
57+
token_dir.mkdir(mode=0o755)
58+
token_file = token_dir / "garmin_tokens.json"
59+
token_file.write_text("{}")
60+
token_file.chmod(0o644)
61+
62+
_make_client().dump(str(token_dir))
63+
64+
assert _mode(token_file) == 0o600, oct(_mode(token_file))
65+
assert _mode(token_dir) == 0o700, oct(_mode(token_dir))
66+
finally:
67+
os.umask(old_umask)
68+
69+
70+
def test_dump_explicit_json_path(tmp_path):
71+
"""A direct ``*.json`` path is also written owner-only."""
72+
old_umask = os.umask(0o022)
73+
try:
74+
token_file = tmp_path / "store" / "garmin_tokens.json"
75+
_make_client().dump(str(token_file))
76+
assert _mode(token_file) == 0o600, oct(_mode(token_file))
77+
finally:
78+
os.umask(old_umask)

0 commit comments

Comments
 (0)