From 4a410d4b08b52883efcfcef7136989867a9a5fdc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 05:48:14 +0400 Subject: [PATCH] =?UTF-8?q?wiki=5Ffetch:=20=D0=BA=D0=BE=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20ForeignHost=20(I-2,=20T5.2,=20#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Адрес вложения из HTML бывает с пробелами и кириллицей: путь и запрос теперь кодируются перед запросом, готовые %XX не кодируются повторно, одиночный % становится %25. Отказ по хосту — ForeignHost («чужой хост» или «некорректный адрес»), а не голый ValueError: UnicodeEncodeError и прочие ValueError больше не маскируются под «чужой хост». Добавлены тесты на похожий хост, userinfo, другой порт и схему. Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 34 +++++++++++++++++++++++++------ tests/test_eva_client.py | 17 +++++++++++++++- tests/test_wiki_fetch.py | 43 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/agent.py b/src/agent.py index 277fc57..8008f48 100644 --- a/src/agent.py +++ b/src/agent.py @@ -238,6 +238,20 @@ class EvaError(Exception): """JSON-RPC Eva вернула `error`: текст «<метод>: ».""" +class ForeignHost(Exception): + """Адрес вложения не на хосте Eva (или не разбирается): токен туда не отправляется, запроса нет.""" + + +_URL_SAFE = "/%:@!$&'()*+,;=-._~" +_STRAY_PERCENT = re.compile(r"%(?![0-9A-Fa-f]{2})") + + +def _quote_url_part(part: str, extra_safe: str = "") -> str: + """Процентная кодировка пути/запроса для HTTP: пробелы и не-ASCII кодируются, готовые %XX остаются как есть, + одиночный `%` без двух шестнадцатеричных цифр становится %25 (I-2).""" + return urllib.parse.quote(_STRAY_PERCENT.sub("%25", part), safe=_URL_SAFE + extra_safe) + + class EvaClient(Http): """Клиент Eva (JSON-RPC 2.2): повторы, пауза между вызовами, счётчики вызовов и 429, время последнего вызова.""" PAGE_SIZE = 200 @@ -325,10 +339,18 @@ class EvaClient(Http): offset += self.PAGE_SIZE def download(self, url: str, limit: int) -> bytes: - """Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста.""" - if _origin(url) != _origin(self.base) or not url.startswith(self.base): - raise ValueError("чужой хост") - return self._retrying(lambda: self.fetch_bytes(url[len(self.base):], limit)) + """Файл Eva потоком: читается не больше limit + 1 байта, длиннее limit — TooBig. Адрес — только своего хоста + (иначе ForeignHost); путь и запрос перед отправкой кодируются (адрес из HTML бывает с пробелами и кириллицей).""" + try: + same = _origin(url) == _origin(self.base) + except ValueError: # `http://[::1`, порт не числом + raise ForeignHost("некорректный адрес") from None + rest = url[len(self.base):] if url.startswith(self.base) else None + if not same or rest is None or rest[:1] not in ("", "/", "?"): + raise ForeignHost("чужой хост") + path, _, query = rest.split("#", 1)[0].partition("?") + path = _quote_url_part(path) + ("?" + _quote_url_part(query, "?") if query else "") + return self._retrying(lambda: self.fetch_bytes(path, limit)) class _ParseError(Exception): @@ -870,8 +892,8 @@ class Executor: return self._out("failed", error="wiki_fetch: не задан url") try: data = self.eva.download(url, MAX_ATTACHMENT) - except ValueError: - return self._out("failed", error="чужой хост") + except ForeignHost as e: + return self._out("failed", error=str(e)) except TooBig: return self._out("failed", error="файл больше 10 МБ — откройте в Eva") except HttpError as e: diff --git a/tests/test_eva_client.py b/tests/test_eva_client.py index 292c1ce..6e141e1 100644 --- a/tests/test_eva_client.py +++ b/tests/test_eva_client.py @@ -101,10 +101,25 @@ class EvaClientTest(unittest.TestCase): self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret") def test_download_foreign_host(self): - with self.assertRaises(ValueError): + with self.assertRaises(agent.ForeignHost): self.cli.download("http://example.invalid/files/a.bin", 10) self.assertEqual(self.eva.requests, []) + def test_download_lookalikes(self): + """T5.2: похожий хост, userinfo, другой порт и схема — ForeignHost до запроса (адрес Eva — https://eva.test).""" + cli = agent.EvaClient(c.eva_cfg("https://eva.test")) + cli.fetch_bytes = lambda *a, **kw: self.fail("запроса быть не должно") + for url in ("https://eva.test.evil/files/a.bin", "https://eva.test@evil/files/a.bin", + "https://eva.test:8443/files/a.bin", "http://eva.test/files/a.bin", + "https://eva.testx/files/a.bin", "https://user@eva.test/files/a.bin"): + with self.subTest(url=url): + with self.assertRaises(agent.ForeignHost) as cm: + cli.download(url, 10) + self.assertEqual(str(cm.exception), "чужой хост") + with self.assertRaises(agent.ForeignHost) as cm: + cli.download("https://eva.test:abc/files/a.bin", 10) + self.assertEqual(str(cm.exception), "некорректный адрес") + def test_download_limit(self): limit = 10 * 1024 * 1024 self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False) diff --git a/tests/test_wiki_fetch.py b/tests/test_wiki_fetch.py index be6ad03..ac4918f 100644 --- a/tests/test_wiki_fetch.py +++ b/tests/test_wiki_fetch.py @@ -44,6 +44,49 @@ class WikiFetchTest(unittest.TestCase): self.assertEqual(self.eva.requests, []) self.assertEqual(self.hub.uploaded, []) + def test_fetch_foreign_lookalikes(self): + """T5.2: похожий хост, userinfo, другой порт и схема — «чужой хост», запроса к Eva нет.""" + port = self.eva.base_url.rsplit(":", 1)[1] # http://127.0.0.1: + for url in ("http://127.0.0.1.evil:%s/files/a.bin" % port, + "http://127.0.0.1:%s@evil.test/files/a.bin" % port, + "http://127.0.0.1:%d/files/a.bin" % (int(port) + 1), + "https://127.0.0.1:%s/files/a.bin" % port): + with self.subTest(url=url): + out = self.fetch(url) + self.assertEqual((out["status"], out["error"]), ("failed", "чужой хост")) + self.assertEqual(self.eva.requests, []) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_malformed_url(self): + out = self.fetch("http://[::1/files/a.bin") + self.assertEqual(out["status"], "failed") + self.assertIn("некорректный адрес", out["error"]) + self.assertEqual(self.eva.requests, []) + + def test_fetch_non_ascii_and_spaces(self): + """I-2: адрес из HTML с пробелами и кириллицей кодируется перед запросом; %XX не кодируется повторно.""" + enc = "/files/obj/x/%D0%9F%D0%9C%D0%9F%20%D1%84%D0%B0%D0%B9%D0%BB.png" + self.eva.add_file(enc, b"png") + for path in ("/files/obj/x/ПМП файл.png", enc, "/files/obj/x/ПМП%20файл.png"): + with self.subTest(path=path): + out = self.fetch(self.file_url(path)) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 3) + self.assertEqual([r["path"] for r in self.eva.requests], [enc] * 3) + + def test_fetch_stray_percent(self): + self.eva.add_file("/files/100%25.txt", b"ok") + out = self.fetch(self.file_url("/files/100%.txt")) + self.assertEqual(out["status"], "done", out) + + def test_value_error_inside_is_not_foreign_host(self): + """I-2: ValueError внутри скачивания (например, UnicodeEncodeError) не выдаётся за «чужой хост».""" + def boom(*a, **kw): + raise UnicodeEncodeError("ascii", "я", 0, 1, "ordinal not in range") + self.ex.eva.fetch_bytes = boom + with self.assertRaises(UnicodeEncodeError): + self.fetch(self.file_url()) + def test_fetch_too_big_header(self): self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт out = self.fetch(self.file_url())