test_confirm_link_unattended_fetch_1898.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """A machine must not be able to spend a one-tap verdict token (#1898).
  2. Telegram and Slack fetch the URLs in a message to build a preview card, mail
  3. gateways detonate links before delivery, and browsers prefetch. While the
  4. verdict URLs went out as plain text in the notification body -- which is what
  5. ``DEFAULT_TEMPLATES['print_confirm_request']`` used to ship -- any one of those
  6. GETs recorded a verdict nobody chose and retired the token, so the operator's
  7. real tap landed on "already answered" and a scrap part counted as good for the
  8. rest of time.
  9. Four defences, in the order they matter. The capability URLs no longer travel
  10. in body text at all. Recording is a POST, so a GET from anything that walks a
  11. URL changes nothing (the route side of that is in the integration tests). The
  12. confirmation page submits its own form only for a URL carrying the one-tap
  13. marker, which rides on the Telegram inline keyboard and on nothing a machine
  14. can read — so the scanners that render HTML and run JavaScript, which send an
  15. ordinary Chrome string and which no User-Agent list can name, get a page with a
  16. button on it. And the channels that build previews are asked not to, on the one
  17. message whose links are capabilities.
  18. The unattended-fetch heuristic is the fifth and the weakest, which is why it is
  19. no longer the only thing in front of the write.
  20. """
  21. import json
  22. import httpx
  23. import pytest
  24. from backend.app.models.notification import NotificationProvider
  25. from backend.app.models.notification_template import DEFAULT_TEMPLATES
  26. from backend.app.services.notification_service import NotificationService
  27. from backend.app.services.print_confirmation import is_one_tap_request, is_unattended_fetch, one_tap_url
  28. CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
  29. # Real link-preview and mail-security fetchers, plus the browsers that must
  30. # keep working. The phone UAs are the ones a notification tap actually opens.
  31. BROWSER_AGENTS = [
  32. "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
  33. "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
  34. "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
  35. "Chrome/126.0.0.0 Mobile Safari/537.36",
  36. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
  37. "Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0",
  38. # The ntfy app performs its action buttons itself, with an HTTP client UA.
  39. "okhttp/4.12.0",
  40. "python-httpx/0.27.0",
  41. ]
  42. MACHINE_AGENTS = [
  43. "TelegramBot (like TwitterBot)",
  44. "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)",
  45. "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)",
  46. "facebookexternalhit/1.1",
  47. "Twitterbot/1.0",
  48. "WhatsApp/2.23.20.0 A",
  49. "Mozilla/5.0 (compatible; SkypeUriPreview Preview/0.5)",
  50. "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
  51. "Barracuda Sentinel (EE)",
  52. "Mimecast-Link-Protect",
  53. ]
  54. class _Client:
  55. """Stand-in for httpx.AsyncClient that records what would be sent."""
  56. def __init__(self):
  57. self.is_closed = False
  58. self.calls: list[dict] = []
  59. async def post(self, url, data=None, files=None, json=None, headers=None):
  60. self.calls.append(json if json is not None else (data or {}))
  61. return httpx.Response(200, json={"ok": True, "result": {}})
  62. class TestUnattendedFetchDetection:
  63. @pytest.mark.parametrize("agent", BROWSER_AGENTS)
  64. def test_a_real_browser_is_left_alone(self, agent):
  65. assert is_unattended_fetch("GET", {"user-agent": agent}) is False
  66. @pytest.mark.parametrize("agent", MACHINE_AGENTS)
  67. def test_link_fetchers_are_recognised(self, agent):
  68. assert is_unattended_fetch("GET", {"user-agent": agent}) is True
  69. def test_prefetch_hints_count_even_from_a_browser_ua(self):
  70. """Chrome and Firefox preload links before they are clicked; Safari's
  71. preview sends the same hint. A finger on a button never does."""
  72. browser = BROWSER_AGENTS[1]
  73. assert is_unattended_fetch("GET", {"user-agent": browser, "purpose": "prefetch"}) is True
  74. assert is_unattended_fetch("GET", {"user-agent": browser, "x-purpose": "preview"}) is True
  75. assert is_unattended_fetch("GET", {"user-agent": browser, "x-moz": "prefetch"}) is True
  76. assert is_unattended_fetch("GET", {"user-agent": browser, "sec-purpose": "prefetch;prerender"}) is True
  77. def test_head_is_never_a_tap(self):
  78. """Only the GET route renders the page, so only a GET can be widened
  79. to HEAD by a future router change. A probe must not come back looking
  80. like a page load."""
  81. assert is_unattended_fetch("HEAD", {"user-agent": BROWSER_AGENTS[0]}) is True
  82. def test_a_missing_user_agent_is_not_held_against_the_caller(self):
  83. """Some notification clients send none; the token is still the
  84. credential, and guessing here would cost real verdicts."""
  85. assert is_unattended_fetch("GET", {}) is False
  86. class TestPromptKeepsTheCapabilityOutOfBodyText:
  87. """This file used to pin the opposite: that both verdict URLs were in the
  88. body. They were, and that was the defect — the body is the one part of a
  89. notification that every unfurler, gateway and proxy reads. The capability
  90. links now travel only in affordances nothing prefetches."""
  91. def test_the_default_template_carries_only_the_deep_link(self):
  92. template = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request")
  93. assert "{good_url}" not in template["body_template"]
  94. assert "{reject_url}" not in template["body_template"]
  95. assert "{confirm_url}" in template["body_template"]
  96. class TestNtfyButtonsRecordOverPost:
  97. @pytest.mark.asyncio
  98. async def test_the_action_buttons_use_post(self):
  99. """The ntfy app issues the action itself, so it can use the method that
  100. records. A GET would only open the confirmation page — which is the
  101. point of the split, and is what every crawler gets."""
  102. service = NotificationService()
  103. captured: dict = {}
  104. async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None):
  105. captured["actions"] = actions
  106. return True, "ok"
  107. service._send_ntfy = _fake_ntfy
  108. provider = NotificationProvider(
  109. name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"})
  110. )
  111. ok, _ = await service._send_to_provider(
  112. provider,
  113. "How did your print come out?",
  114. "X1C: bracket.3mf",
  115. event_type="print_confirm_request",
  116. variables={
  117. "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
  118. "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
  119. },
  120. )
  121. assert ok
  122. assert "method=POST" in captured["actions"]
  123. assert "method=GET" not in captured["actions"]
  124. class TestTheOneTapMarkerRidesOnlyOnButtons:
  125. """The marker is what decides whether the confirmation page submits itself,
  126. and it is on the Telegram inline keyboard and nowhere else.
  127. The User-Agent list above catches the fetchers that say what they are, and
  128. none of those run JavaScript. The ones that do — a mail-security sandbox
  129. detonating the link, a browser-isolation proxy — send an ordinary Chrome
  130. string, so no list can name them. What they cannot have is a URL that never
  131. appeared in any text they can read.
  132. """
  133. def test_a_marked_url_is_the_only_thing_that_opens_the_script(self):
  134. assert is_one_tap_request({"tap": "1"}) is True
  135. assert is_one_tap_request({}) is False, "a link out of a message body must not qualify"
  136. assert is_one_tap_request({"tap": "0"}) is False
  137. assert is_one_tap_request({"tap": "yes"}) is False
  138. def test_marking_a_url_that_already_carries_a_query(self):
  139. assert one_tap_url("https://host/api/v1/archives/confirm/tok/good") == (
  140. "https://host/api/v1/archives/confirm/tok/good?tap=1"
  141. )
  142. assert one_tap_url("https://host/confirm/tok/good?from=ntfy") == "https://host/confirm/tok/good?from=ntfy&tap=1"
  143. @pytest.mark.asyncio
  144. async def test_the_telegram_buttons_are_marked_and_the_body_link_is_not(self):
  145. """Telegram cannot POST, so its buttons are the one affordance that
  146. opens a browser — and the one that needs the page to submit itself.
  147. Telegram never fetches an inline-keyboard URL, so the marker does not
  148. leak into anything a machine reads."""
  149. service = NotificationService()
  150. captured: dict = {}
  151. async def _fake_telegram(config, message, image_data=None, buttons=None, link_preview=True):
  152. captured["buttons"] = buttons
  153. captured["message"] = message
  154. return True, "ok"
  155. service._send_telegram = _fake_telegram
  156. provider = NotificationProvider(name="farm", provider_type="telegram", config=json.dumps(CONFIG))
  157. ok, _ = await service._send_to_provider(
  158. provider,
  159. "How did your print come out?",
  160. "X1C: bracket.3mf\nGood: https://farm.example.com/api/v1/archives/confirm/tok/good",
  161. event_type="print_confirm_request",
  162. variables={
  163. "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
  164. "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
  165. },
  166. )
  167. assert ok
  168. assert [b["url"] for b in captured["buttons"]] == [
  169. "https://farm.example.com/api/v1/archives/confirm/tok/good?tap=1",
  170. "https://farm.example.com/api/v1/archives/confirm/tok/reject?tap=1",
  171. ]
  172. # An install that kept {good_url} in its edited body still sends the
  173. # plain URL in the text — and that is exactly the one that must not
  174. # press its own button when a scanner opens it.
  175. assert "?tap=1" not in captured["message"]
  176. @pytest.mark.asyncio
  177. async def test_the_ntfy_actions_stay_unmarked(self):
  178. """They POST, so no page is rendered and there is nothing to submit.
  179. Marking them would put the marker in an Authorization-free HTTP header
  180. for no gain."""
  181. service = NotificationService()
  182. captured: dict = {}
  183. async def _fake_ntfy(config, title, message, image_data=None, event_type=None, actions=None):
  184. captured["actions"] = actions
  185. return True, "ok"
  186. service._send_ntfy = _fake_ntfy
  187. provider = NotificationProvider(
  188. name="farm", provider_type="ntfy", config=json.dumps({"server": "https://ntfy.sh", "topic": "farm"})
  189. )
  190. ok, _ = await service._send_to_provider(
  191. provider,
  192. "How did your print come out?",
  193. "X1C: bracket.3mf",
  194. event_type="print_confirm_request",
  195. variables={
  196. "good_url": "https://farm.example.com/api/v1/archives/confirm/tok/good",
  197. "reject_url": "https://farm.example.com/api/v1/archives/confirm/tok/reject",
  198. },
  199. )
  200. assert ok
  201. assert "tap=1" not in captured["actions"]
  202. class TestSlackDoesNotUnfurl:
  203. @pytest.mark.asyncio
  204. async def test_the_slack_payload_turns_previews_off(self):
  205. """Slack and Mattermost unfurl the URLs in `text` the same way
  206. Telegram builds a preview card."""
  207. service = NotificationService()
  208. client = _Client()
  209. service._http_client = client
  210. ok, _ = await service._send_webhook(
  211. {"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"},
  212. "How did your print come out?",
  213. "X1C: bracket.3mf",
  214. event_type="print_confirm_request",
  215. )
  216. assert ok
  217. assert client.calls[0]["unfurl_links"] is False
  218. assert client.calls[0]["unfurl_media"] is False
  219. @pytest.mark.asyncio
  220. async def test_every_other_event_keeps_its_previews(self):
  221. """Only the outcome prompt's links are capabilities. The slack payload
  222. never attaches image bytes — the base64 attach is generic-format only —
  223. so the unfurl is the only way a {finish_photo_url} in a print_complete
  224. body ever becomes a photo in the channel, and there is no setting that
  225. turns it back on."""
  226. service = NotificationService()
  227. client = _Client()
  228. service._http_client = client
  229. ok, _ = await service._send_webhook(
  230. {"webhook_url": "https://hooks.slack.example.com/services/T/B/x", "payload_format": "slack"},
  231. "Print complete",
  232. "X1C: bracket.3mf\nhttps://farm.example.com/api/v1/archives/7/photos/finish_a.jpg",
  233. event_type="print_complete",
  234. )
  235. assert ok
  236. assert "unfurl_links" not in client.calls[0]
  237. assert "unfurl_media" not in client.calls[0]
  238. class TestTelegramDoesNotAskForAPreview:
  239. @pytest.mark.asyncio
  240. async def test_send_message_disables_the_link_preview(self):
  241. """Telegram's servers GET the first URL in the text to build the
  242. preview card. That fetch is the one that answered the prompt."""
  243. service = NotificationService()
  244. client = _Client()
  245. service._http_client = client
  246. ok, _ = await service._send_telegram(
  247. CONFIG,
  248. "*How did your print come out?*\nX1C: bracket.3mf\nGood: https://host/api/v1/archives/confirm/tok/good",
  249. link_preview=False,
  250. )
  251. assert ok
  252. assert client.calls[0]["disable_web_page_preview"] is True
  253. @pytest.mark.asyncio
  254. async def test_the_inline_buttons_variant_disables_it_too(self):
  255. """The shape the outcome prompt actually sends on a camera-less
  256. printer: no photo, so sendMessage rather than sendPhoto."""
  257. service = NotificationService()
  258. client = _Client()
  259. service._http_client = client
  260. buttons = [
  261. {"text": "Good", "url": "https://host/api/v1/archives/confirm/tok/good"},
  262. {"text": "Reject", "url": "https://host/api/v1/archives/confirm/tok/reject"},
  263. ]
  264. ok, _ = await service._send_telegram(CONFIG, "*T*\nbody", buttons=buttons, link_preview=False)
  265. assert ok
  266. assert client.calls[0]["disable_web_page_preview"] is True
  267. assert client.calls[0]["reply_markup"] == {"inline_keyboard": [buttons]}
  268. @pytest.mark.asyncio
  269. async def test_every_other_message_keeps_its_preview(self):
  270. """When the finish photo is too large to attach, the preview card is
  271. how a {finish_photo_url} in a print_complete body still shows up as a
  272. photo in the chat. Only the outcome prompt gives that up."""
  273. service = NotificationService()
  274. client = _Client()
  275. service._http_client = client
  276. ok, _ = await service._send_telegram(
  277. CONFIG, "*Print complete*\nX1C: bracket.3mf\nhttps://farm.example.com/api/v1/archives/7/photos/finish_a.jpg"
  278. )
  279. assert ok
  280. assert "disable_web_page_preview" not in client.calls[0]
  281. @pytest.mark.asyncio
  282. @pytest.mark.parametrize(
  283. ("event_type", "expected"),
  284. [("print_confirm_request", False), ("print_complete", True), (None, True)],
  285. )
  286. async def test_only_the_outcome_prompt_is_sent_without_a_preview(self, event_type, expected):
  287. service = NotificationService()
  288. captured: dict = {}
  289. async def _fake_telegram(config, message, image_data=None, buttons=None, link_preview=True):
  290. captured["link_preview"] = link_preview
  291. return True, "ok"
  292. service._send_telegram = _fake_telegram
  293. provider = NotificationProvider(name="farm", provider_type="telegram", config=json.dumps(CONFIG))
  294. ok, _ = await service._send_to_provider(provider, "Title", "body", event_type=event_type)
  295. assert ok
  296. assert captured["link_preview"] is expected