Files
shelfmark/tests/core/test_image_cache.py
T
splitsec2 b05bbc8c58 fix(covers): key the cover cache by the image URL, not the caller-supplied id (#1505)
`/api/covers/<id>?url=...` cached the fetched image under the id in the
path, and the caller chooses that id. A request with another book's id
and a different URL put its image in that book's cache entry, and every
user then got it.

The key is now derived on the server from the image URL (a hash of it),
and the id in the path stays only so links keep their shape. The SSRF
checks and `fetch_and_cache` are unchanged, and `transform_cover_url`
links already carry the URL, so they keep working. The URL is decoded
first now, so a missing one is a 404 and bad encoding a 400, both before
any fetch.

I made the key the URL alone, not URL plus id. Either stops the
overwrite, and the URL alone lets one image be shared when the same
cover appears under two ids (the viewer redaction rewrites ids and still
hits the cache).

Entries cached under the old keys are not read any more, since there is
no telling which were overwritten. They age out of the LRU and the cache
warms once after the upgrade.

Tests: a request with another id can't replace the legitimate image, in
either order; the normal miss then hit; the same URL under two ids hits;
no URL gives 404; bad encoding gives 400 without a fetch; and a unit
test for the key. Ruff, basedpyright and the backend suite pass.
2026-10-20 23:35:42 -04:00

87 lines
2.9 KiB
Python

"""Tests for targeted image cache safety and fetch fallbacks."""
import requests
from shelfmark.core.image_cache import ImageCacheService
def test_fetch_and_cache_rejects_backslash_authority_bypass_before_request(
tmp_path, monkeypatch
) -> None:
cache = ImageCacheService(tmp_path)
calls = []
def fake_get(url, **_kwargs):
calls.append(url)
raise AssertionError("unsafe URL should not be requested")
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
assert cache.fetch_and_cache("cover-ssrf", "http://127.0.0.1:6666\\@1.1.1.1") is None
assert calls == []
assert "cover-ssrf" not in cache._index
def test_is_safe_url_rejects_encoded_separator_in_authority() -> None:
assert ImageCacheService._is_safe_url("http://127.0.0.1:6666%5c@1.1.1.1") is False
assert ImageCacheService._is_safe_url("http://127.0.0.1:6666%2f@1.1.1.1") is False
def test_is_safe_url_rejects_invalid_ipv6_url() -> None:
assert ImageCacheService._is_safe_url("http://[") is False
def test_fetch_and_cache_blocks_unsafe_redirect(tmp_path, monkeypatch) -> None:
cache = ImageCacheService(tmp_path)
def fake_getaddrinfo(hostname, *_args, **_kwargs):
addresses = {
"example.com": "93.184.216.34",
"127.0.0.1": "127.0.0.1",
}
return [(None, None, None, None, (addresses[hostname], 0))]
class RedirectResponse:
is_redirect = True
headers = {"location": "http://127.0.0.1/cover.jpg"}
def close(self):
return None
calls = []
def fake_get(url, **_kwargs):
calls.append(url)
return RedirectResponse()
monkeypatch.setattr("shelfmark.core.image_cache.socket.getaddrinfo", fake_getaddrinfo)
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
assert cache.fetch_and_cache("cover-redirect", "https://example.com/cover.jpg") is None
assert calls == ["https://example.com/cover.jpg"]
assert "cover-redirect" not in cache._index
def test_fetch_and_cache_returns_none_on_request_exception(tmp_path, monkeypatch) -> None:
cache = ImageCacheService(tmp_path)
def fake_get(*_args, **_kwargs):
raise requests.exceptions.TooManyRedirects("too many redirects")
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
assert cache.fetch_and_cache("cover-1", "https://example.com/cover.jpg") is None
assert "cover-1" not in cache._index
def test_cover_cache_key_depends_on_the_url_only() -> None:
from shelfmark.core.image_cache import cover_cache_key
key = cover_cache_key("https://example.com/cover.jpg")
assert key == cover_cache_key(" https://example.com/cover.jpg \n")
assert key != cover_cache_key("https://example.com/other.jpg")
assert key != cover_cache_key("https://example.com/cover.jpg?size=large")
assert key.isalnum() and key == key.lower()
assert "/" not in key and "." not in key