diff --git a/src/tr_api/auth.py b/src/tr_api/auth.py index 932ab32..c23754f 100644 --- a/src/tr_api/auth.py +++ b/src/tr_api/auth.py @@ -93,6 +93,26 @@ def _login_headers(waf_token: str) -> dict[str, str]: } +# --------------------------------------------------------------------------- +# Redaction +# --------------------------------------------------------------------------- +def _redact_body(text: str | None, limit: int = 120) -> str: + """Return a short, safe snippet of an upstream response body for error + messages. + + Raw upstream bodies can carry auth-flow detail and, if the exception is + logged, leak it. We collapse whitespace and truncate hard so we never + attach a full raw body to a user-facing exception, while keeping enough + (status code is added by the caller) to debug. + """ + if not text: + return "" + snippet = " ".join(text.split()) + if len(snippet) > limit: + snippet = snippet[:limit] + "…" + return snippet + + # --------------------------------------------------------------------------- # Errors # --------------------------------------------------------------------------- @@ -176,7 +196,7 @@ def _parse_initiate_response(r: requests.Response) -> InitiateResult: try: j = r.json() except ValueError as e: - raise LoginError(f"Initiate succeeded but body wasn't JSON: {r.text[:200]}") from e + raise LoginError(f"Initiate succeeded but body wasn't JSON: {_redact_body(r.text)}") from e pid = j.get("processId") if not pid: raise LoginError(f"Initiate returned 200 but no processId: {j}") @@ -230,7 +250,7 @@ def _parse_initiate_response(r: requests.Response) -> InitiateResult: ) raise LoginError( - f"Initiate failed: status={r.status_code} body={r.text[:300]}" + f"Initiate failed: status={r.status_code} body={_redact_body(r.text)}" ) @@ -291,7 +311,7 @@ def complete_login( ) raise LoginError( - f"Complete failed: status={r.status_code} body={r.text[:300]}" + f"Complete failed: status={r.status_code} body={_redact_body(r.text)}" ) @@ -401,7 +421,7 @@ def initiate_login_v2( try: j = r.json() except ValueError as e: - raise LoginError(f"v2 initiate 200 but body wasn't JSON: {r.text[:200]}") from e + raise LoginError(f"v2 initiate 200 but body wasn't JSON: {_redact_body(r.text)}") from e pid = j.get("processId") if not pid: raise LoginError(f"v2 initiate 200 but no processId: {j}") @@ -553,7 +573,7 @@ def refresh_session(profile: Profile) -> RefreshResult: ok=False, status_code=r.status_code, cookies_changed=[], - error=f"refresh rejected: status={r.status_code} body={r.text[:200]}", + error=f"refresh rejected: status={r.status_code} body={_redact_body(r.text)}", ) # TR returned 200 — its Set-Cookie headers have already updated diff --git a/src/tr_api/cookies.py b/src/tr_api/cookies.py index 92dc8e2..1f1d6b8 100644 --- a/src/tr_api/cookies.py +++ b/src/tr_api/cookies.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +import os import time from http.cookiejar import Cookie, MozillaCookieJar from pathlib import Path @@ -161,9 +162,14 @@ def save_to_file(cookies: dict[str, str], path: Path | str) -> int: ) ) - jar.save(ignore_discard=True, ignore_expires=True) - # Restrict permissions: cookies are session secrets. - path.chmod(0o600) + # Atomic write: cookies are session secrets, so create the real file with + # 0600 already in place (write to a sibling .tmp, chmod, then rename). + # Chmod-after-write on the final path leaves a umask-race window where the + # file is briefly world-readable on a shared host. + tmp = path.with_name(path.name + ".tmp") + jar.save(str(tmp), ignore_discard=True, ignore_expires=True) + os.chmod(tmp, 0o600) + os.replace(tmp, path) return len(jar) diff --git a/src/tr_api/profiles.py b/src/tr_api/profiles.py index 02ecd58..af5ba92 100644 --- a/src/tr_api/profiles.py +++ b/src/tr_api/profiles.py @@ -71,7 +71,9 @@ def _validate_phone(phone: str) -> str: def _ensure_dirs() -> None: - PROFILES_DIR.mkdir(parents=True, exist_ok=True) + # 0700: this tree holds session cookies — keep names unlistable to others. + BASE_DIR.mkdir(mode=0o700, exist_ok=True) + PROFILES_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) # --------------------------------------------------------------------------- @@ -91,7 +93,7 @@ def create(phone: str, jurisdiction: str = "DE", name: str | None = None) -> Pro name=name, created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), ) - prof.dir.mkdir(parents=True, exist_ok=True) + prof.dir.mkdir(mode=0o700, parents=True, exist_ok=True) prof.meta_file.write_text( json.dumps(asdict(prof), indent=2, ensure_ascii=False) + "\n", encoding="utf-8",