Skip to content

Commit 39e5555

Browse files
committed
Harden audit log download validation
1 parent 8174c85 commit 39e5555

2 files changed

Lines changed: 113 additions & 9 deletions

File tree

src/kernel/lib/audit_log_download.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .._exceptions import KernelError
1414

1515
_DEFAULT_MAX_TRANSFER_RETRIES = 6
16+
_MAX_CHUNK_ROWS = 50_000
1617
_MAX_RETRY_DELAY = 8.0
1718

1819

@@ -198,11 +199,10 @@ def _parse_chunk_headers(headers: httpx.Headers, current_cursor: str | None) ->
198199
has_more = has_more_value == "true"
199200

200201
row_count = headers.get("x-row-count")
201-
try:
202-
rows = int(row_count) if row_count is not None else -1
203-
except ValueError as error:
204-
raise AuditLogDownloadError("response missing or invalid X-Row-Count header") from error
205-
if rows < 0:
202+
if row_count is None or not row_count.isascii() or not row_count.isdecimal():
203+
raise AuditLogDownloadError("response missing or invalid X-Row-Count header")
204+
rows = int(row_count)
205+
if rows > _MAX_CHUNK_ROWS:
206206
raise AuditLogDownloadError("response missing or invalid X-Row-Count header")
207207

208208
next_cursor = headers.get("x-next-cursor") or None
@@ -217,11 +217,11 @@ def _write_chunk(destination: BinaryIO, body: bytes) -> None:
217217
remaining = memoryview(body)
218218
while remaining:
219219
written = destination.write(remaining)
220-
if written <= 0 or written > len(remaining):
220+
if type(written) is not int or written <= 0 or written > len(remaining):
221221
raise AuditLogDownloadError("audit log download destination performed a short write")
222222
remaining = remaining[written:]
223223

224224

225225
def _validate_max_transfer_retries(max_transfer_retries: int) -> None:
226-
if max_transfer_retries < 0:
227-
raise ValueError("max_transfer_retries must be non-negative")
226+
if type(max_transfer_retries) is not int or max_transfer_retries < 0:
227+
raise ValueError("max_transfer_retries must be a non-negative integer")

tests/test_audit_log_download.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import hashlib
44
import threading
55
from io import BytesIO
6-
from typing import BinaryIO
6+
from typing import BinaryIO, cast
77

88
import httpx
99
import pytest
@@ -98,6 +98,110 @@ async def on_progress(_: object) -> None:
9898
assert thread_ids and all(thread_id != threading.get_ident() for thread_id in thread_ids)
9999

100100

101+
async def test_async_download_retries_checksum_mismatch(
102+
async_client: AsyncKernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch
103+
) -> None:
104+
async def no_sleep(_: float) -> None:
105+
pass
106+
107+
monkeypatch.setattr(audit_log_download.anyio, "sleep", no_sleep)
108+
bad = chunk_response(b"bad", rows=1, has_more=False)
109+
bad.headers["x-content-sha256"] = hashlib.sha256(b"good").hexdigest()
110+
route = respx_mock.get("/audit-logs/export/chunk").mock(
111+
side_effect=[bad, chunk_response(b"good", rows=1, has_more=False)]
112+
)
113+
destination = BytesIO()
114+
115+
await async_client.audit_logs.download(
116+
to=destination,
117+
start="2026-06-01T00:00:00Z",
118+
end="2026-06-02T00:00:00Z",
119+
)
120+
121+
assert destination.getvalue() == b"good"
122+
assert route.call_count == 2
123+
124+
125+
async def test_async_download_uses_client_http_retries(async_client: AsyncKernel, respx_mock: MockRouter) -> None:
126+
route = respx_mock.get("/audit-logs/export/chunk").mock(
127+
side_effect=[
128+
httpx.Response(500, json={"message": "temporary failure"}),
129+
chunk_response(b"good", rows=1, has_more=False),
130+
]
131+
)
132+
133+
await async_client.audit_logs.download(
134+
to=BytesIO(),
135+
start="2026-06-01T00:00:00Z",
136+
end="2026-06-02T00:00:00Z",
137+
)
138+
139+
assert route.call_count == 2
140+
141+
142+
async def test_async_download_respects_disabled_http_retries(async_client: AsyncKernel, respx_mock: MockRouter) -> None:
143+
route = respx_mock.get("/audit-logs/export/chunk").mock(
144+
return_value=httpx.Response(500, json={"message": "temporary failure"})
145+
)
146+
147+
with pytest.raises(InternalServerError, match="500"):
148+
await async_client.with_options(max_retries=0).audit_logs.download(
149+
to=BytesIO(),
150+
start="2026-06-01T00:00:00Z",
151+
end="2026-06-02T00:00:00Z",
152+
)
153+
154+
assert route.call_count == 1
155+
156+
157+
@pytest.mark.parametrize("row_count", ["", "1.0", "50001"])
158+
def test_download_rejects_invalid_row_count(row_count: str, client: Kernel, respx_mock: MockRouter) -> None:
159+
response = chunk_response(b"chunk", rows=1, has_more=False)
160+
response.headers["x-row-count"] = row_count
161+
respx_mock.get("/audit-logs/export/chunk").mock(return_value=response)
162+
destination = BytesIO()
163+
164+
with pytest.raises(AuditLogDownloadError, match="response missing or invalid X-Row-Count header"):
165+
client.audit_logs.download(
166+
to=destination,
167+
start="2026-06-01T00:00:00Z",
168+
end="2026-06-02T00:00:00Z",
169+
)
170+
171+
assert destination.getvalue() == b""
172+
173+
174+
class InvalidWriteDestination:
175+
def __init__(self, result: object) -> None:
176+
self.result = result
177+
178+
def write(self, _: object) -> object:
179+
return self.result
180+
181+
182+
@pytest.mark.parametrize("write_result", [float("nan"), 0.5, True])
183+
def test_download_rejects_invalid_write_result(write_result: object, client: Kernel, respx_mock: MockRouter) -> None:
184+
respx_mock.get("/audit-logs/export/chunk").mock(return_value=chunk_response(b"chunk", rows=1, has_more=False))
185+
186+
with pytest.raises(AuditLogDownloadError, match="audit log download destination performed a short write"):
187+
client.audit_logs.download(
188+
to=cast(BinaryIO, InvalidWriteDestination(write_result)),
189+
start="2026-06-01T00:00:00Z",
190+
end="2026-06-02T00:00:00Z",
191+
)
192+
193+
194+
@pytest.mark.parametrize("max_transfer_retries", [True, 1.5])
195+
def test_download_rejects_invalid_transfer_retry_count(max_transfer_retries: object, client: Kernel) -> None:
196+
with pytest.raises(ValueError, match="max_transfer_retries must be a non-negative integer"):
197+
client.audit_logs.download(
198+
to=BytesIO(),
199+
start="2026-06-01T00:00:00Z",
200+
end="2026-06-02T00:00:00Z",
201+
max_transfer_retries=cast(int, max_transfer_retries),
202+
)
203+
204+
101205
def test_download_retries_checksum_mismatch(
102206
client: Kernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch
103207
) -> None:

0 commit comments

Comments
 (0)