Skip to content

Commit 3174d7e

Browse files
feat(#320): convert upload_artifact to streaming-form-data with mid-stream 413 abort
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/60342348-2c34-4552-836f-d1aab44ec210 Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
1 parent c507e89 commit 3174d7e

4 files changed

Lines changed: 243 additions & 55 deletions

File tree

backend/app/api/external_results.py

Lines changed: 109 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
import uuid as _uuid_module
1919
from uuid import UUID
2020

21-
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
21+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
2222
from sqlalchemy.ext.asyncio import AsyncSession
23+
from streaming_form_data import StreamingFormDataParser
24+
from streaming_form_data.targets import FileTarget, ValueTarget
2325

2426
from app.auth.dependencies import (
2527
get_runner_or_user_auth,
@@ -51,11 +53,11 @@
5153
_DEFAULT_RUNNER = "@bgstm/playwright-core@unknown"
5254

5355
# ---------------------------------------------------------------------------
54-
# Artifact upload constants
56+
# Artifact upload constants and helpers
5557
# ---------------------------------------------------------------------------
5658

57-
# Read the upload stream in 64 KiB chunks. Tests may monkeypatch this value
58-
# to a smaller number to exercise the streaming / partial-write path.
59+
# Kept as a module attribute so existing monkeypatch calls in tests don't fail
60+
# (the streaming implementation uses network-driven chunk sizes, not this value).
5961
_ARTIFACT_CHUNK_SIZE: int = 65_536 # 64 KiB
6062

6163
# Content-type allowlist (global). ``artifact_kind.other`` bypasses this check.
@@ -427,16 +429,45 @@ def _safe_unlink(path: str) -> None:
427429
pass
428430

429431

432+
class _SizeLimitExceeded(Exception):
433+
"""Raised inside ``_SizeLimitedFileTarget.on_data_received`` when the
434+
running byte total exceeds ``max_bytes``. The stream loop catches this
435+
sentinel and stops reading immediately — bytes past the limit are never
436+
consumed from the request stream.
437+
"""
438+
439+
440+
class _SizeLimitedFileTarget(FileTarget):
441+
"""``FileTarget`` subclass that aborts mid-stream on size-limit violation."""
442+
443+
def __init__(self, filename: str, *, max_bytes: int) -> None:
444+
super().__init__(filename)
445+
self._max_bytes = max_bytes
446+
self.size_bytes: int = 0
447+
# Redeclare with an explicit type so mypy can resolve it (FileTarget sets it
448+
# to None in __init__ but the stubs don't expose its type).
449+
self._fd = None # type: ignore[assignment]
450+
451+
def on_data_received(self, chunk: bytes) -> None:
452+
self.size_bytes += len(chunk)
453+
if self.size_bytes > self._max_bytes:
454+
# Close the file descriptor before aborting so the caller can safely
455+
# unlink the temp file on all platforms.
456+
fd = self._fd # type: ignore[has-type]
457+
if fd is not None:
458+
fd.close()
459+
self._fd = None # type: ignore[has-type]
460+
raise _SizeLimitExceeded()
461+
super().on_data_received(chunk)
462+
463+
430464
@router.post(
431465
"/external-results/artifact",
432466
response_model=ArtifactResponse,
433467
status_code=status.HTTP_201_CREATED,
434468
)
435469
async def upload_artifact(
436-
case_result_id: str = Form(...),
437-
kind: str = Form(...),
438-
filename: str = Form(...),
439-
file: UploadFile = File(...),
470+
request: Request,
440471
db: AsyncSession = Depends(get_db),
441472
token: RunnerToken = Depends(require_runner_scope(_WRITE_SCOPE)),
442473
) -> ArtifactResponse:
@@ -448,11 +479,75 @@ async def upload_artifact(
448479
- ``filename`` — original filename including extension.
449480
- ``file`` — binary body; its ``Content-Type`` part header is used as the
450481
artifact content-type.
482+
483+
The handler uses ``streaming-form-data`` to parse the multipart body chunk by
484+
chunk. As soon as the cumulative byte count of the ``file`` part exceeds
485+
``BGSTM_ARTIFACT_MAX_BYTES``, a ``_SizeLimitExceeded`` sentinel is raised
486+
inside the part-data callback, the stream loop exits immediately (no further
487+
reads), the temp file is deleted, and 413 is returned.
451488
"""
489+
max_bytes: int = settings.BGSTM_ARTIFACT_MAX_BYTES
490+
491+
# Create the temp file upfront; ``FileTarget`` will reopen it via ``on_start``.
492+
fd, tmp_path = tempfile.mkstemp(prefix="bgstm_artifact_")
493+
os.close(fd)
494+
495+
case_result_id_target = ValueTarget()
496+
kind_target = ValueTarget()
497+
filename_target = ValueTarget()
498+
file_target = _SizeLimitedFileTarget(tmp_path, max_bytes=max_bytes)
499+
500+
parser = StreamingFormDataParser(headers=request.headers)
501+
parser.register("case_result_id", case_result_id_target)
502+
parser.register("kind", kind_target)
503+
parser.register("filename", filename_target)
504+
parser.register("file", file_target)
505+
506+
try:
507+
async for chunk in request.stream():
508+
parser.data_received(chunk)
509+
except _SizeLimitExceeded:
510+
_safe_unlink(tmp_path)
511+
raise HTTPException(
512+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
513+
detail={
514+
"code": "artifact.too_large",
515+
"message": f"Artifact exceeds the maximum allowed size of {max_bytes} bytes.",
516+
"details": None,
517+
},
518+
)
519+
except Exception as exc:
520+
_safe_unlink(tmp_path)
521+
raise HTTPException(
522+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
523+
detail={
524+
"code": "validation_error",
525+
"message": f"Failed to parse multipart body: {exc}",
526+
"details": None,
527+
},
528+
) from exc
529+
530+
# --- Decode text fields ---
531+
try:
532+
case_result_id = case_result_id_target.value.decode("utf-8")
533+
kind = kind_target.value.decode("utf-8")
534+
filename = filename_target.value.decode("utf-8")
535+
except UnicodeDecodeError as exc:
536+
_safe_unlink(tmp_path)
537+
raise HTTPException(
538+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
539+
detail={
540+
"code": "validation_error",
541+
"message": f"Multipart text field contains invalid UTF-8: {exc}",
542+
"details": None,
543+
},
544+
) from exc
545+
452546
# --- Validate kind ---
453547
try:
454548
artifact_kind = ArtifactKind(kind)
455549
except ValueError:
550+
_safe_unlink(tmp_path)
456551
raise HTTPException(
457552
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
458553
detail={
@@ -463,11 +558,9 @@ async def upload_artifact(
463558
)
464559

465560
# --- Sanitize and validate filename (path-traversal defense) ---
466-
# Reject if the filename differs from its own basename OR fails the allowlist regex.
467-
# This catches directory components (`../`, `subdir/`, `/etc/`) as well as
468-
# dangerous characters (null bytes, backslashes, spaces, etc.).
469561
safe_filename = os.path.basename(filename)
470562
if safe_filename != filename or not _SAFE_FILENAME_RE.fullmatch(safe_filename):
563+
_safe_unlink(tmp_path)
471564
raise HTTPException(
472565
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
473566
detail={
@@ -484,6 +577,7 @@ async def upload_artifact(
484577
try:
485578
case_result_uuid = _uuid_module.UUID(case_result_id)
486579
except ValueError:
580+
_safe_unlink(tmp_path)
487581
raise HTTPException(
488582
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
489583
detail={
@@ -496,6 +590,7 @@ async def upload_artifact(
496590
# --- Verify the case result exists ---
497591
case_result = await get_case_result(db, case_result_uuid)
498592
if case_result is None:
593+
_safe_unlink(tmp_path)
499594
raise HTTPException(
500595
status_code=status.HTTP_404_NOT_FOUND,
501596
detail={
@@ -506,10 +601,12 @@ async def upload_artifact(
506601
)
507602

508603
# --- Derive content-type from the upload part header ---
509-
content_type: str = (file.content_type or "application/octet-stream").split(";")[0].strip().lower()
604+
raw_ct = getattr(file_target, "multipart_content_type", None) or "application/octet-stream"
605+
content_type: str = raw_ct.split(";")[0].strip().lower()
510606

511607
# --- Validate content-type (bypass for kind=other) ---
512608
if artifact_kind != ArtifactKind.other and content_type not in _ALLOWED_CONTENT_TYPES:
609+
_safe_unlink(tmp_path)
513610
raise HTTPException(
514611
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
515612
detail={
@@ -522,35 +619,6 @@ async def upload_artifact(
522619
},
523620
)
524621

525-
# --- Stream to a temp file, enforcing max size ---
526-
max_bytes: int = settings.BGSTM_ARTIFACT_MAX_BYTES
527-
fd, tmp_path = tempfile.mkstemp(prefix="bgstm_artifact_")
528-
total_bytes = 0
529-
try:
530-
with os.fdopen(fd, "wb") as fp:
531-
while True:
532-
chunk = await file.read(_ARTIFACT_CHUNK_SIZE)
533-
if not chunk:
534-
break
535-
fp.write(chunk)
536-
total_bytes += len(chunk)
537-
if total_bytes > max_bytes:
538-
# Partial data written — clean up and reject
539-
raise HTTPException(
540-
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
541-
detail={
542-
"code": "artifact.too_large",
543-
"message": (f"Artifact exceeds the maximum allowed size of {max_bytes} bytes."),
544-
"details": None,
545-
},
546-
)
547-
except HTTPException:
548-
_safe_unlink(tmp_path)
549-
raise
550-
except Exception:
551-
_safe_unlink(tmp_path)
552-
raise
553-
554622
# --- Persist via storage backend ---
555623
storage = get_storage()
556624
storage_key = f"{case_result_id}/{_uuid_module.uuid4().hex}/{safe_filename}"

backend/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ PyJWT==2.11.0
1717
bcrypt>=4.0.0
1818
email-validator==2.3.0
1919
reportlab==4.4.10
20+
# Streaming multipart parser — used by upload_artifact for true mid-wire size-limit abort.
21+
# Pinned to <2.0 to avoid breaking API changes; Dependabot will bump the minor.
22+
streaming-form-data>=1.16,<2.0
2023

2124
# Optional dependencies for LLM embeddings
2225
# Uncomment the lines below if using LLM-based similarity

backend/tests/test_external_results_artifact.py

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -214,17 +214,14 @@ async def test_upload_screenshot_returns_201(self, db_session, write_token, tmp_
214214
class TestSizeEnforcement:
215215
@pytest.mark.asyncio
216216
async def test_oversized_file_returns_413_and_cleans_up(self, db_session, write_token, tmp_path, monkeypatch):
217-
"""413 on oversized upload; Starlette-managed temp file is cleaned up; no DB record created.
217+
"""413 on oversized upload; streaming abort is now the actual behavior.
218218
219-
Note: FastAPI/Starlette fully parses the multipart body via python-multipart
220-
**before** the handler is invoked — by the time the chunk loop runs, ``file``
221-
is already a SpooledTemporaryFile containing the entire upload. The 413
222-
enforcement happens post-buffer (reading from the spooled file in chunks),
223-
not mid-wire. True in-stream early-abort is a follow-up improvement; deploy
224-
behind a reverse-proxy ``client_max_body_size`` for first-line DoS protection.
219+
The handler uses streaming-form-data to parse the multipart body and raises
220+
_SizeLimitExceeded mid-stream as soon as cumulative bytes exceed
221+
BGSTM_ARTIFACT_MAX_BYTES — bytes past the limit are never read from the
222+
connection.
225223
226-
This test verifies:
227-
- 413 is returned when the spooled content exceeds BGSTM_ARTIFACT_MAX_BYTES.
224+
This test exercises the cleanup / DB-row / artifact-dir guarantees post-abort:
228225
- The handler's own temp file (bgstm_artifact_*) is cleaned up on 413.
229226
- No artifact row is written to the DB.
230227
- No file is left in the artifacts directory.
@@ -281,12 +278,130 @@ def _fake_mkstemp(*args, **kwargs):
281278
artifact_files = list(tmp_path.rglob("*"))
282279
assert artifact_files == [], f"Unexpected files in artifacts dir: {artifact_files}"
283280

281+
@pytest.mark.asyncio
282+
async def test_oversized_upload_aborts_stream_without_reading_full_body(
283+
self, db_session, write_token, tmp_path, monkeypatch
284+
):
285+
"""Verify that bytes past BGSTM_ARTIFACT_MAX_BYTES are NEVER read from the
286+
request stream. This is the load-bearing test for #320 — it fails against
287+
the old buffer-then-reject implementation and passes only when streaming
288+
abort is correctly wired.
289+
"""
290+
_token_model, plaintext = write_token
291+
monkeypatch.setattr(settings, "BGSTM_ARTIFACT_MAX_BYTES", 1024)
292+
monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path))
293+
monkeypatch.setattr(settings, "BGSTM_ARTIFACT_URL_PREFIX", "http://testserver/artifacts")
294+
monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local")
295+
296+
with TestClient(app) as sync_client:
297+
session_id = _create_session(sync_client, plaintext)
298+
case_result_id = _create_case_result(sync_client, plaintext, session_id)
299+
300+
# Build a multipart body: small text fields + 100 KiB file (100x the 1024 limit).
301+
LIMIT = 1024
302+
FILE_SIZE = 100 * LIMIT # 100 KiB — well past the limit
303+
boundary = b"bgstmtestboundary"
304+
file_data = b"X" * FILE_SIZE
305+
306+
def _field_part(name: str, value: str) -> bytes:
307+
return (
308+
b"--"
309+
+ boundary
310+
+ b"\r\n"
311+
+ b'Content-Disposition: form-data; name="'
312+
+ name.encode()
313+
+ b'"\r\n'
314+
+ b"\r\n"
315+
+ value.encode()
316+
+ b"\r\n"
317+
)
318+
319+
full_body = (
320+
_field_part("case_result_id", case_result_id)
321+
+ _field_part("kind", "screenshot")
322+
+ _field_part("filename", "big.png")
323+
+ b"--"
324+
+ boundary
325+
+ b"\r\n"
326+
+ b'Content-Disposition: form-data; name="file"; filename="big.png"\r\n'
327+
+ b"Content-Type: image/png\r\n"
328+
+ b"\r\n"
329+
+ file_data
330+
+ b"\r\n"
331+
+ b"--"
332+
+ boundary
333+
+ b"--\r\n"
334+
)
335+
body_size = len(full_body)
336+
337+
# Track how many bytes our generator has yielded — each yield corresponds
338+
# to one receive() call from the ASGI server (via ASGITransport).
339+
bytes_yielded = 0
340+
CHUNK_SIZE = 4096
341+
342+
async def streaming_body():
343+
nonlocal bytes_yielded
344+
for i in range(0, len(full_body), CHUNK_SIZE):
345+
chunk = full_body[i : i + CHUNK_SIZE]
346+
bytes_yielded += len(chunk)
347+
yield chunk
348+
349+
from httpx import ASGITransport, AsyncClient
350+
351+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
352+
resp = await client.post(
353+
"/api/v1/external-results/artifact",
354+
content=streaming_body(),
355+
headers={
356+
"Authorization": f"Bearer {plaintext}",
357+
"Content-Type": f"multipart/form-data; boundary={boundary.decode()}",
358+
},
359+
)
360+
361+
assert resp.status_code == 413, resp.text
362+
assert resp.json()["detail"]["code"] == "artifact.too_large"
363+
364+
# The server must have stopped reading well before the full body was sent.
365+
# Allow generous slack (limit + 256 KiB for framing + chunks), but assert
366+
# well below total body size (100 KiB file ≫ slack).
367+
assert bytes_yielded < body_size // 2, (
368+
f"Server read {bytes_yielded} of {body_size} body bytes — "
369+
"streaming abort is not actually aborting; bytes past the limit are still being read."
370+
)
371+
284372

285373
# ---------------------------------------------------------------------------
286-
# Content-type enforcement (415)
374+
# Malformed multipart body (422 + code=validation_error)
287375
# ---------------------------------------------------------------------------
288376

289377

378+
class TestMalformedMultipart:
379+
@pytest.mark.asyncio
380+
async def test_malformed_multipart_returns_422(self, db_session, write_token, tmp_path, monkeypatch):
381+
"""Posting a body that is not valid multipart must return 422 with
382+
code=validation_error, never 500.
383+
"""
384+
_token_model, plaintext = write_token
385+
monkeypatch.setattr(settings, "BGSTM_ARTIFACTS_DIR", str(tmp_path))
386+
monkeypatch.setattr(settings, "BGSTM_STORAGE_BACKEND", "local")
387+
388+
from httpx import ASGITransport, AsyncClient
389+
390+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
391+
resp = await client.post(
392+
"/api/v1/external-results/artifact",
393+
content=b"this is not multipart data at all \x00\x01\x02",
394+
headers={
395+
"Authorization": f"Bearer {plaintext}",
396+
# Valid multipart content-type but body is garbage
397+
"Content-Type": "multipart/form-data; boundary=correctboundary",
398+
},
399+
)
400+
401+
assert resp.status_code == 422, resp.text
402+
assert resp.json()["detail"]["code"] == "validation_error"
403+
404+
290405
class TestContentTypeEnforcement:
291406
@pytest.mark.asyncio
292407
async def test_disallowed_content_type_returns_415(self, db_session, write_token, tmp_path, monkeypatch):

0 commit comments

Comments
 (0)