[BUG]: _stream_to_temp_file does not seek original UploadFile back to position 0, causing subsequent file.read() to return empty bytes
Checklist
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
...
- Line 488: The original file is seeked to position 0 before reading.
- Lines 491–505: All data is read from the original file into
temp.
- Line 515: Only
temp is seeked back to position 0.
- 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
- Create a valid ZIP file as bytes.
- Pass it as an
UploadFile to FileValidator.validate_zip_file(file).
- Call
await file.read() immediately after validation returns.
- 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.