Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions src/tr_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<empty>"
snippet = " ".join(text.split())
if len(snippet) > limit:
snippet = snippet[:limit] + "…"
return snippet


# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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)}"
)


Expand Down Expand Up @@ -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)}"
)


Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/tr_api/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""
from __future__ import annotations

import os
import time
from http.cookiejar import Cookie, MozillaCookieJar
from pathlib import Path
Expand Down Expand Up @@ -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)


Expand Down
6 changes: 4 additions & 2 deletions src/tr_api/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


# ---------------------------------------------------------------------------
Expand All @@ -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",
Expand Down
Loading