mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 04:48:25 +00:00
fix: keep server paths and URLs out of an error response and a debug log (#1558)
Two small leaks from going through the open CodeQL alerts (code scanning alerts 100 and 123). The local download route returned `str(e)` on an OSError, and for a missing or unreadable file that text is the file's path on the server (`[Errno 2] No such file or directory: '/audiobooks/Author/Title.epub'`). #1422 cuts a non-admin's download path down to the file name everywhere else, so this put it back. The route now answers "Could not read the file" and the detail stays in the log. `should_bypass_proxy` logged the whole request URL at debug when it could not be parsed. URLs can carry credentials, so it logs only the exception now. Each change has a test that fails without it. The full non-e2e suite, ruff and basedpyright pass.
This commit is contained in:
1 parent
003df0b686
commit
4f97fc9bec
4 files changed
+58
-2
No files matched your search
@@ -53,7 +53,7 @@ def should_bypass_proxy(url: str) -> bool:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
except ValueError as e:
|
||||
logger.debug("Failed to parse URL for proxy bypass check: %s - %s", url, e)
|
||||
logger.debug("Failed to parse URL for proxy bypass check: %s", e)
|
||||
return False
|
||||
|
||||
if not hostname:
|
||||
|
||||
+3
-1
@@ -2022,8 +2022,10 @@ def api_local_download() -> Response | tuple[Response, int]:
|
||||
return send_file(file_paths[0], download_name=file_name, as_attachment=True)
|
||||
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
# The details are logged. The message of an OSError names the file's path on the server,
|
||||
# which a non-admin never sees anywhere else.
|
||||
logger.error_trace(f"Local download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "Could not read the file"}), 500
|
||||
|
||||
|
||||
@app.route("/api/covers/<cover_id>", methods=["GET"])
|
||||
|
||||
@@ -357,6 +357,44 @@ class TestActivityRoutes:
|
||||
assert response.status_code == 200
|
||||
assert response.data == file_bytes
|
||||
|
||||
def test_localdownload_error_does_not_reveal_the_server_path(
|
||||
self, main_module, client, tmp_path
|
||||
):
|
||||
"""A failed read answers with a fixed message, not the OSError text and its path."""
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
task_id = f"live-localdownload-{uuid.uuid4().hex[:8]}"
|
||||
file_path = tmp_path / "Hidden Author" / "Hidden Title.epub"
|
||||
file_path.parent.mkdir()
|
||||
file_path.write_bytes(b"payload")
|
||||
|
||||
task = DownloadTask(
|
||||
task_id=task_id,
|
||||
source="direct_download",
|
||||
title="Live Local Download",
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
download_path=str(file_path),
|
||||
)
|
||||
assert main_module.backend.book_queue.add(task) is True
|
||||
|
||||
failure = FileNotFoundError(2, "No such file or directory", str(file_path))
|
||||
try:
|
||||
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
|
||||
with (
|
||||
patch.object(main_module, "get_auth_mode", return_value="builtin"),
|
||||
patch.object(main_module, "send_file", side_effect=failure),
|
||||
):
|
||||
response = client.get(f"/api/localdownload?id={task_id}")
|
||||
finally:
|
||||
main_module.backend.book_queue.cancel_download(task_id)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.get_json() == {"error": "Could not read the file"}
|
||||
assert "Hidden" not in response.get_data(as_text=True)
|
||||
assert str(tmp_path) not in response.get_data(as_text=True)
|
||||
|
||||
def test_localdownload_zips_a_live_multi_file_download(self, main_module, client, tmp_path):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
@@ -59,3 +59,19 @@ def test_get_proxies_returns_socks_proxy_for_both_schemes(monkeypatch):
|
||||
"http": "socks5://proxy.local:1080",
|
||||
"https": "socks5://proxy.local:1080",
|
||||
}
|
||||
|
||||
|
||||
def test_a_url_that_fails_to_parse_is_not_written_to_the_log(monkeypatch):
|
||||
"""The proxy check logs why a URL did not parse, never the URL itself."""
|
||||
from unittest.mock import patch
|
||||
|
||||
network = _set_proxy_config(monkeypatch, NO_PROXY="localhost")
|
||||
# An unterminated IPv6 literal makes urlparse raise ValueError.
|
||||
url = "http://user:s3cret@[::1/path?apikey=s3cret"
|
||||
|
||||
with patch.object(network.logger, "debug") as debug:
|
||||
assert network.should_bypass_proxy(url) is False
|
||||
|
||||
logged = " ".join(str(arg) for call in debug.call_args_list for arg in call.args)
|
||||
assert "Failed to parse URL" in logged
|
||||
assert "s3cret" not in logged
|
||||
Reference in new issue
Block a user