Skip to content

Commit aadde47

Browse files
test(teams): harden format boundary scan + tighten safe_link_href to upstream parity
Boundary test: replace the substring scan over raw import lines with an AST-based one that reconstructs the fully-qualified module path each import statement reaches. The old scan missed the idiomatic split form `from chat_sdk.adapters.teams import adapter` (the dotted token `chat_sdk.adapters.teams.adapter` never appears on one side of ` import `), the most likely circular-import / SDK-pull-in regression. Both `import <forbidden>` and `from <forbidden> import ...` forms are now caught for the adapter module, microsoft_teams, and httpx/aiohttp. safe_link_href: upstream uses `new URL(href).protocol`, which throws (=> rejected => plain label) for malformed bare-scheme hrefs like `http:` or `https://`. `urlparse(...).scheme` is lenient and passed those (emitting a live link). Require a non-empty host for the http/https branch so bare-scheme http(s) hrefs are rejected to parity; mailto: (no netloc) stays allowed. Adds regression tests for both.
1 parent 22c408d commit aadde47

2 files changed

Lines changed: 80 additions & 8 deletions

File tree

src/chat_sdk/adapters/teams/format.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
through an exact ``{http, https, mailto}`` protocol allowlist using
2222
:func:`urllib.parse.urlparse` (port of the upstream ``URL().protocol`` check),
2323
rejecting ``javascript:``, ``data:``, relative, and other unsafe hrefs so they
24-
render as plain text rather than active links (SSRF / injection guard).
24+
render as plain text rather than active links (SSRF / injection guard). Because
25+
``urlparse`` is more lenient than the upstream ``new URL(...)`` (which throws on
26+
a bare-scheme href like ``http:`` or ``https://``), the ``http``/``https``
27+
branch additionally requires a non-empty host so those malformed hrefs are
28+
rejected to parity.
2529
"""
2630

2731
from __future__ import annotations
@@ -179,8 +183,21 @@ def safe_link_href(href: str) -> bool:
179183
Port of the upstream ``safeLinkHref`` protocol check using
180184
:func:`urllib.parse.urlparse`. Rejects ``javascript:``, ``data:``,
181185
relative, and any other scheme (SSRF / injection guard).
186+
187+
Upstream parses the href with ``new URL(href)``, which *throws* for a
188+
malformed bare-scheme href like ``http:`` or ``https://`` (no authority),
189+
so such hrefs are rejected and render as plain label text.
190+
:func:`urllib.parse.urlparse` is lenient and would yield a matching scheme
191+
with an empty ``netloc`` for those, so the ``http``/``https`` branch
192+
additionally requires a non-empty host to match upstream. ``mailto:`` has
193+
no ``netloc`` by design and stays allowed.
182194
"""
183195
try:
184-
return urlparse(href).scheme in _SAFE_LINK_PROTOCOLS
196+
parsed = urlparse(href)
185197
except ValueError:
186198
return False
199+
if parsed.scheme not in _SAFE_LINK_PROTOCOLS:
200+
return False
201+
if parsed.scheme in ("http", "https"):
202+
return bool(parsed.netloc)
203+
return True

tests/test_teams_format_primitives.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import ast
1718
import importlib.util
1819
from pathlib import Path
1920

@@ -102,6 +103,27 @@ def test_disallowed_protocols_fail_the_ssrf_gate(self):
102103
assert safe_link_href("/internal") is False
103104
assert safe_link_href("") is False
104105

106+
def test_bare_scheme_http_hrefs_are_rejected_to_upstream_parity(self):
107+
# Upstream parses with `new URL(href)`, which throws (-> rejected) for a
108+
# bare-scheme http(s) href with no host. `urlparse` is lenient and would
109+
# yield a matching scheme with an empty netloc, so the gate requires a
110+
# non-empty host for http/https to stay at parity.
111+
assert safe_link_href("http:") is False
112+
assert safe_link_href("https://") is False
113+
assert safe_link_href("https:") is False
114+
# ...but a host-bearing http(s) href and host-less mailto still pass.
115+
assert safe_link_href("https://example.com") is True
116+
assert safe_link_href("http://example.com") is True
117+
assert safe_link_href("mailto:x@y.com") is True
118+
119+
def test_bare_scheme_links_render_as_plain_label(self):
120+
# End-to-end: a bare-scheme href must render as plain label, while a
121+
# host-bearing https link and a mailto link still emit live anchors.
122+
assert markdown_to_teams_html("[l](http:)") == "l"
123+
assert markdown_to_teams_html("[l](https://)") == "l"
124+
assert markdown_to_teams_html("[m](mailto:x@y.com)") == '<a href="mailto:x@y.com">m</a>'
125+
assert markdown_to_teams_html("[e](https://example.com)") == '<a href="https://example.com">e</a>'
126+
105127
def test_unescape_does_not_collapse_double_escaped_ampersand(self):
106128
# `&amp;lt;` must round-trip to `&lt;`, not `<` (reverse-order unescape).
107129
assert unescape_teams_text("&amp;lt;") == "&lt;"
@@ -113,6 +135,35 @@ def _format_module_source() -> str:
113135
return Path(spec.origin).read_text(encoding="utf8")
114136

115137

138+
def _imported_module_paths(source: str) -> list[tuple[str, list[str]]]:
139+
"""Yield ``(line, modules)`` for each import statement in ``source``.
140+
141+
``modules`` is every fully-qualified module path the statement reaches:
142+
143+
* ``import a.b`` -> ``["a.b"]``
144+
* ``from a.b import c, d`` -> ``["a.b", "a.b.c", "a.b.d"]`` (so the split
145+
``from <pkg> import <module>`` form is normalized to ``<pkg>.<module>``)
146+
147+
AST parsing makes the scan robust to formatting and reconstructs the dotted
148+
path that a plain substring check over the raw line would miss.
149+
"""
150+
tree = ast.parse(source)
151+
lines = source.splitlines()
152+
results: list[tuple[str, list[str]]] = []
153+
for node in ast.walk(tree):
154+
if isinstance(node, ast.Import):
155+
modules = [alias.name for alias in node.names]
156+
elif isinstance(node, ast.ImportFrom):
157+
base = node.module or ""
158+
modules = [base] if base else []
159+
modules += [f"{base}.{alias.name}" if base else alias.name for alias in node.names]
160+
else:
161+
continue
162+
line = lines[node.lineno - 1].strip() if node.lineno - 1 < len(lines) else ""
163+
results.append((line, modules))
164+
return results
165+
166+
116167
class TestFormatImportBoundary:
117168
"""Port of upstream ``format/boundary.test.ts``.
118169
@@ -127,11 +178,10 @@ class TestFormatImportBoundary:
127178
def test_source_does_not_import_the_sdk_runtime_or_adapter(self):
128179
# Inspect only the actual import statements, so the docstring (which
129180
# *mentions* these modules to describe the boundary) is not flagged.
130-
import_lines = [
131-
line.strip()
132-
for line in _format_module_source().splitlines()
133-
if line.strip().startswith(("import ", "from "))
134-
]
181+
# Both `import <forbidden>` AND `from <forbidden> import ...` forms must
182+
# be caught — including the split `from chat_sdk.adapters.teams import
183+
# adapter` form, which a plain substring scan misses (the dotted module
184+
# path `chat_sdk.adapters.teams.adapter` never appears on one side).
135185
forbidden = (
136186
"microsoft_teams",
137187
"httpx",
@@ -141,7 +191,12 @@ def test_source_does_not_import_the_sdk_runtime_or_adapter(self):
141191
"chat_sdk.adapters.teams.cards",
142192
"chat_sdk.chat",
143193
)
144-
present = [f"{token} :: {line}" for line in import_lines for token in forbidden if token in line]
194+
present = [
195+
f"{token} :: {line}"
196+
for line, modules in _imported_module_paths(_format_module_source())
197+
for token in forbidden
198+
if any(module == token or module.startswith(f"{token}.") for module in modules)
199+
]
145200
assert not present, f"format primitive imports forbidden modules: {present}"
146201

147202
def test_emoji_reuse_does_not_duplicate_unicode(self):

0 commit comments

Comments
 (0)