File Upload and Download Integrity#
Two distinct integrity risks exist in Dify's file pipeline:
-
Upload β silent stream truncation: Most upload controllers call
file.stream.read()without first callingfile.stream.seek(0). If any middleware or validation code has advanced the stream position, the read silently returns a partial payload; no error is raised and the truncated bytes are committed to storage with an equally-truncated size recorded in the DB. -
Download β Content-Length / actual-size mismatch: Download responses set the
Content-Lengthheader from theUploadFile.sizedatabase column with no verification against the bytes actually stored in the backend. A bug inupload_textpreviously stored character count (len(text)) rather than byte count, poisoning the DB value for non-ASCII uploads; this was fixed in PR #40034, though historical records may still contain incorrect metadata.
Upload: Silent Stream Truncation#
Pattern#
The plugin upload endpoint reads the stream directly without first seeking to position 0 :
file_binary=file.stream.read(),
The same unseek'd file.stream.read() pattern appears in at least six other controllers:
api/controllers/service_api/app/file.pyapi/controllers/console/files.pyapi/controllers/web/files.pyapi/controllers/openapi/files.pyapi/controllers/service_api/dataset/document.py
Because read() returns bytes from the current stream position, any upstream code that advances the cursor produces a silently-truncated payload. The downstream storage call succeeds, and len(file_binary) records the truncated size as ground truth in UploadFile.size β with no exception, no log warning.
Downstream path#
FileService.upload_file computes size as file_size = len(content) where content is the bytes already read from the stream. ToolFileManager.create_file_by_raw does the same (size=len(file_binary)). Both methods faithfully record whatever bytes they received β the corruption is silent and upstream.
Safer counter-pattern#
AnnotationBatchImportApi is the only controller with explicit stream position management :
file.stream.seek(0, 2) # seek to end to determine size
file_size = file.stream.tell()
file.stream.seek(0) # reset to beginning before reading
All other upload controllers should follow this pattern.
PR context#
PR #35985 standardized all controllers from file.read() to file.stream.read() across the console, service-api, web, and openapi namespaces, but did not add seek(0) guards. The vulnerability pattern is now uniformly present across the codebase.
Download: Content-Length Mismatch#
Header sourced from DB metadata#
FilePreviewApi.get sets the response header unconditionally from the database column:
if upload_file.size > 0:
response.headers["Content-Length"] = str(upload_file.size)
upload_file.size is the value persisted at upload time β never re-validated against the storage backend. If the stored file has changed size (storage corruption, manual intervention, or silent truncation from an upload bug), the header is wrong. Clients relying on Content-Length for range requests, progress bars, or integrity checks receive incorrect data.
upload_text recorded characters, not bytes (fixed in PR #40034)#
FileService.upload_text previously saved text.encode("utf-8") to storage but recorded size=len(text) β the Unicode character count, not the UTF-8 byte length. For any text containing multi-byte characters (CJK, emoji, accented Latin), the DB size was smaller than the actual stored file. Content-Length would be under-reported on downloads, causing clients to treat the transfer as complete before all bytes were received.
PR #40034 fixed this by encoding the text once, saving those bytes, and using len(content) for the size β consistent with FileService.upload_file, which already used file_size = len(content) where content is bytes.
Note: Historical UploadFile records created before this fix may still have incorrect size metadata for non-ASCII text. The upload process itself now correctly stores UTF-8 byte lengths.
Affected Endpoints Summary#
| Endpoint | File | Risk |
|---|---|---|
POST /files/upload/for-plugin | api/controllers/files/plugin_file_upload.py | Upload truncation |
POST /files/upload (console / service-api / web / openapi) | multiple controllers | Upload truncation |
GET /<file_id>/file-preview | upload_file_delivery.py:95-144 | Content-Length from unverified DB value |
Text files uploaded via upload_text | file_service.py:167-198 | FIXED in PR #40034: DB size = character count, not bytes |
Key Source Locations#
| File | Purpose |
|---|---|
api/controllers/files/plugin_file_upload.py | Plugin upload endpoint β unseek'd stream read |
api/services/file_service.py | Core upload service; upload_text encodes once & uses len(content) for byte size (fixed in PR #40034) |
api/controllers/files/upload_file_delivery.py | Download/preview controller; Content-Length from DB |
api/controllers/console/app/annotation.py | Reference: correct seek-before-read pattern |
api/core/tools/tool_file_manager.py | create_file_by_raw β size from truncated bytes |
| PR #35985 | Standardized file.stream.read() without adding seek guards |