From 2111a5156eefe011897e1c9060e4532a35632dc4 Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Wed, 26 Aug 2026 14:52:43 +0500 Subject: [PATCH] fix(events): cap stdin in the generated dispatcher, not just the CLI command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #3857 fix capped stdin at 1 MiB in `specify event run` (src/specify_cli/commands/event.py), but that command is not the code path native hooks actually invoke. Every installed integration writes a self-contained `.specify/events.py` dispatcher (the `_EVENTS_DISPATCHER_TEMPLATE` string in src/specify_cli/events.py) that native hook configs call directly, and its `main()` did: payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" with no size cap at all — the exact DoS #3857 was meant to close, wide open on the primary invocation path. `specify event run` is a secondary/manual entry point; the generated dispatcher is what actually runs on every session_start/pre_tool_use/etc. hook fire in real usage. Fix: apply the same byte-capped read (from the binary buffer, so the cap counts encoded bytes rather than decoded characters — matching the just-merged fix for the CLI command) inside the dispatcher template, so every newly-installed or refreshed dispatcher enforces the limit. ## Test plan - Added 3 tests in tests/integrations/test_events.py::TestCommandRunner: an oversized payload exits 1 with the limit message instead of running unbounded, a multibyte payload (~300k emoji, ~1.14 MiB UTF-8 but only 300k characters) is still rejected by the byte-based cap, and a normal under-the-cap payload still reaches the handler script unchanged. - Verified both new failing-without-fix tests via test-the-test (stashed the src fix): the oversized-payload test failed because the dispatcher silently accepted the full payload and returned "not found" instead of exiting 1 with the limit message — reproducing the exact bug. - Ran the full tests/integrations/test_events.py suite (124/128 pass; the remaining 4 are the pre-existing Windows symlink-elevation failures unrelated to this change). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PJHJ2dHP2RVCNncHqN8Qm9 --- src/specify_cli/events.py | 16 ++++- tests/integrations/test_events.py | 105 ++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 83da04d4fb..ef0f152b64 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -375,7 +375,21 @@ def main(): # hookEventName field (required by Qwen's hooks spec; included by # Gemini/Tabnine/Devin which derive from the same protocol). native_event = sys.argv[5] if len(sys.argv) >= 6 else "" - payload = sys.stdin.read() if not sys.stdin.isatty() else "{}" + # Cap piped stdin at 1 MiB to prevent a DoS (mirrors the same guard on the + # `specify event run` CLI command). Read from the binary buffer so the cap + # counts encoded bytes, not decoded characters. + MAX_STDIN_BYTES = 1 * 1024 * 1024 + if not sys.stdin.isatty(): + raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1) + if len(raw) > MAX_STDIN_BYTES: + print( + "stdin payload exceeds 1 MiB limit; truncate or pipe a smaller payload", + file=sys.stderr, + ) + sys.exit(1) + payload = raw.decode("utf-8") + else: + payload = "{}" project_root = Path(__file__).parent.parent.resolve() # Preferred path: specify_cli is importable (durable install) — delegate to diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index f74aeaaa36..bb55fbe293 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1503,6 +1503,111 @@ def test_dispatcher_ignores_stale_specify_cli_without_confinement(self, tmp_path ) assert not ran.exists(), f"stale package ran; stderr={result.stderr!r}" + def test_dispatcher_rejects_oversized_stdin(self, tmp_path): + """The generated dispatcher — the actual script native hooks invoke — + must enforce the same 1 MiB stdin cap as `specify event run`. The + #3857 DoS guard previously only applied to the CLI command; the + template's own `sys.stdin.read()` had no cap at all.""" + import subprocess as _sp + import sys as _sys + + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + dispatcher = tmp_path / EVENTS_DISPATCHER_REL + + oversized = "x" * (1 * 1024 * 1024 + 10) + result = _sp.run( + [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], + input=oversized, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(tmp_path), + ) + assert result.returncode == 1, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "1 MiB limit" in result.stderr + + def test_dispatcher_stdin_cap_counts_bytes_not_characters(self, tmp_path): + """~300k emoji is ~1.14 MiB of UTF-8 but only 300k *characters* — + comfortably under a text-mode `sys.stdin.read(N)` character cap. The + dispatcher must still reject it by reading from the binary buffer.""" + import subprocess as _sp + import sys as _sys + + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + dispatcher = tmp_path / EVENTS_DISPATCHER_REL + + oversized = "\U0001F600" * 300_000 # 4 bytes each in UTF-8 + assert len(oversized) < 1 * 1024 * 1024 # under a character-based cap + result = _sp.run( + [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], + input=oversized, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(tmp_path), + ) + assert result.returncode == 1, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "1 MiB limit" in result.stderr + + def test_dispatcher_underlimit_stdin_still_runs(self, tmp_path): + """A normal, under-the-cap piped payload must still reach the handler + (regression guard against an over-eager cap check).""" + import subprocess as _sp + import sys as _sys + + integration = ClaudeIntegration() + manifest = MagicMock(spec=IntegrationManifest) + manifest.files = {} + manifest.record_file = MagicMock() + manifest.record_existing = MagicMock() + install_integration_events( + integration, tmp_path, manifest, + {"session_start": [{"command": "speckit.boot"}]}, + ) + dispatcher = tmp_path / EVENTS_DISPATCHER_REL + + cmd_dir = tmp_path / ".specify" / "templates" / "commands" + cmd_dir.mkdir(parents=True) + out_file = tmp_path / "payload.out" + (cmd_dir / "boot.md").write_text( + "---\ndescription: \"Boot\"\nscripts:\n sh: scripts/boot.sh\n---\nBody\n", + encoding="utf-8", + ) + script_dir = tmp_path / ".specify" / "scripts" + script_dir.mkdir(parents=True) + script = script_dir / "boot.sh" + script.write_text(f"#!/bin/sh\ncat > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + + if platform.system().lower().startswith("win"): + return # sh is POSIX + + result = _sp.run( + [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], + input='{"key": "value"}', + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert out_file.read_text() == '{"key": "value"}' + def test_dispatcher_threads_per_handler_timeout(self, tmp_path): """S4: the generated dispatcher reads an optional 4th timeout arg and uses it for the inner subprocess, instead of a fixed 120s cap that