From 1ab5021667dd11c4ea1cc22a06505c197420775a Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 1 Jun 2026 15:23:28 +0300 Subject: [PATCH] feat(config): support large uploads with read header timeout Disable default read and write timeouts (set to 0s) to prevent Go from prematurely closing connections during large multi-GB uploads. Introduce `WARPBOX_READ_HEADER_TIMEOUT` (defaulting to 15s) to protect against slowloris-style attacks while still allowing long-running uploads to complete. Update documentation and example configurations accordingly. --- .env.example | 5 +- README.md | 8 +++ SECURITY_PROXY.md | 18 +++++++ backend/libs/config/config.go | 80 +++++++++++++++--------------- backend/libs/config/config_test.go | 22 +++++++- backend/libs/httpserver/server.go | 11 ++-- scripts/env/dev.env.example | 5 +- 7 files changed, 100 insertions(+), 49 deletions(-) diff --git a/.env.example b/.env.example index e78e8e5..b33b71a 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60 WARPBOX_SHORT_WINDOW_SECONDS=60 WARPBOX_ANONYMOUS_STORAGE_BACKEND=local WARPBOX_USER_STORAGE_BACKEND=local -WARPBOX_READ_TIMEOUT=15s -WARPBOX_WRITE_TIMEOUT=60s +WARPBOX_READ_HEADER_TIMEOUT=15s +WARPBOX_READ_TIMEOUT=0s +WARPBOX_WRITE_TIMEOUT=0s WARPBOX_IDLE_TIMEOUT=120s WARPBOX_TRUSTED_PROXIES= diff --git a/README.md b/README.md index a924376..b339240 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ Upload policy defaults are also configured in megabytes and can later be changed Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. The dev script resolves that path from the repository root. +Large uploads are expected to take minutes on normal home/server connections. Keep +`WARPBOX_READ_TIMEOUT=0s` and `WARPBOX_WRITE_TIMEOUT=0s` so Go does not close the connection +mid-upload; `WARPBOX_READ_HEADER_TIMEOUT=15s` still protects header reads from slowloris-style +connections. + Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with `WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with `WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`. @@ -106,6 +111,9 @@ WARPBOX_DATA_DIR=/var/lib/warpbox WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1 +WARPBOX_READ_HEADER_TIMEOUT=15s +WARPBOX_READ_TIMEOUT=0s +WARPBOX_WRITE_TIMEOUT=0s ``` Example `/etc/systemd/system/warpbox.service`: diff --git a/SECURITY_PROXY.md b/SECURITY_PROXY.md index 364553d..b6dd864 100644 --- a/SECURITY_PROXY.md +++ b/SECURITY_PROXY.md @@ -54,6 +54,24 @@ network edge, or set it to a value that does not include public clients. Direct public exposure is not recommended; use a reverse proxy for TLS and request normalization. +## Large Uploads + +Multi-GB uploads must not use whole-body read/write deadlines. Keep these +Warpbox values for production unless you intentionally want a hard wall-clock +upload limit: + +```env +WARPBOX_READ_HEADER_TIMEOUT=15s +WARPBOX_READ_TIMEOUT=0s +WARPBOX_WRITE_TIMEOUT=0s +``` + +`WARPBOX_READ_HEADER_TIMEOUT` protects request headers. `WARPBOX_READ_TIMEOUT` +and `WARPBOX_WRITE_TIMEOUT` cover the whole upload/response lifetime in Go, so +small values can cause browser errors such as `NS_ERROR_NET_INTERRUPT` during +large transfers. Upload size, daily, storage, and box limits still enforce abuse +controls independently of these timeout values. + ## Ban Behavior Active bans return: diff --git a/backend/libs/config/config.go b/backend/libs/config/config.go index ed87bba..7dbf7bf 100644 --- a/backend/libs/config/config.go +++ b/backend/libs/config/config.go @@ -11,26 +11,27 @@ import ( ) type Config struct { - AppName string - AppVersion string - Environment string - Addr string - BaseURL string - DataDir string - AdminToken string - StaticDir string - TemplateDir string - ReadTimeout time.Duration - WriteTimeout time.Duration - IdleTimeout time.Duration - TrustedProxies []string - JobsEnabled bool - CleanupEnabled bool - CleanupEvery time.Duration - ThumbnailEnabled bool - ThumbnailEvery time.Duration - MaxUploadSize int64 - DefaultSettings SettingsDefaults + AppName string + AppVersion string + Environment string + Addr string + BaseURL string + DataDir string + AdminToken string + StaticDir string + TemplateDir string + ReadHeaderTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + TrustedProxies []string + JobsEnabled bool + CleanupEnabled bool + CleanupEvery time.Duration + ThumbnailEnabled bool + ThumbnailEvery time.Duration + MaxUploadSize int64 + DefaultSettings SettingsDefaults } type SettingsDefaults struct { @@ -55,25 +56,26 @@ type SettingsDefaults struct { func Load() (Config, error) { cfg := Config{ - AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"), - AppVersion: envString("APP_VERSION", "dev"), - Environment: envString("WARPBOX_ENV", "development"), - Addr: envString("WARPBOX_ADDR", ":8080"), - BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"), - DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")), - AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""), - StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")), - TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")), - ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second), - WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second), - IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second), - TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"), - JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true), - CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true), - CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour), - ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true), - ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute), - MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default. + AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"), + AppVersion: envString("APP_VERSION", "dev"), + Environment: envString("WARPBOX_ENV", "development"), + Addr: envString("WARPBOX_ADDR", ":8080"), + BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"), + DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")), + AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""), + StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")), + TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")), + ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second), + ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0), + WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0), + IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second), + TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"), + JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true), + CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true), + CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour), + ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true), + ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute), + MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default. DefaultSettings: SettingsDefaults{ AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true), AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512), diff --git a/backend/libs/config/config_test.go b/backend/libs/config/config_test.go index 0dcd366..1eff696 100644 --- a/backend/libs/config/config_test.go +++ b/backend/libs/config/config_test.go @@ -1,6 +1,9 @@ package config -import "testing" +import ( + "testing" + "time" +) func TestParseMegabytes(t *testing.T) { tests := map[string]int64{ @@ -49,3 +52,20 @@ func TestEnvBool(t *testing.T) { t.Fatalf("envBool() did not fall back to true") } } + +func TestLoadDefaultsUseLargeUploadFriendlyTimeouts(t *testing.T) { + t.Setenv("WARPBOX_BASE_URL", "http://example.test") + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.ReadHeaderTimeout != 15*time.Second { + t.Fatalf("ReadHeaderTimeout = %s, want 15s", cfg.ReadHeaderTimeout) + } + if cfg.ReadTimeout != 0 { + t.Fatalf("ReadTimeout = %s, want 0 for long uploads", cfg.ReadTimeout) + } + if cfg.WriteTimeout != 0 { + t.Fatalf("WriteTimeout = %s, want 0 for long uploads", cfg.WriteTimeout) + } +} diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index 32da313..717e161 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -54,11 +54,12 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { ) server := &http.Server{ - Addr: cfg.Addr, - Handler: handler, - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - IdleTimeout: cfg.IdleTimeout, + Addr: cfg.Addr, + Handler: handler, + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, } server.RegisterOnShutdown(func() { stopJobs() diff --git a/scripts/env/dev.env.example b/scripts/env/dev.env.example index e78e8e5..b33b71a 100644 --- a/scripts/env/dev.env.example +++ b/scripts/env/dev.env.example @@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60 WARPBOX_SHORT_WINDOW_SECONDS=60 WARPBOX_ANONYMOUS_STORAGE_BACKEND=local WARPBOX_USER_STORAGE_BACKEND=local -WARPBOX_READ_TIMEOUT=15s -WARPBOX_WRITE_TIMEOUT=60s +WARPBOX_READ_HEADER_TIMEOUT=15s +WARPBOX_READ_TIMEOUT=0s +WARPBOX_WRITE_TIMEOUT=0s WARPBOX_IDLE_TIMEOUT=120s WARPBOX_TRUSTED_PROXIES=