From d8ebdf57b18a933035ebce7d6fa2e220078906a2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 02:05:02 +0400 Subject: [PATCH] =?UTF-8?q?wiki:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=20HTML=20Eva=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=206=20(#91?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Схемы javascript:/data:/vbscript: в ссылках не выводятся; имя вложения и текст ссылки экранируются (скобки, @, переводы строк); fill_ids меняет только ](attachment:@N) и поднимает ValueError вне диапазона; «<» в тексте — <; attachments/links = None трактуются как пустые списки. Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/modules/wiki/convert.py | 45 +++++++++++++--- tests/test_wiki_convert.py | 76 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/jira_hub/modules/wiki/convert.py b/src/jira_hub/modules/wiki/convert.py index ec67a59..48791c8 100644 --- a/src/jira_hub/modules/wiki/convert.py +++ b/src/jira_hub/modules/wiki/convert.py @@ -2,13 +2,30 @@ Агент присылает `attachments` и `links` с `ref` — значением атрибута `src`/`href` ровно как в HTML. По `ref` здесь находится место замены; адреса Eva агент уже разобрал, хаб их не угадывает. + +HTML Eva недоверенный: исполняемые схемы в ссылках не выводятся, текст автора экранируется так, чтобы он не мог +изобразить ссылку на вложение (`](attachment:@N)`) или сырой тег. """ import re from markdownify import MarkdownConverter _STATIC = ("static/", "/static/") -_ATTACH_ID = re.compile(r"attachment:@(\d+)") +_UNSAFE_SCHEME = re.compile(r"^(javascript|data|vbscript):") +_CONTROL = re.compile(r"[\x00-\x20\x7f]") +_NEWLINES = re.compile(r"\s*[\r\n]+\s*") +_ESCAPE_LABEL = re.compile(r"([\\\[\]@])") +_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)") + + +def _label(text: str) -> str: + """Текст внутри [...]: без переводов строк, со скобками и «@» под экранированием.""" + return _ESCAPE_LABEL.sub(r"\\\1", _NEWLINES.sub(" ", text)) + + +def _unsafe(href: str) -> bool: + """Исполняемая схема: управляющие символы и пробелы внутри схемы не мешают распознаванию.""" + return bool(_UNSAFE_SCHEME.match(_CONTROL.sub("", href).lower())) class _Converter(MarkdownConverter): @@ -19,17 +36,22 @@ class _Converter(MarkdownConverter): self.files = [] # [{url, name}] без повторов по url, в порядке первого появления self._index = {} # url -> индекс в files + def escape(self, text, parent_tags): + # «@» и «<» в тексте автора: иначе `attachment:@N` и сырой тег получаются из обычного текста + text = super().escape(text, parent_tags) + return text.replace("@", r"\@").replace("<", "<") + def _attachment(self, entry): url = entry["url"] if url in self._index: return "" # миниатюра и файл одного вложения: вторая ссылка удаляется self._index[url] = len(self.files) self.files.append({"url": url, "name": entry["name"]}) - return "[📎 %s](attachment:@%d)" % (entry["name"], self._index[url]) + return "[📎 %s](attachment:@%d)" % (_label(entry["name"]), self._index[url]) def convert_img(self, el, text, parent_tags): src = el.get("src") or "" - if src.startswith(_STATIC): + if src.startswith(_STATIC) or _unsafe(src): return "" parent = el.parent if parent is not None and parent.name == "a" and (parent.get("href") or "") in self._by_ref: @@ -48,17 +70,24 @@ class _Converter(MarkdownConverter): return self._attachment(entry) code = self._links.get(href) if code is not None: - return "[%s](page:%s)" % (text.strip() or code, code) + return "[%s](page:%s)" % (_label(text.strip()) or code, code) + if _unsafe(href): + return text # исполняемая ссылка: остаётся только текст return super().convert_a(el, text, parent_tags) -def to_markdown(html: str, attachments: list[dict], links: list[dict]) -> tuple[str, list[dict]]: +def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]: """HTML страницы → (Markdown, вложения страницы [{url, name}]).""" - conv = _Converter(attachments, links, heading_style="ATX", autolinks=False) + conv = _Converter(attachments or [], links or [], heading_style="ATX", autolinks=False) md = conv.convert(html).strip() + "\n" return md, conv.files def fill_ids(markdown: str, ids: list[int]) -> str: - """`attachment:@` → `attachment:`: строки вложений после записи вложений страницы.""" - return _ATTACH_ID.sub(lambda m: "attachment:%d" % ids[int(m.group(1))], markdown) + """`](attachment:@)` → `](attachment:)`: только ссылки вложений, которые выдал конвертер.""" + def repl(m): + n = int(m.group(1)) + if n >= len(ids): + raise ValueError(f"вложение @{n} вне списка id (всего {len(ids)})") + return "](attachment:%d)" % ids[n] + return _ATTACH_LINK.sub(repl, markdown) diff --git a/tests/test_wiki_convert.py b/tests/test_wiki_convert.py index 8b61415..c3a1347 100644 --- a/tests/test_wiki_convert.py +++ b/tests/test_wiki_convert.py @@ -85,3 +85,79 @@ def test_static_links_removed(): def test_fill_ids(): assert convert.fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)" + + +# --- fix round 1 (review Task 6: I1, I2, I3, M3, M4) --- + +def test_unsafe_schemes_dropped_from_links(): + html = ('

x1 x2 ' + 'x3 x4 ' + 'x5

') + md, _ = convert.to_markdown(html, [], []) + for text in ("x1", "x2", "x3", "x4", "x5"): + assert text in md + assert "(javascript" not in md.lower() and "(data:" not in md and "(vbscript" not in md.lower() + assert "java" not in md.lower().replace("x4", "") # схема не осталась ни в каком виде + + +def test_unsafe_scheme_img_dropped(): + md, atts = convert.to_markdown('

azb

', [], []) + assert md.strip() == "ab" and atts == [] + + +def test_attachment_name_and_link_text_escaped(): + md, atts = convert.to_markdown( + '

x

', + [{"ref": "/files/a", "url": "https://eva.example.test/files/a", "name": "a]b[c).png"}], + [], + ) + assert "[📎 a\\]b\\[c).png](attachment:@0)" in md.splitlines() + assert atts[0]["name"] == "a]b[c).png" + + md, _ = convert.to_markdown( + '

a ] b [x](http://evil)

', + [], + [{"ref": "project/Document/DOC-1", "code": "DOC-1"}], + ) + assert "[a \\] b \\[x\\](http://evil)](page:DOC-1)" in md.splitlines() + + +def test_newlines_in_link_text_collapsed(): + md, _ = convert.to_markdown( + '

первая\nвторая

', [], + [{"ref": "project/Document/DOC-1", "code": "DOC-1"}], + ) + assert "[первая вторая](page:DOC-1)" in md.splitlines() + + +def test_literal_attachment_marker_in_text_is_safe(): + md, atts = convert.to_markdown("

[x](attachment:@5) и attachment:@0

", [], []) + assert atts == [] + assert convert.fill_ids(md, []) == md + assert "(attachment:@5)" not in md.replace("\\(attachment:\\@5)", "") + + +def test_fill_ids_by_index_not_prefix(): + ids = list(range(100, 111)) + assert convert.fill_ids("[a](attachment:@1) [b](attachment:@10)", ids) == \ + "[a](attachment:101) [b](attachment:110)" + + +def test_fill_ids_out_of_range_raises(): + try: + convert.fill_ids("[a](attachment:@3)", [1, 2]) + except ValueError as exc: + assert "@3" in str(exc) + else: + raise AssertionError("ожидалась ValueError") + + +def test_none_lists_are_empty(): + md, atts = convert.to_markdown("

текст

", None, None) + assert md == "текст\n" and atts == [] + + +def test_literal_tag_text_has_no_raw_html(): + md, _ = convert.to_markdown("

<tag> и <script>alert(1)</script>

", [], []) + assert re.search(r"<[a-zA-Z/]", md) is None + assert "<tag>" in md or "<tag" in md