File Download Security#
Dify protects file downloads through two complementary layers: HMAC-signed URLs that expire, and response headers that prevent browsers from rendering dangerous file types inline.
HMAC-Signed URL Scheme#
Every file download URL carries three query parameters β timestamp, nonce, and sign β that must pass server-side verification before any bytes are served.
Signing is implemented in api/core/tools/signature.py. The signing functions all follow the same pattern :
- Record the current Unix time as
timestamp. - Generate a 16-byte random
nonce(hex-encoded). - Construct a pipe-delimited payload, e.g.
"file-preview|{file_id}|{timestamp}|{nonce}". - Compute
HMAC-SHA256(SECRET_KEY, payload)and base64url-encode it assign. - Append
?timestamp=β¦&nonce=β¦&sign=β¦to the URI.
The architecture separates URI generation (with HMAC signature) from URL binding (base URL selection). This allows the same signed URI to be bound to different base URLs for different network audiences (browser, internal services, remote Sandbox) while maintaining the same security mechanisms.
| Function | Payload prefix | Endpoint |
|---|---|---|
sign_tool_file_uri | file-preview | Returns origin-free /files/tools/<id>.<ext> URI |
sign_tool_file | file-preview | Calls sign_tool_file_uri, then binds to FILES_URL or INTERNAL_FILES_URL via bind_file_uri |
sign_upload_file_preview_url | image-preview | GET /files/<id>/image-preview |
get_signed_file_uri_for_plugin | upload | Returns origin-free /files/upload/for-plugin URI |
bind_file_uri | β | Binds an origin-free /files/... URI to a base URL (FILES_URL, INTERNAL_FILES_URL, or custom) |
sign_tool_file accepts a for_external flag that is passed to bind_file_uri to select between FILES_URL (public-facing) and INTERNAL_FILES_URL (Docker-internal, for plugin daemon use) . The overall behavior for callers remains the same, but the implementation is refactored to use the new separation of URI generation and URL binding.
Verification happens in verify_tool_file_signature: the server recomputes the HMAC over the same payload and checks two conditions :
- The recomputed signature must match the
signparameter exactly. current_time β timestamp β€ FILES_ACCESS_TIMEOUTβ default 300 seconds (5 minutes).
The HTTP endpoint GET /files/tools/<file_id>.<extension> calls verify_tool_file_signature on every request and raises 403 Forbidden if either check fails .
Response Headers: Preventing Inline Browser Execution#
After signature verification, the endpoint applies two defenses against browser-based attacks:
1. Attachment disposition on request β If the client sends ?as_attachment=true, the endpoint sets Content-Disposition: attachment; filename*=UTF-8''<encoded_filename>, forcing a download dialog instead of inline rendering .
2. HTML content enforcement β enforce_download_for_html() in api/controllers/common/file_response.py detects HTML by MIME type (text/html, application/xhtml+xml) or file extension (.html, .htm) and, when matched, forcibly sets :
Content-Disposition: attachment(with filename if available)Content-Type: application/octet-streamX-Content-Type-Options: nosniff
This prevents an attacker from uploading an HTML file and tricking the browser into executing it as a same-origin page. Both ToolFileApi and the file preview endpoint call this helper before returning any response.
Additionally, file preview endpoints (/files/<id>/image-preview, /files/<id>/file-preview) apply a broader set of security headers via the @add_security_headers decorator :
Content-Security-Policy: default-src 'none'; sandboxX-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: no-referrer
Configuration#
| Setting | Default | Purpose |
|---|---|---|
SECRET_KEY | "" (auto-generated) | HMAC key shared across the API process |
FILES_ACCESS_TIMEOUT | 300 s | Maximum age of a signed URL |
FILES_URL | β | External base URL for client-facing signed URLs |
INTERNAL_FILES_URL | β | Internal base URL for plugin daemon / Docker-internal access |
Two-Stage URL Signing Pattern#
Tool-file URLs are frequently stored as unsigned relative paths early in the pipeline (e.g., /files/tools/{id} in MessageFile.url), then signed at serialization time when a response is sent to a client. The main signing call-sites are:
| Caller | When it signs |
|---|---|
message_file_utils.py:76 | Before streaming MessageFile records to clients |
file_runtime.py:79-83 | For datasource-file transfer-method files in workflow runtimes |
file_runtime.py:113 | For tool-file transfer-method files via resolve_tool_file_url |
Code paths that bypass message_file_utils.py β such as ToolFileMessageTransformer.get_tool_file_url(), which returns a bare unsigned relative URL β will produce URLs the /files/tools/ endpoint rejects with 403 . This is a known inconsistency tracked in issue #39222.
Key Files#
| File | Role |
|---|---|
api/core/tools/signature.py | Sign / verify functions for all file URL types |
api/controllers/files/tool_files.py | HTTP endpoint enforcing signature + HTML download headers |
api/controllers/common/file_response.py | enforce_download_for_html() helper |
api/controllers/common/wraps.py | @add_security_headers decorator for preview endpoints |
api/core/app/task_pipeline/message_file_utils.py | Signs URLs at client-response serialization time |