Skip to content

Commit 771cb50

Browse files
fix: bound stdin reads to 10 MiB via sys.stdin.buffer for byte-accurate limiting
Address Copilot review feedback on PR #3903: - Use sys.stdin.buffer.read() instead of sys.stdin.read() so the 10 MiB limit is enforced on raw bytes rather than Unicode code points (a 4-byte UTF-8 sequence now counts as 4 bytes, not 1 character). - Add regression tests for both the events module and CLI event runner: below-limit, exact-limit, oversized, multibyte UTF-8, empty stdin, TTY, and invalid UTF-8 replacement.
1 parent c3bbcc4 commit 771cb50

3 files changed

Lines changed: 197 additions & 14 deletions

File tree

src/specify_cli/commands/event.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@
66
import sys
77
import typer
88

9+
_MAX_STDIN_BYTES = 10 * 1024 * 1024 # 10 MiB
10+
11+
12+
def _read_stdin_bounded(max_bytes: int = _MAX_STDIN_BYTES) -> str:
13+
"""Read at most *max_bytes* from stdin to prevent unbounded memory use.
14+
15+
Uses ``sys.stdin.buffer`` so the limit is enforced on raw bytes rather
16+
than Unicode code points — a 4-byte UTF-8 sequence counts as 4 bytes,
17+
not 1 character.
18+
"""
19+
if sys.stdin.isatty():
20+
return "{}"
21+
chunks: list[bytes] = []
22+
total = 0
23+
while total < max_bytes:
24+
chunk = sys.stdin.buffer.read(min(max_bytes - total, 65536))
25+
if not chunk:
26+
break
27+
chunks.append(chunk)
28+
total += len(chunk)
29+
return b"".join(chunks).decode("utf-8", errors="replace")
30+
31+
932
event_app = typer.Typer(
1033
name="event",
1134
help="Manage and execute event-driven commands",
@@ -24,19 +47,7 @@ def event_run(
2447
"""Resolve and run an event-driven command script with stdin payload."""
2548
from ..events import resolve_and_run_event_command
2649

27-
# Read payload from stdin if available (capped at 1 MiB to prevent DoS).
28-
MAX_STDIN_BYTES = 1 * 1024 * 1024
29-
if not sys.stdin.isatty():
30-
raw = sys.stdin.read(MAX_STDIN_BYTES)
31-
if not sys.stdin.eof:
32-
raise typer.Exit(
33-
code=1,
34-
message="stdin payload exceeds 1 MiB limit; "
35-
"truncate or pipe a smaller payload",
36-
)
37-
payload = raw
38-
else:
39-
payload = "{}"
50+
payload = _read_stdin_bounded()
4051

4152
# Run the event command
4253
project_root = Path.cwd() # The agent runs events from project root

src/specify_cli/events.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,30 @@
6161
"stop",
6262
})
6363

64+
# -- Stdin bounded read ---------------------------------------------------
65+
66+
_MAX_STDIN_BYTES = 10 * 1024 * 1024 # 10 MiB
67+
68+
69+
def _read_stdin_bounded(max_bytes: int = _MAX_STDIN_BYTES) -> str:
70+
"""Read at most *max_bytes* from stdin to prevent unbounded memory use.
71+
72+
Uses ``sys.stdin.buffer`` so the limit is enforced on raw bytes rather
73+
than Unicode code points — a 4-byte UTF-8 sequence counts as 4 bytes,
74+
not 1 character.
75+
"""
76+
if sys.stdin.isatty():
77+
return "{}"
78+
chunks: list[bytes] = []
79+
total = 0
80+
while total < max_bytes:
81+
chunk = sys.stdin.buffer.read(min(max_bytes - total, 65536))
82+
if not chunk:
83+
break
84+
chunks.append(chunk)
85+
total += len(chunk)
86+
return b"".join(chunks).decode("utf-8", errors="replace")
87+
6488
# -- Events Dispatcher template ---------------------------------------------
6589

