Skip to content

Commit 89e204c

Browse files
fix: use bounded read for integration catalog HTTP responses (#3763)
* fix(skills): match closing frontmatter delimiter on its own line SkillsIntegration.setup parsed each command template's frontmatter with raw.split("---", 2). A bare substring split stops at the first `---` *anywhere*, so a template whose description embeds `---` (e.g. "Separate sections with --- markers") truncated the parsed frontmatter: later keys were dropped, the description fell back to the generic default, and the leftover frontmatter spilled into the skill body. Scan for the closing `---` on its own line instead, for both the description parse and the body strip. The frontmatter block is parsed unstripped so trailing newlines in literal (|) block scalars still survive, and the body slice keeps the newline after the marker so output stays byte-for-byte identical to the old split for well-formed templates. Adds regression tests covering the dashed-description truncation and the frontmatter-spilled-into-body cases. * fix: use bounded read for integration catalog HTTP responses The integration catalog fetch used unbounded resp.read() to read HTTP responses into memory. A malicious or misconfigured catalog server could return an arbitrarily large response causing OOM. Replace with read_response_limited() capped at MAX_JSON_METADATA_BYTES (1 MiB), consistent with how other JSON fetch paths in the codebase (_version.py, _github_http.py, authentication/azure_devops.py) already enforce bounded reads. Pass error_type=IntegrationCatalogError so oversized catalogs are caught by the existing per-entry recovery path in _get_merged_integrations() rather than aborting the entire merge. Add regression test verifying oversized responses are rejected as IntegrationCatalogError and that healthy catalogs remain usable.
1 parent 88e9973 commit 89e204c

4 files changed

Lines changed: 245 additions & 13 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1652,13 +1652,27 @@ def setup(
16521652
command_name = src_file.stem # e.g. "plan"
16531653
skill_name = f"speckit-{command_name.replace('.', '-')}"
16541654

1655-
# Parse frontmatter for description
1655+
# Parse frontmatter for description. Locate the closing ``---`` on
1656+
# its own line rather than with ``raw.split("---", 2)`` — a bare
1657+
# substring split stops at the first ``---`` *anywhere*, including
1658+
# one inside a value such as ``description: Separate sections
1659+
# with ---``, which truncates the frontmatter and drops later keys.
1660+
# The block between the delimiters is parsed unstripped so trailing
1661+
# newlines in literal (``|``) block scalars survive.
16561662
frontmatter: dict[str, Any] = {}
16571663
if raw.startswith("---"):
1658-
parts = raw.split("---", 2)
1659-
if len(parts) >= 3:
1664+
fm_lines = raw.splitlines(keepends=True)
1665+
fm_close = next(
1666+
(
1667+
i
1668+
for i in range(1, len(fm_lines))
1669+
if fm_lines[i].rstrip() == "---"
1670+
),
1671+
None,
1672+
)
1673+
if fm_close is not None:
16601674
try:
1661-
fm = yaml.safe_load(parts[1])
1675+
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
16621676
if isinstance(fm, dict):
16631677
frontmatter = fm
16641678
except yaml.YAMLError:
@@ -1673,11 +1687,27 @@ def setup(
16731687
# Strip the processed frontmatter — we rebuild it for skills.
16741688
# Preserve leading whitespace in the body to match release ZIP
16751689
# output byte-for-byte (the template body starts with \n after
1676-
# the closing ---).
1690+
# the closing ---). Scan for the closing ``---`` on its own line
1691+
# rather than ``split("---", 2)`` so a ``---`` embedded in a value
1692+
# does not truncate the frontmatter and spill it into the body.
16771693
if processed_body.startswith("---"):
1678-
parts = processed_body.split("---", 2)
1679-
if len(parts) >= 3:
1680-
processed_body = parts[2]
1694+
body_lines = processed_body.splitlines(keepends=True)
1695+
close_idx = next(
1696+
(
1697+
i
1698+
for i in range(1, len(body_lines))
1699+
if body_lines[i].rstrip() == "---"
1700+
),
1701+
None,
1702+
)
1703+
if close_idx is not None:
1704+
# Keep whatever trails the ``---`` marker on the closing
1705+
# line (normally just the newline) so the body stays
1706+
# byte-for-byte identical to ``split("---", 2)[2]``. The
1707+
# line-anchored check guarantees ``---`` sits at index 0.
1708+
processed_body = body_lines[close_idx][3:] + "".join(
1709+
body_lines[close_idx + 1 :]
1710+
)
16811711

16821712
# Select description — use the original template description
16831713
# to stay byte-for-byte identical with release ZIP output.

src/specify_cli/integrations/catalog.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import yaml
2222
from packaging import version as pkg_version
2323

24+
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
2425
from ..catalogs import CatalogEntry, CatalogStackBase
2526

2627

@@ -200,7 +201,14 @@ def _fetch_single_catalog(
200201
final_url = resp.geturl()
201202
if final_url != entry.url:
202203
self._validate_catalog_url(final_url)
203-
catalog_data = json.loads(resp.read())
204+
catalog_data = json.loads(
205+
read_response_limited(
206+
resp,
207+
max_bytes=MAX_JSON_METADATA_BYTES,
208+
error_type=IntegrationCatalogError,
209+
label=f"catalog from {entry.url}",
210+
)
211+
)
204212

205213
shape_error = _catalog_shape_error(catalog_data)
206214
if shape_error is not None:

tests/integrations/test_integration_catalog.py

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,33 @@ def test_load_catalog_config_rejects_falsy_non_mapping_roots(
220220
# ---------------------------------------------------------------------------
221221

222222

223+
class _OversizedResponse:
224+
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
225+
226+
def __init__(self, data, url=""):
227+
self._data = json.dumps(data).encode()
228+
self._url = url if isinstance(url, str) else url.full_url
229+
self._pos = 0
230+
231+
def read(self, n=-1):
232+
if n < 0:
233+
chunk = self._data[self._pos:]
234+
self._pos = len(self._data)
235+
return chunk
236+
chunk = self._data[self._pos : self._pos + n]
237+
self._pos += len(chunk)
238+
return chunk
239+
240+
def geturl(self):
241+
return self._url
242+
243+
def __enter__(self):
244+
return self
245+
246+
def __exit__(self, *a):
247+
pass
248+
249+
223250
class TestCatalogFetch:
224251
"""Tests that use a local HTTP server stub via monkeypatch."""
225252

@@ -230,9 +257,16 @@ class FakeResponse:
230257
def __init__(self, data, url=""):
231258
self._data = json.dumps(data).encode()
232259
self._url = url if isinstance(url, str) else url.full_url
260+
self._pos = 0
233261

234-
def read(self):
235-
return self._data
262+
def read(self, n=-1):
263+
if n < 0:
264+
chunk = self._data[self._pos:]
265+
self._pos = len(self._data)
266+
return chunk
267+
chunk = self._data[self._pos:self._pos + n]
268+
self._pos += len(chunk)
269+
return chunk
236270

237271
def geturl(self):
238272
return self._url
@@ -395,6 +429,90 @@ def test_invalid_catalog_format(self, tmp_path, monkeypatch):
395429
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
396430
cat.search()
397431

432+
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
433+
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
434+
435+
The per-entry error is logged as a warning and skipped (not fatal).
436+
When ALL catalogs are oversized, search() raises the aggregate error.
437+
"""
438+
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
439+
440+
monkeypatch.setenv("HOME", str(tmp_path))
441+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
442+
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
443+
(tmp_path / ".specify").mkdir()
444+
cat = IntegrationCatalog(tmp_path)
445+
446+
# Build a valid catalog dict whose JSON encoding exceeds the limit.
447+
oversized = {
448+
"schema_version": "1.0",
449+
"integrations": {},
450+
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
451+
}
452+
453+
import specify_cli.authentication.http as _auth_http
454+
455+
def _oversized_urlopen(req, timeout=10):
456+
url = req if isinstance(req, str) else req.full_url
457+
return _OversizedResponse(oversized, url)
458+
459+
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
460+
461+
# Both default + community catalogs are oversized → all fail → aggregate error.
462+
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
463+
# logged as a warning; the aggregate raise has a different message.
464+
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
465+
cat.search()
466+
467+
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
468+
"""When one catalog is oversized, the healthy catalog still returns results."""
469+
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
470+
471+
monkeypatch.setenv("HOME", str(tmp_path))
472+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
473+
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
474+
specify = tmp_path / ".specify"
475+
specify.mkdir()
476+
477+
healthy_catalog = {
478+
"schema_version": "1.0",
479+
"integrations": {
480+
"good-agent": {
481+
"id": "good-agent",
482+
"name": "Good Agent",
483+
"version": "1.0.0",
484+
"description": "A healthy integration",
485+
"author": "test-org",
486+
},
487+
},
488+
}
489+
oversized_catalog = {
490+
"schema_version": "1.0",
491+
"integrations": {},
492+
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
493+
}
494+
cfg = specify / "integration-catalogs.yml"
495+
cfg.write_text(yaml.dump({"catalogs": [
496+
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
497+
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
498+
]}))
499+
cat = IntegrationCatalog(tmp_path)
500+
501+
import specify_cli.authentication.http as _auth_http
502+
503+
def _multi_catalog_urlopen(req, timeout=10):
504+
url = req if isinstance(req, str) else req.full_url
505+
if "oversized" in url:
506+
return _OversizedResponse(oversized_catalog, url)
507+
return _OversizedResponse(healthy_catalog, url)
508+
509+
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
510+
511+
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
512+
results = cat.search()
513+
ids = [r["id"] for r in results]
514+
assert "good-agent" in ids
515+
398516
def test_clear_cache(self, tmp_path):
399517
(tmp_path / ".specify").mkdir()
400518
cat = IntegrationCatalog(tmp_path)
@@ -592,8 +710,15 @@ class FakeResponse:
592710
def __init__(self, data, url=""):
593711
self._data = json.dumps(data).encode()
594712
self._url = url if isinstance(url, str) else url.full_url
595-
def read(self):
596-
return self._data
713+
self._pos = 0
714+
def read(self, n=-1):
715+
if n < 0:
716+
chunk = self._data[self._pos:]
717+
self._pos = len(self._data)
718+
return chunk
719+
chunk = self._data[self._pos:self._pos + n]
720+
self._pos += len(chunk)
721+
return chunk
597722
def geturl(self):
598723
return self._url
599724
def __enter__(self):

tests/integrations/test_skill_frontmatter_quoting.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@
3434
Body of the command.
3535
"""
3636

37+
# A description whose value contains an embedded ``---``. A substring split
38+
# (``raw.split("---", 2)``) stops at this inner marker, truncating the parsed
39+
# frontmatter — the closing document separator on its own line is the real
40+
# boundary. See TestSkillFrontmatterEmbeddedDashes below.
41+
DASHED_DESCRIPTION = "Separate sections with --- markers"
42+
DASHED_TEMPLATE = """---
43+
description: Separate sections with --- markers
44+
name-marker: sentinel
45+
---
46+
47+
Body of the command.
48+
"""
49+
3750

3851
def _parse_frontmatter(skill_file: Path) -> dict:
3952
content = skill_file.read_text(encoding="utf-8")
@@ -90,6 +103,62 @@ def test_control_character_description_parses(self, tmp_path, monkeypatch):
90103
assert fm["description"] == CONTROL
91104

92105

106+
def _parse_frontmatter_line_anchored(skill_file: Path) -> dict:
107+
"""Parse SKILL.md frontmatter using the closing ``---`` on its own line.
108+
109+
Unlike ``_parse_frontmatter`` (which uses ``split("---", 2)``), this is
110+
robust to a ``---`` embedded in a value, so it can validate that the
111+
generated frontmatter is itself well formed.
112+
"""
113+
content = skill_file.read_text(encoding="utf-8")
114+
assert content.startswith("---\n")
115+
lines = content.splitlines(keepends=True)
116+
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
117+
return yaml.safe_load("".join(lines[1:end]))
118+
119+
120+
class TestSkillFrontmatterEmbeddedDashes:
121+
"""A ``---`` inside a description value must not truncate parsing (#3634).
122+
123+
The skills setup path parsed template frontmatter with
124+
``raw.split("---", 2)``, which stops at the first ``---`` *anywhere* —
125+
including one inside a value such as ``description: ... --- ...``. That
126+
dropped every frontmatter key after the marker (so the description fell
127+
back to the generic default) and spilled the leftover frontmatter into
128+
the skill body. The parser must match the closing ``---`` on its own line.
129+
"""
130+
131+
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
132+
integration = get_integration("agy")
133+
monkeypatch.setattr(
134+
integration,
135+
"shared_commands_dir",
136+
lambda: _fake_templates(tmp_path, template),
137+
)
138+
manifest = IntegrationManifest("agy", tmp_path)
139+
created = integration.setup(tmp_path, manifest)
140+
skill_files = [f for f in created if f.name == "SKILL.md"]
141+
assert len(skill_files) == 1
142+
return skill_files[0]
143+
144+
def test_dashed_description_is_preserved(self, tmp_path, monkeypatch):
145+
skill_file = self._generate(tmp_path, monkeypatch, DASHED_TEMPLATE)
146+
fm = _parse_frontmatter_line_anchored(skill_file)
147+
# Buggy split("---", 2) truncates the value to "Separate sections with"
148+
# (or drops it entirely, falling back to "Spec Kit: plan workflow").
149+
assert fm["description"] == DASHED_DESCRIPTION
150+
151+
def test_leftover_frontmatter_not_spilled_into_body(self, tmp_path, monkeypatch):
152+
skill_file = self._generate(tmp_path, monkeypatch, DASHED_TEMPLATE)
153+
content = skill_file.read_text(encoding="utf-8")
154+
lines = content.splitlines(keepends=True)
155+
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
156+
body = "".join(lines[end + 1 :])
157+
# The template's trailing frontmatter key must not leak into the body.
158+
assert "name-marker: sentinel" not in body
159+
assert "Body of the command." in body
160+
161+
93162
class TestHermesSkillFrontmatterQuoting:
94163
def test_multiline_description_survives(self, tmp_path, monkeypatch):
95164
home = tmp_path / "home"

0 commit comments

Comments
 (0)