- Answer all guard rejections with a FastAPI-shaped JSON body
({"detail": ...}) so Open WebUI renders the reason instead of a
JSON parse error
- Own the spool directory by the runtime UID in the image; the plain
COPY dropped the 1777 mode and uploads failed with permission denied
- Document the tmp volume layout again, tmpfs as an alternative
|
||
|---|---|---|
| .dockerignore | ||
| config.example.yaml | ||
| config.go | ||
| config_test.go | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
Upload Guard
A small reverse proxy that prevents selected file types from reaching Open WebUI. Multipart uploads are spooled to disk once and inspected in a single pass before they are forwarded, so an upload never needs to be held in memory. All non-multipart traffic passes through directly.
Detection uses both the filename checks and file contents. Renaming a file does not bypass the built-in signatures for Windows PE, ELF, Mach-O, Java class, MSI/OLE binaries, or the ZIP structure of an IntuneWin package. ZIP-based containers (ZIP, DOCX, JAR, APK, ...) are opened and their entries are checked too: blocked extensions on entry names, magic bytes of entry contents (including one level of nested archives), and encrypted or unreadable entries are rejected. Ordinary ZIP, DOCX, and other ZIP-based documents are not blocked merely for starting with ZIP magic bytes. Inspection reads the raw multipart bytes, so the guard sees exactly what Open WebUI will receive.
Docker Compose with Traefik
Point the existing Open WebUI Traefik router at upload-guard instead of directly
at Open WebUI. Both containers must share a Docker network.
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
networks:
- web
# Remove the Traefik router labels from this service.
upload-guard:
build: .
restart: unless-stopped
depends_on:
- open-webui
networks:
- web
volumes:
- upload-guard-tmp:/tmp
- ./upload-guard.yaml:/etc/upload-guard/config.yaml:ro
labels:
- traefik.enable=true
- traefik.http.routers.open-webui.rule=Host(`chat.example.com`)
- traefik.http.routers.open-webui.entrypoints=websecure
- traefik.http.routers.open-webui.tls=true
- traefik.http.services.open-webui.loadbalancer.server.port=8080
- traefik.http.services.open-webui.loadbalancer.healthcheck.path=/healthz
networks:
web:
external: true
volumes:
upload-guard-tmp:
The temporary volume should have enough free space for max_upload_bytes
multiplied by max_concurrent_uploads. Temporary files are deleted after each
request. A tmpfs mount (tmpfs: [/tmp:size=2g,mode=1777]) works as a
RAM-backed alternative.
Keep any certificate resolver and authentication middleware labels from your
current Open WebUI router on the upload-guard service. Do not expose the Open
WebUI container directly, or clients could bypass the guard.
The temporary volume should have enough free space for max_upload_bytes
multiplied by max_concurrent_uploads. Temporary files are deleted after each
request. A tmpfs mount (tmpfs: [/tmp:size=2g,mode=1777]) works as a
RAM-backed alternative.
Configuration
Copy config.example.yaml to upload-guard.yaml next to the Compose file and
edit it. Unknown YAML keys and invalid hexadecimal signatures cause startup to
fail rather than being ignored.
The default path is /etc/upload-guard/config.yaml. Set CONFIG_FILE only when
the configuration is mounted somewhere else:
environment:
CONFIG_FILE: /config/guard.yaml
volumes:
- ./upload-guard.yaml:/config/guard.yaml:ro
An embedded default configuration is included in the container, but mounting your own file is recommended.
Keys
| Key | Default | Purpose |
|---|---|---|
listen_address |
:8080 |
Guard listen address |
upstream_url |
http://open-webui:8080 |
Open WebUI internal URL |
temp_directory |
/tmp |
Where uploads are spooled |
max_upload_bytes |
104857600 (100 MiB) |
Total cap for one multipart request |
max_form_field_bytes |
1048576 (1 MiB) |
Cap per non-file form field (fields are loaded into memory by Open WebUI) |
max_filename_bytes |
1024 |
Cap per filename |
max_multipart_parts |
100 |
Cap of parts per request |
max_concurrent_uploads |
20 |
Concurrent uploads being inspected; excess gets 503 |
inspection_timeout_seconds |
600 |
Max time to receive and inspect an upload |
extension_mode |
blocklist |
blocklist rejects listed extensions; allowlist allows only listed ones |
extensions |
see example | Extension list, meaning depends on extension_mode |
detect_intunewin |
true |
Detect IntuneWin packages by ZIP structure |
magic_signatures |
see example | Executable signatures matched at byte zero; [] disables |
Set magic_signatures: [] to disable magic-byte detection or
extensions: [] to disable the extension check in blocklist mode.
Protection notes
- Requests with
Transfer-Encoding: chunked(no Content-Length) are still bounded bymax_upload_bytes. multipart/*subtypes other thanmultipart/form-dataare rejected.- Requests with a
Content-Encodingother thanidentityare rejected; the guard does not decompress uploads. - Truncated or malformed multipart bodies fail closed with 400.
- ZIP archives with more than 4096 entries, nesting deeper than two levels, or
nested archives larger than
max_upload_bytesare rejected. /healthzserves 200 for load balancer health checks.- Detection is file-type filtering, not antivirus scanning. Decompression bombs
inside otherwise allowed formats (PDF, PNG, DOCX) are not detected; ZIP entry
inspection reads only entry names, the first bytes of each entry's content,
and full nested archives up to
max_upload_bytes. Keepmax_upload_bytesat or below what Open WebUI can survive.
Build and test locally with:
go test ./...
docker build -t upload-guard .