6690
_EVENTS_DISPATCHER_TEMPLATE = '''#!/usr/bin/env python3
@@ -347,7 +371,7 @@ def main():
347371
# hookEventName field (required by Qwen's hooks spec; included by
348372
# Gemini/Tabnine/Devin which derive from the same protocol).
349373
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
350-
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
374+
payload = _read_stdin_bounded()
351375
project_root = Path(__file__).parent.parent.resolve()
352376
353377
# Preferred path: specify_cli is importable (durable install) — delegate to

tests/integrations/test_events.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2525,3 +2525,151 @@ def test_refresh_failure_preserves_existing_config(self, tmp_path):
25252525
# The pre-existing config was NOT destroyed before the failure
25262526
# (install handles cleanup atomically; refresh no longer pre-strips).
25272527
assert config_path.read_text() == original
2528+
2529+
2530+
# -- Bounded stdin reader ---------------------------------------------------
2531+
2532+
class TestReadStdinBounded:
2533+
"""Test _read_stdin_bounded byte-accurate limiting."""
2534+
2535+
def test_small_payload_passes_through(self):
2536+
from specify_cli.events import _read_stdin_bounded
2537+
from io import BytesIO
2538+
2539+
buf = BytesIO(b'{"key": "value"}')
2540+
stdin_mock = MagicMock()
2541+
stdin_mock.isatty.return_value = False
2542+
stdin_mock.buffer = buf
2543+
with patch("specify_cli.events.sys") as mock_sys:
2544+
mock_sys.stdin = stdin_mock
2545+
result = _read_stdin_bounded(max_bytes=1024)
2546+
assert result == '{"key": "value"}'
2547+
2548+
def test_multibyte_utf8_counted_as_bytes(self):
2549+
"""A 4-byte UTF-8 character counts as 4 bytes, not 1 character."""
2550+
from specify_cli.events import _read_stdin_bounded
2551+
from io import BytesIO
2552+
2553+
# U+1F600 (😀) is 4 bytes in UTF-8
2554+
payload = "hello \U0001F600 world".encode("utf-8")
2555+
assert len(payload) == 16 # "hello " (6) + 😀 (4) + " world" (6)
2556+
2557+
buf = BytesIO(payload)
2558+
stdin_mock = MagicMock()
2559+
stdin_mock.isatty.return_value = False
2560+
stdin_mock.buffer = buf
2561+
with patch("specify_cli.events.sys") as mock_sys:
2562+
mock_sys.stdin = stdin_mock
2563+
result = _read_stdin_bounded(max_bytes=16)
2564+
assert len(result.encode("utf-8")) == 16
2565+
2566+
def test_oversized_payload_truncated(self):
2567+
from specify_cli.events import _read_stdin_bounded
2568+
from io import BytesIO
2569+
2570+
buf = BytesIO(b"x" * 200)
2571+
stdin_mock = MagicMock()
2572+
stdin_mock.isatty.return_value = False
2573+
stdin_mock.buffer = buf
2574+
with patch("specify_cli.events.sys") as mock_sys:
2575+
mock_sys.stdin = stdin_mock
2576+
result = _read_stdin_bounded(max_bytes=100)
2577+
assert len(result.encode("utf-8")) == 100
2578+
assert result == "x" * 100
2579+
2580+
def test_exact_limit_passes(self):
2581+
from specify_cli.events import _read_stdin_bounded
2582+
from io import BytesIO
2583+
2584+
buf = BytesIO(b"a" * 65536)
2585+
stdin_mock = MagicMock()
2586+
stdin_mock.isatty.return_value = False
2587+
stdin_mock.buffer = buf
2588+
with patch("specify_cli.events.sys") as mock_sys:
2589+
mock_sys.stdin = stdin_mock
2590+
result = _read_stdin_bounded(max_bytes=65536)
2591+
assert result == "a" * 65536
2592+
2593+
def test_tty_returns_empty_json(self):
2594+
from specify_cli.events import _read_stdin_bounded
2595+
2596+
stdin_mock = MagicMock()
2597+
stdin_mock.isatty.return_value = True
2598+
with patch("specify_cli.events.sys") as mock_sys:
2599+
mock_sys.stdin = stdin_mock
2600+
result = _read_stdin_bounded()
2601+
assert result == "{}"
2602+
2603+
def test_empty_stdin_returns_empty_string(self):
2604+
from specify_cli.events import _read_stdin_bounded
2605+
from io import BytesIO
2606+
2607+
buf = BytesIO(b"")
2608+
stdin_mock = MagicMock()
2609+
stdin_mock.isatty.return_value = False
2610+
stdin_mock.buffer = buf
2611+
with patch("specify_cli.events.sys") as mock_sys:
2612+
mock_sys.stdin = stdin_mock
2613+
result = _read_stdin_bounded()
2614+
assert result == ""
2615+
2616+
def test_invalid_utf8_replaced(self):
2617+
from specify_cli.events import _read_stdin_bounded
2618+
from io import BytesIO
2619+
2620+
buf = BytesIO(b"hello\xff\xfeworld")
2621+
stdin_mock = MagicMock()
2622+
stdin_mock.isatty.return_value = False
2623+
stdin_mock.buffer = buf
2624+
with patch("specify_cli.events.sys") as mock_sys:
2625+
mock_sys.stdin = stdin_mock
2626+
result = _read_stdin_bounded()
2627+
assert "hello" in result
2628+
assert "world" in result
2629+
2630+
2631+
class TestReadStdinBoundedCLI:
2632+
"""Test the CLI event runner's bounded stdin reader."""
2633+
2634+
def test_cli_reader_small_payload(self):
2635+
from specify_cli.commands.event import _read_stdin_bounded
2636+
from io import BytesIO
2637+
2638+
buf = BytesIO(b'{"event": "test"}')
2639+
stdin_mock = MagicMock()
2640+
stdin_mock.isatty.return_value = False
2641+
stdin_mock.buffer = buf
2642+
with patch("specify_cli.commands.event.sys") as mock_sys:
2643+
mock_sys.stdin = stdin_mock
2644+
result = _read_stdin_bounded(max_bytes=1024)
2645+
assert result == '{"event": "test"}'
2646+
2647+
def test_cli_reader_oversized(self):
2648+
from specify_cli.commands.event import _read_stdin_bounded
2649+
from io import BytesIO
2650+
2651+
buf = BytesIO(b"y" * 500)
2652+
stdin_mock = MagicMock()
2653+
stdin_mock.isatty.return_value = False
2654+
stdin_mock.buffer = buf
2655+
with patch("specify_cli.commands.event.sys") as mock_sys:
2656+
mock_sys.stdin = stdin_mock
2657+
result = _read_stdin_bounded(max_bytes=100)
2658+
assert len(result) == 100
2659+
2660+
def test_cli_reader_multibyte(self):
2661+
from specify_cli.commands.event import _read_stdin_bounded
2662+
from io import BytesIO
2663+
2664+
# U+00E9 (é) is 2 bytes in UTF-8
2665+
payload = "caf\u00e9".encode("utf-8")
2666+
assert len(payload) == 5 # c + a + f + 2
2667+
2668+
buf = BytesIO(payload)
2669+
stdin_mock = MagicMock()
2670+
stdin_mock.isatty.return_value = False
2671+
stdin_mock.buffer = buf
2672+
with patch("specify_cli.commands.event.sys") as mock_sys:
2673+
mock_sys.stdin = stdin_mock
2674+
result = _read_stdin_bounded(max_bytes=5)
2675+
assert result == "caf\u00e9"

0 commit comments

Comments
 (0)