"""Adversarial tests: stored XSS, SSRF, auth on submissions, rate limits, moderation, no copying.""" import datetime as dt import io import json import os import socket import sys import tempfile import threading import unittest import urllib.error import urllib.request from http.server import ThreadingHTTPServer from unittest import mock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from newsdigest import digest, moderate, render, safefetch, text, web # noqa: E402 from newsdigest.store import Store # noqa: E402 ARTICLE = ("Acme ships open protocol for AI agents" "

" + "Acme released an open protocol that lets AI agents discover each other and exchange tasks across vendors. " * 6 + "

").encode() XSS = '">' def fake_fetch(pages): def f(url, **kw): if url not in pages: raise safefetch.FetchError("http_404") return url, "text/html; charset=utf-8", pages[url] return f def chat_news(system, user, **kw): if "Reply with exactly one word" in system: return "NEWS" return "Acme published a cross-vendor discovery standard. Why it matters: agents can find and hire each other." def resolver_for(ip): return lambda host, port, *a: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port))] class SSRF(unittest.TestCase): def blocked(self, url, resolver=resolver_for("93.184.216.34"), doh=None): with self.assertRaises(safefetch.FetchError): safefetch.fetch(url, resolver=resolver, doh=doh) def test_schemes_ports_literals(self): for u in ["file:///etc/passwd", "gopher://x.com/", "ftp://x.com/", "http://127.0.0.1/", "http://[::1]/", "http://169.254.169.254/latest/meta-data/", "http://0x7f000001/", "http://2130706433/", "https://example.com:8788/", "http://example.com:3000/", "http://user:pw@example.com/", "http://localhost/", "http://foo.localhost/", "http://intranet/", "http://a.internal/", "http://example.com/\r\nX: y", "javascript:alert(1)"]: self.blocked(u) def test_dns_to_private(self): for ip in ["127.0.0.1", "10.0.0.5", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "::1", "fe80::1", "fc00::1", "::ffff:127.0.0.1", "224.0.0.1"]: self.blocked("https://evil.example/", resolver=lambda h, p, *a, ip=ip: [(0, 0, 0, "", (ip, p))]) def test_mixed_answer_rejected(self): r = lambda h, p, *a: [(0, 0, 0, "", ("93.184.216.34", p)), (0, 0, 0, "", ("127.0.0.1", p))] self.blocked("https://rebind.example/", resolver=r) def test_egress_gateway_vets_real_records(self): gw = resolver_for("198.18.0.1") self.blocked("https://localtest.example/", resolver=gw, doh=lambda h: ["127.0.0.1"]) self.blocked("https://mixed.example/", resolver=gw, doh=lambda h: ["93.184.216.34", "10.1.1.1"]) self.blocked("https://nx.example/", resolver=gw, doh=lambda h: []) self.assertEqual(safefetch.resolve_public("ok.example", 443, gw, lambda h: ["93.184.216.34"]), "198.18.0.1") def test_redirect_to_private_is_rechecked(self): calls = [] class Resp: status = 302 def getheader(self, k, d=None): return "http://127.0.0.1:8788/v1/net/admin" if k == "Location" else d class Conn: def __init__(self, *a, **k): pass def request(self, *a, **k): calls.append(a) def getresponse(self): return Resp() def close(self): pass with mock.patch.object(safefetch, "_PinnedHTTPS", Conn): with self.assertRaises(safefetch.FetchError) as cm: safefetch.fetch("https://redir.example/", resolver=resolver_for("93.184.216.34")) self.assertEqual(str(cm.exception), "bad_port") self.assertEqual(len(calls), 1) def test_feed_rejects_entities(self): bomb = b']>&b;' self.assertEqual(digest.parse_feed(bomb, "x"), []) class XSSandText(unittest.TestCase): def test_plain_strips_markup_and_bidi(self): out = text.plain(XSS + "\u202eevil\u200b <script>", 500) self.assertNotIn("<", out) self.assertNotIn("\u202e", out) def test_safe_url(self): for bad in ["javascript:alert(1)", "data:text/html,

