-
Notifications
You must be signed in to change notification settings - Fork 0
Honour a review decision over the match, and keep credentials out of error messages #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |
|
|
||
| import json | ||
| import time | ||
| import urllib.parse | ||
|
|
||
| import requests | ||
|
|
||
|
|
@@ -26,6 +27,27 @@ def __init__(self, message: str, status: int = 0, body: str = ""): | |
| self.body = body | ||
|
|
||
|
|
||
| def _safe_url(url: str) -> str: | ||
| """the url with any user:password@ removed, for showing in messages. | ||
|
|
||
| a lidarr url can carry basic-auth credentials — behind a reverse proxy | ||
| that asks for them, http://user:pw@host is how you get through. those | ||
| end up in every connection error otherwise, and errors get pasted into | ||
| bug reports. | ||
| """ | ||
| try: | ||
| parts = urllib.parse.urlsplit(url) | ||
| host = parts.hostname | ||
| except ValueError: | ||
| return url | ||
| if not host: | ||
| return url # nothing parsed as a host; nothing to hide | ||
| if parts.port: | ||
| host = f"{host}:{parts.port}" | ||
| return urllib.parse.urlunsplit( | ||
| (parts.scheme, host, parts.path, parts.query, parts.fragment)) | ||
|
|
||
|
|
||
| def _is_duplicate(status: int, body: str) -> bool: | ||
| """true if lidarr is saying "this album is already here". | ||
|
|
||
|
|
@@ -59,42 +81,54 @@ def _is_duplicate(status: int, body: str) -> bool: | |
| class Lidarr: | ||
| def __init__(self, url: str, api_key: str): | ||
| self.base = url.rstrip("/") | ||
| # what the user sees in errors: the same address minus any | ||
| # credentials, so a pasted traceback doesn't carry a password | ||
| self.shown = _safe_url(self.base) | ||
| self.s = requests.Session() | ||
| self.s.headers["X-Api-Key"] = api_key | ||
|
|
||
| def _scrub(self, text) -> str: | ||
| """text with this instance's url swapped for the credential-free one.""" | ||
| out = str(text) | ||
| return out.replace(self.base, self.shown) if self.base != self.shown else out | ||
|
|
||
| def _call(self, path: str, method: str = "GET", **kw): | ||
| url = f"{self.base}/api/v1/{path}" | ||
| try: | ||
| r = self.s.request(method, url, timeout=60, **kw) | ||
| except requests.ConnectionError as e: | ||
| raise LidarrError( | ||
| f"Can't reach Lidarr at {self.base} — is it running, and is the " | ||
| f"Can't reach Lidarr at {self.shown} — is it running, and is the " | ||
| f"URL right? (the address you use in your browser)") from e | ||
| except requests.Timeout as e: | ||
| raise LidarrError(f"Lidarr at {self.base} timed out.") from e | ||
| raise LidarrError(f"Lidarr at {self.shown} timed out.") from e | ||
| except (requests.exceptions.InvalidSchema, | ||
| requests.exceptions.MissingSchema, | ||
| requests.exceptions.InvalidURL) as e: | ||
| raise LidarrError( | ||
| f"{self.base} is not a URL Lidarr can be reached at — it needs " | ||
| f"{self.shown} is not a URL Lidarr can be reached at — it needs " | ||
| f"to start with http:// or https://") from e | ||
| except requests.RequestException as e: | ||
| raise LidarrError(f"Lidarr request failed: {e}") from e | ||
| # requests quotes the url it was given, credentials and all | ||
| raise LidarrError( | ||
| f"Lidarr request to {self.shown} failed: {self._scrub(e)}") from e | ||
|
Comment on lines
99
to
+114
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
python - <<'PY'
import traceback
secret = "http://admin:hunter2@lidarr.test:8686"
try:
raise RuntimeError(secret)
except RuntimeError as cause:
try:
raise ValueError("sanitized") from cause
except ValueError as error:
assert secret in "".join(traceback.format_exception(error))
PYRepository: alperien/chartarr Length of output: 155 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf 'Files matching lidarr.py:\n'
fd -a 'lidarr\.py$' . || true
printf '\nRelevant snippets and imports:\n'
while IFS= read -r f; do
printf '\n=== %s ===\n' "$f"
wc -l "$f"
ast-grep outline "$f" --view compact || true
sed -n '1,150p' "$f" | cat -n
done < <(fd 'lidarr\.py$' .)
printf '\nSearch for LidarrError definition and tests involving LidarrError:\n'
rg -n "class LidarrError|LidarrError|requests\.ConnectionError|Lidarr request|format_exception|traceback" .Repository: alperien/chartarr Length of output: 344 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf 'Relevant snippets and imports:\n'
sed -n '1,150p' chartarr/lidarr.py | cat -n
printf '\nSearch for LidarrError definition and tests involving LidarrError:\n'
rg -n "class LidarrError|LidarrError|requests\.ConnectionError|Lidarr request|format_exception|traceback" .Repository: alperien/chartarr Length of output: 9752 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import traceback
import urllib.parse
base = "http://admin:hunter2@lidarr.test:8686"
safe = "http://lidarr.test:8686"
try:
raise RuntimeError(f"connection refused to {base}")
except RuntimeError as cause:
try:
raise RuntimeError(f"Lidarr request to {safe} failed: connection refused to {base}") from cause
except RuntimeError as error:
text = "".join(traceback.format_exception(error))
print("contains_base:", base in text)
print("contains_safe:", safe in text)
print("contains_credentials:", "`@lidarr.test`" in text)
for line in text.splitlines():
if base in line or safe in line:
print("relevant_trace_line:", line)
PY
printf '\nTests around credential-bearing errors:\n'
sed -n '230,360p' tests/test_lidarr.py | cat -n
printf '\ncli prints traceback usage:\n'
rg -n "traceback|LidarrError|print\\(" chartarr/cli.py tests/test_lidarr.pyRepository: alperien/chartarr Length of output: 803 Suppress the credential-bearing exception chain. The sanitized 🤖 Prompt for AI Agents |
||
|
|
||
| if r.status_code == 401: | ||
| raise LidarrError( | ||
| "Lidarr rejected the API key (401). Copy it from " | ||
| "Settings → General → Security → API Key.", status=401) | ||
| if r.status_code >= 400: | ||
| raise LidarrError(f"HTTP {r.status_code}: {r.text[:300] or 'no details'}", | ||
| status=r.status_code, body=r.text) | ||
| # a proxy's error page can quote the request url back at us | ||
| raise LidarrError( | ||
| f"HTTP {r.status_code}: {self._scrub(r.text)[:300] or 'no details'}", | ||
| status=r.status_code, body=r.text) | ||
|
Comment on lines
120
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Do not retain the raw proxy response body on The displayed HTTP message is scrubbed, but 🤖 Prompt for AI Agents |
||
| if not r.text: | ||
| return None | ||
| try: | ||
| return r.json() | ||
| except ValueError as e: | ||
| raise LidarrError( | ||
| f"Lidarr returned something that isn't JSON — is {self.base} " | ||
| f"Lidarr returned something that isn't JSON — is {self.shown} " | ||
| f"really Lidarr, and not a login page or another service?") from e | ||
|
|
||
| def status(self) -> dict: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: alperien/chartarr
Length of output: 155
🏁 Script executed:
Repository: alperien/chartarr
Length of output: 9600
🏁 Script executed:
Repository: alperien/chartarr
Length of output: 3489
Redact malformed authorities before returning them.
urlsplit("http://admin:hunter2@")hashostname is Nonebut still emitsadmin:hunter2@, so_safe_url()stores the credential-bearing string inself.shown. A malformed IPv6 authority with userinfo also raises before any scrubbing. Strip userinfo fromparts.netlocfor parsed URLs and from parse-error fallbacks before storingshown, otherwise connection/invalid-URL tracebacks can still expose credentials.🤖 Prompt for AI Agents