3
0
Fork
You've already forked safeuploads
0

[BUG]: _stream_to_temp_file does not seek original UploadFile back to position 0 #3

Closed
opened 2026年06月03日 11:00:47 +02:00 by hugobatista · 1 comment

[BUG]: _stream_to_temp_file does not seek original UploadFile back to position 0, causing subsequent file.read() to return empty bytes

Checklist

  • I have searched existing issues to make sure this bug hasn't already been reported.

Description

FileValidator._stream_to_temp_file streams the content of the incoming UploadFile into a SpooledTemporaryFile for validation, but only seeks the temp copy back to position 0 before returning. The original UploadFile is left at EOF.

This means any caller that needs to read the UploadFile after validation receives b"" (empty bytes) from file.read().

Root Cause

In safeuploads/file_validator.py, _stream_to_temp_file (lines 460–521):

async def _stream_to_temp_file(self, file: UploadFile, max_file_size: int) -> tuple[tempfile.SpooledTemporaryFile, int]:
 ...
 await file.seek(0) # [1] seek original to start
 try:
 while True:
 chunk = await file.read(chunk_size) # [2] read ALL data from original
 ...
 temp.write(chunk) # [3] write to temp copy
 temp.seek(0) # [4] seek ONLY temp copy back to 0
 return temp, total_bytes # [5] original is left at EOF
 ...
  1. Line 488: The original file is seeked to position 0 before reading.
  2. Lines 491–505: All data is read from the original file into temp.
  3. Line 515: Only temp is seeked back to position 0.
  4. Line 516: Returns (temp, total_bytes). The original file is still at EOF.

The validate_zip_file, validate_activity_file, and validate_gzip_file methods all call _stream_to_temp_file and then operate on temp_file, which is correct for validation. However, they never restore the original file's position.

Impact

Any application that calls a FileValidator.validate_* method and then reads the original UploadFile after validation will silently get empty data.

Real-world reproduction (Endurain)

In backend/app/profile/router.py:

await core_file_uploads.validate_upload(file, kind=core_file_uploads.UploadKind.ZIP)
zip_data = await file.read() # returns b"" because file pointer is at EOF
result = await import_service.import_from_zip_data(zip_data)

zip_data is b"", causing zipfile.ZipFile(BytesIO(b"")) to raise:

BadZipFile: File is not a zip file

This also affects activity file and gzip validation when the caller needs the original bytes.

Steps to Reproduce

  1. Create a valid ZIP file as bytes.
  2. Pass it as an UploadFile to FileValidator.validate_zip_file(file).
  3. Call await file.read() immediately after validation returns.
  4. Observe b"" is returned instead of the actual content.
file = UploadFile(file=io.BytesIO(valid_zip_bytes), filename="test.zip")
await validator.validate_zip_file(file)
remaining = await file.read()
assert remaining != b"" # FAILS: remaining is b""

Expected Behavior

After any validate_* method completes successfully, the original UploadFile should have its read position restored to the beginning (position 0) so callers can consume the file content.

Alternatively, the method contract should document that the original file is consumed and left at EOF, and callers must pass the returned SpooledTemporaryFile for further processing.

Proposed Fix

In _stream_to_temp_file, add await file.seek(0) after the while-loop (before the return):

temp.seek(0)
await file.seek(0) # restore original file position
return temp, total_bytes

This is the minimal, correct fix because:

  • It restores the UploadFile to the state it was in before validation
  • Existing callers that only use temp_file are unaffected
  • Callers that need the original file after validation can read it normally

Affected Methods

All three validators that delegate to _stream_to_temp_file:

  • validate_zip_file (line 681)
  • validate_activity_file (line 828)
  • validate_gzip_file (line 947)

Additional Context

Compare with _validate_file_size (used for image validation), which does reseek the original file — confirming the missing seek is an oversight rather than an intentional design choice.