" + b"Bake the bread at two hundred degrees for forty minutes until golden. " * 8 + b"

" r = self.sub("https://food.example/x", pages={"https://food.example/x": page}) self.assertIn("off topic", r["reason"]) def test_ssrf_link_rejected(self): r = moderate.submit(self.store, "a", "http://127.0.0.1:8788/v1/net/admin", via="t", t=self.t, chat=chat_news) self.assertEqual(r["decision"], "rejected") def test_rate_limits(self): self.assertEqual(self.sub("https://news.example/a")["decision"], "approved") self.assertIn("rate_limited", self.sub("https://news.example/b", t=self.t + dt.timedelta(seconds=30))["reason"]) self.sub("https://news.example/c", t=self.t + dt.timedelta(minutes=3)) self.sub("https://news.example/d", t=self.t + dt.timedelta(minutes=6)) self.assertIn("a day", self.sub("https://news.example/e", t=self.t + dt.timedelta(minutes=9))["reason"]) self.assertNotIn("rate", self.sub("https://news.example/zz", who="other")["reason"]) def test_duplicate(self): self.sub("https://news.example/a") self.assertIn("already", self.sub("https://news.example/a", who="b")["reason"]) def test_copy_detector(self): art = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen" self.assertTrue(digest.copied("Intro: " + art, art)) self.assertFalse(digest.copied("A completely different short summary.", art)) def test_parse_message(self): p = moderate.parse_message("Submit: https://news.example/a). Great piece on agent protocols") self.assertEqual(p["url"], "https://news.example/a") class API(unittest.TestCase): @classmethod def setUpClass(cls): tmp = tempfile.mkdtemp() web.App.store = Store(os.path.join(tmp, "s.json")) cls.srv = ThreadingHTTPServer(("127.0.0.1", 0), web.App) threading.Thread(target=cls.srv.serve_forever, daemon=True).start() cls.base = f"http://127.0.0.1:{cls.srv.server_port}" @classmethod def tearDownClass(cls): cls.srv.shutdown() def setUp(self): web._auth_fail.clear() def req(self, path, body=None, headers=None, method=None): r = urllib.request.Request(self.base + path, json.dumps(body).encode() if body is not None else None, {"Content-Type": "application/json", **(headers or {})}, method=method) try: with urllib.request.urlopen(r) as resp: return resp.status, resp.read().decode(), resp.headers except urllib.error.HTTPError as e: return e.code, e.read().decode(), e.headers def test_no_auth_401(self): with mock.patch.object(web, "platform_identity", return_value=None): self.assertEqual(self.req("/v1/submit", {"url": "https://a.com"})[0], 401) def test_human_session_not_agent(self): class R(io.BytesIO): def __enter__(self): return self def __exit__(self, *a): pass with mock.patch.object(urllib.request, "urlopen", return_value=R(b'{"kind":"human","handle":"bob"}')): self.assertIsNone(web.platform_identity({"X-Inbox-Passcode": "x"})) self.assertIsNone(web.platform_identity({})) self.assertIsNone(web.platform_identity({"Authorization": "Basic abc"})) def test_passcode_in_query_refused(self): self.assertEqual(self.req("/v1/submit?passcode=abc", {"url": "https://a.com"})[0], 400) def test_body_limits(self): with mock.patch.object(web, "platform_identity", return_value="a"): self.assertEqual(self.req("/v1/submit", {"url": "x" * 5000})[0], 413) self.assertEqual(self.req("/v1/submit", {"url": 5})[0], 400) self.assertEqual(self.req("/v1/submit", ["x"])[0], 400) def test_read_only_and_headers(self): code, body, h = self.req("/") self.assertEqual(code, 200) self.assertIn("default-src 'none'", h["Content-Security-Policy"]) self.assertEqual(h["X-Content-Type-Options"], "nosniff") self.assertNotIn("