Sync reading position from Moon Reader app to Bookhive atproto records
atproto
bookhive
ereader
moonreader
1"""Guard-rail unit tests added during the pre-publish security review.
2
3Each test pins down one specific hardening from the review:
4 1. PROPFIND displayname escapes XML-special chars
5 2. Passthrough refuses sibling-prefix directory escapes
6 3. upload_cover rejects non-https / private-IP / localhost URLs
7"""
8
9from __future__ import annotations
10
11import pytest
12
13from spacebee.adapters.webdav import moonreader, passthrough
14from spacebee.atproto.bookhive import _is_safe_cover_url
15
16# 1. XML escaping in PROPFIND response -------------------------------------
17
18def test_propfind_displayname_escapes_special_chars():
19 xml = moonreader._response_xml(
20 href="/Books/.Moon+/Cache/Rosencrantz & Guildenstern.epub.po",
21 is_collection=False,
22 last_modified_http="Thu, 01 Jan 1970 00:00:00 GMT",
23 content_length=0,
24 etag='"abc"',
25 display_name="Rosencrantz & Guildenstern <script>.epub.po",
26 )
27 assert "&" in xml
28 assert "<script>" in xml
29 # The raw '<' must not appear as the start of a tag we didn't write.
30 assert "<script>" not in xml
31
32
33def test_passthrough_entry_xml_escapes_local_name(tmp_path):
34 sneaky = tmp_path / "R&D <plan>.txt"
35 sneaky.write_text("hello")
36 xml = passthrough._entry_xml(sneaky, "/R&D <plan>.txt", tmp_path)
37 assert "&" in xml
38 assert "<plan>" in xml
39 assert "<plan>" not in xml
40
41
42# 2. Path-traversal guard --------------------------------------------------
43
44def test_passthrough_refuses_parent_escape(tmp_path):
45 root = tmp_path / "pass"
46 root.mkdir()
47 p = passthrough.Passthrough(str(root))
48 with pytest.raises(PermissionError):
49 p._local("/../etc/passwd")
50
51
52def test_passthrough_refuses_sibling_prefix_escape(tmp_path):
53 # Regression for the str.startswith antipattern — a sibling like
54 # `/tmp/.../pass_evil` must not pass when root is `/tmp/.../pass`.
55 root = tmp_path / "pass"
56 root.mkdir()
57 (tmp_path / "passerby").mkdir()
58 p = passthrough.Passthrough(str(root))
59 with pytest.raises(PermissionError):
60 p._local("/../passerby/secret")
61
62
63# 3. Cover-URL SSRF guard --------------------------------------------------
64
65@pytest.mark.parametrize(
66 "url",
67 [
68 "https://covers.bookhive.buzz/img/abc.jpg",
69 "https://images.example.com/covers/12345",
70 ],
71)
72def test_safe_cover_urls_accepted(url):
73 assert _is_safe_cover_url(url)
74
75
76@pytest.mark.parametrize(
77 "url",
78 [
79 "http://covers.bookhive.buzz/img/abc.jpg", # plaintext
80 "https://localhost/foo", # localhost hostname
81 "https://localhost.localdomain/x", # localhost alias
82 "https://foo.localhost/x", # .localhost suffix
83 "https://127.0.0.1/x", # loopback IPv4
84 "https://[::1]/x", # loopback IPv6
85 "https://169.254.169.254/latest/meta-data/", # AWS IMDS
86 "https://10.0.0.5/x", # RFC1918
87 "https://192.168.1.1/x", # RFC1918
88 "file:///etc/passwd", # not http(s)
89 "gopher://example.com/", # not http(s)
90 "", # empty
91 "not a url at all", # malformed
92 ],
93)
94def test_unsafe_cover_urls_rejected(url):
95 assert not _is_safe_cover_url(url)