No description
Find a file
maxbirnbacher 4110bb0cc9 Return JSON errors and fix spool directory permissions
- 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
2026-10-09 17:00:25 +02:00
.dockerignore Add configurable upload guard proxy 2026-08-27 17:40:10 +02:00
config.example.yaml Rework upload inspection and add ZIP entry scanning 2026-10-09 08:45:54 +02:00
config.go Rework upload inspection and add ZIP entry scanning 2026-10-09 08:45:54 +02:00
config_test.go Rework upload inspection and add ZIP entry scanning 2026-10-09 08:45:54 +02:00
Dockerfile Return JSON errors and fix spool directory permissions 2026-10-09 17:00:25 +02:00
go.mod Add configurable upload guard proxy 2026-08-27 17:40:10 +02:00
go.sum Add configurable upload guard proxy 2026-08-27 17:40:10 +02:00
main.go Return JSON errors and fix spool directory permissions 2026-10-09 17:00:25 +02:00
main_test.go Rework upload inspection and add ZIP entry scanning 2026-10-09 08:45:54 +02:00
README.md Return JSON errors and fix spool directory permissions 2026-10-09 17:00:25 +02:00

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 by max_upload_bytes.
  • multipart/* subtypes other than multipart/form-data are rejected.
  • Requests with a Content-Encoding other than identity are 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_bytes are rejected.
  • /healthz serves 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. Keep max_upload_bytes at or below what Open WebUI can survive.

Build and test locally with:

go test ./...
docker build -t upload-guard .