[BUG]: _stream_to_temp_file does not seek original UploadFile back to position 0, causing subsequent file.read() to return empty bytes ## Checklist - [x] I have searched **existing issues** to make sure this bug hasn't already been reported. ## Description `FileValidator._stream_to_temp_file` streams the content of the incoming `UploadFile` into a `SpooledTemporaryFile` for validation, but only seeks the **temp copy** back to position 0 before returning. The **original** `UploadFile` is left at EOF. This means any caller that needs to read the `UploadFile` **after** validation receives `b""` (empty bytes) from `file.read()`. ## Root Cause In `safeuploads/file_validator.py`, `_stream_to_temp_file` (lines 460–521): ```python async def _stream_to_temp_file(self, file: UploadFile, max_file_size: int) -> tuple[tempfile.SpooledTemporaryFile, int]: ... await file.seek(0) # [1] seek original to start try: while True: chunk = await file.read(chunk_size) # [2] read ALL data from original ... temp.write(chunk) # [3] write to temp copy temp.seek(0) # [4] seek ONLY temp copy back to 0 return temp, total_bytes # [5] original is left at EOF ... ``` 1. **Line 488:** The original file is seeked to position 0 before reading. 2. **Lines 491–505:** All data is read from the original file into `temp`. 3. **Line 515:** Only `temp` is seeked back to position 0. 4. **Line 516:** Returns `(temp, total_bytes)`. The original `file` is **still at EOF**. The `validate_zip_file`, `validate_activity_file`, and `validate_gzip_file` methods all call `_stream_to_temp_file` and then operate on `temp_file`, which is correct for validation. However, they never restore the original `file`'s position. ## Impact Any application that calls a `FileValidator.validate_*` method and then reads the original `UploadFile` after validation will silently get empty data. ### Real-world reproduction (Endurain) In `backend/app/profile/router.py`: ```python await core_file_uploads.validate_upload(file, kind=core_file_uploads.UploadKind.ZIP) zip_data = await file.read() # returns b"" because file pointer is at EOF result = await import_service.import_from_zip_data(zip_data) ``` `zip_data` is `b""`, causing `zipfile.ZipFile(BytesIO(b""))` to raise: ``` BadZipFile: File is not a zip file ``` This also affects **activity file** and **gzip** validation when the caller needs the original bytes. ## Steps to Reproduce 1. Create a valid ZIP file as bytes. 2. Pass it as an `UploadFile` to `FileValidator.validate_zip_file(file)`. 3. Call `await file.read()` immediately after validation returns. 4. Observe `b""` is returned instead of the actual content. ```python file = UploadFile(file=io.BytesIO(valid_zip_bytes), filename="test.zip") await validator.validate_zip_file(file) remaining = await file.read() assert remaining != b"" # FAILS: remaining is b"" ``` ## Expected Behavior After any `validate_*` method completes successfully, the original `UploadFile` should have its read position restored to the beginning (position 0) so callers can consume the file content. Alternatively, the method contract should document that the original file is consumed and left at EOF, and callers must pass the returned `SpooledTemporaryFile` for further processing. ## Proposed Fix In `_stream_to_temp_file`, add `await file.seek(0)` after the while-loop (before the return): ```python temp.seek(0) await file.seek(0) # restore original file position return temp, total_bytes ``` This is the minimal, correct fix because: - It restores the `UploadFile` to the state it was in before validation - Existing callers that only use `temp_file` are unaffected - Callers that need the original file after validation can read it normally ## Affected Methods All three validators that delegate to `_stream_to_temp_file`: - `validate_zip_file` (line 681) - `validate_activity_file` (line 828) - `validate_gzip_file` (line 947) ## Additional Context Compare with `_validate_file_size` (used for image validation), which **does** reseek the original file — confirming the missing seek is an oversight rather than an intentional design choice.

Fixed on v1.0.1

Fixed on v1.0.1
Sign in to join this conversation.
No Branch/Tag specified
main
gh-pages
v1.0.1
v1.0.0
v0.1.2
v0.1.1
v0.1.0
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
endurain-project/safeuploads#3
Reference in a new issue
endurain-project/safeuploads
No description provided.
Delete branch "%!s()"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?