feat(#320): convert upload_artifact to true streaming multipart parsing with mid-stream 413 abort - #331
Merged
Conversation
…tream 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>
Copilot
AI
changed the title
[WIP] Convert upload handler to true streaming multipart parsing
feat(#320): convert upload_artifact to true streaming multipart parsing with mid-stream 413 abort
May 9, 2026
bg-playground
marked this pull request as ready for review
May 9, 2026 02:45
Owner
|
LGTM ✅ — all 11 checks green, including Acceptance check:
The load-bearing test is engineered correctly: a Two small notes (not blocking, fix-on-touch later if you like):
Closes #320. Merging. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The old
upload_artifacthandler used FastAPI'sFile(...)/Form(...)DI, which causespython-multipartto fully buffer the multipart body before the handler runs. The "size check" was reading from an already-spooled temp file — an attacker could exhaust disk/spool space before the 413 was ever returned.Core change — streaming handler
Replaced
File(...)/Form(...)withrequest: Request+streaming-form-data. A custom_SizeLimitedFileTargetraises a_SizeLimitExceededsentinel insideon_data_receivedthe moment cumulative bytes exceedBGSTM_ARTIFACT_MAX_BYTES. The stream loop exits immediately — no further reads from the connection.All downstream validation (kind enum, filename regex, UUID, FK lookup, content-type allowlist) runs post-parse, unchanged.
New tests
test_oversized_upload_aborts_stream_without_reading_full_body— the load-bearing test. Useshttpx.AsyncClient(ASGITransport)with a counting async generator as the request body. Sends a 100 KiB file against a 1 KiB limit and assertsbytes_yielded < body_size // 2. This test fails against the old buffer-then-reject implementation.test_malformed_multipart_returns_422— malformed body returns 422code=validation_error, not 500.test_oversized_file_returns_413_and_cleans_upto reflect that streaming abort is now the actual behavior.Deps & docs
backend/requirements.txt: addedstreaming-form-data>=1.16,<2.0(pinned minor, Dependabot will bump).docs/specs/external_results_v1.md: § Size enforcement rewritten;artifact.too_largeregistry entry updated; new "Recommended deploy hardening" sub-section added to § g (nginxclient_max_body_sizeas belt-and-suspenders).Original prompt
Goal
Convert the
/external-results/artifactupload handler from FastAPI's dependency-injectedFile(...)/Form(...)(which buffers the entire multipart body before the handler runs) to true streaming multipart parsing that aborts with 413 as soon as the in-flight byte total exceedsBGSTM_ARTIFACT_MAX_BYTES. Bytes past the limit must NOT be read from the request stream.Closes: #320
Why this is needed (reread before starting)
The current handler in
backend/app/api/external_results.py(functionupload_artifact) usesUploadFile = File(...)andForm(...)parameters. By the time the handler body executes, Starlette has already consumed the full multipart body viapython-multipartand spooled it to aSpooledTemporaryFile. The chunk-loop "size check" is reading from that already-fully-buffered file, not from the request stream. An attacker can tie up disk/spool space with arbitrarily large uploads even though the handler "rejects" them.The existing test
backend/tests/test_external_results_artifact.py::TestSizeEnforcement::test_oversized_file_returns_413_and_cleans_updocuments this limitation in its own docstring. That test must be kept (passes either way) and a new test must be added that fails the buffer-then-reject implementation but passes the streaming implementation.Decisions already made — do not re-open
streaming-form-data(https://pypi.org/project/streaming-form-data/) as the parser. It's the only well-maintained Python library with a stable API for this exact use case and is already pulled in by similar FastAPI projects. Add it tobackend/requirements.txt(andbackend/pyproject.tomlif used).streaming-form-datais small, audited, and battle-tested.ab5d7c1—case_result_id,kind,filename,file. Don't rename or add fields. The reporter side is frozen._SAFE_FILENAME_RE), content-type allowlist (_ALLOWED_CONTENT_TYPES) withkind=otherbypass,case_result_idUUID parsing, FK-existence check, audit-log write, storage-backend round-trip. The only change is HOW the body is parsed; not WHAT is validated.case_result_id,kind,size_bytes,filename,content_type) are required by the smoke job'sassert.py. Do not change them.upload_artifactchanges. Session / case-result endpoints stay on FastAPI's dependency-injected JSON body — they don't have this problem.code=artifact.too_large, 415 withcode=artifact.unsupported_type, 422 withcode=validation_error, 404 withcode=case_result.not_found.Scope
1. Convert
upload_artifactto streaming multipart parsingRewrite the handler signature to take a raw
request: Requestinstead ofUploadFile/Formparams:Use
streaming_form_data.StreamingFormDataParserwithValueTarget(for the three text fields) and a custom file-target class. The custom file-target class:tempfile.NamedTemporaryFile(prefixbgstm_artifact_).self.size_bytes.Content-Typeheader (passed to its__init__).class _SizeLimitExceeded(Exception): pass) insideon_data_receivedas soon assize_bytes > max_bytes.Drive the parser by reading from
request.stream()chunk-by-chunk. As soon as_SizeLimitExceededis raised:_safe_unlink.HTTPException(413, ...)withcode=artifact.too_largeand the existing message.Pseudocode skeleton: