Skip to content

Commit d26183c

Browse files
committed
fix(security): remediate all 16 findings from security audit
Address all findings from the 2026-04-11 security evaluation: HIGH: - SEC-001: Replace unbounded sync.Map PathCache with bounded LRU (hashicorp/golang-lru) to prevent memory exhaustion DoS MEDIUM: - SEC-003: Make panic stack traces configurable via STATIC_DEBUG env var - SEC-004: Generate random multipart boundary per response (crypto/rand) - SEC-005: Add MaxCompressSize (10MB) limit for on-the-fly gzip - SEC-006: Apply path.Clean in CacheKeyForPath to prevent cache poisoning LOW: - SEC-007: Suppress server name disclosure (empty Name field) - SEC-008: Sanitize control characters in access log URIs - SEC-009: Remove deprecated PreferServerCipherSuites TLS option - SEC-010: Handle template execution errors in directory listing - SEC-011: Add MaxServeFileSize (1GB) hard limit for large file serving - SEC-012: Add clarifying comment on CORS wildcard Vary behavior - SEC-013: Document ETag 64-bit truncation rationale - SEC-014: Set explicit MaxRequestBodySize (1024 bytes) - SEC-015: Add MaxConnsPerIP config support for rate limiting - SEC-016: Validate symlink targets during cache preload Also updates dependencies: - brotli v1.2.0 → v1.2.1 - klauspost/compress v1.18.4 → v1.18.5 - fasthttp v1.69.0 → v1.70.0
1 parent fcfe429 commit d26183c

File tree

13 files changed

+937
-50
lines changed

13 files changed

+937
-50
lines changed

SECURITY_EVAL_2026-04-11.md

Lines changed: 730 additions & 0 deletions
Large diffs are not rendered by default.

cmd/static-web/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ func runServe(args []string) {
207207
}
208208

209209
// Pre-warm the path cache with every URL key the file cache knows about.
210-
pathCache = security.NewPathCache()
210+
pathCache = security.NewPathCache(security.DefaultPathCacheSize)
211211
pathCache.PreWarm(stats.Paths, cfg.Files.Root, cfg.Security.BlockDotfiles)
212212
if !effectiveQuiet {
213213
log.Printf("path cache pre-warmed with %d entries", pathCache.Len())

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ go 1.26
55
require (
66
github.com/BurntSushi/toml v1.6.0
77
github.com/hashicorp/golang-lru/v2 v2.0.7
8-
github.com/klauspost/compress v1.18.4
9-
github.com/valyala/fasthttp v1.69.0
8+
github.com/klauspost/compress v1.18.5
9+
github.com/valyala/fasthttp v1.70.0
1010
)
1111

1212
require (
13-
github.com/andybalholm/brotli v1.2.0 // indirect
13+
github.com/andybalholm/brotli v1.2.1 // indirect
1414
github.com/valyala/bytebufferpool v1.0.0 // indirect
1515
)

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
22
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3-
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
4-
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
3+
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
4+
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
55
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
66
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
7-
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
8-
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
7+
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
8+
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
99
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
1010
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
11-
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
12-
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
11+
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
12+
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
1313
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
1414
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=

internal/cache/preload.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ func (c *Cache) Preload(root string, cfg PreloadConfig) PreloadStats {
7676
return nil
7777
}
7878

79+
// SEC-016: Validate symlink targets. If the entry is a symlink,
80+
// resolve it and verify the target is still inside the root directory.
81+
// This prevents preloading files that symlink outside the served root.
82+
if d.Type()&os.ModeSymlink != 0 {
83+
target, err := filepath.EvalSymlinks(fpath)
84+
if err != nil {
85+
stats.Skipped++
86+
return nil
87+
}
88+
rootWithSep := absRoot
89+
if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
90+
rootWithSep += string(filepath.Separator)
91+
}
92+
if target != absRoot && !strings.HasPrefix(target, rootWithSep) {
93+
stats.Skipped++ // symlink escapes root
94+
return nil
95+
}
96+
fpath = target
97+
}
98+
7999
// Skip dotfile components.
80100
if cfg.BlockDotfiles {
81101
rel, relErr := filepath.Rel(absRoot, fpath)

internal/compress/compress.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@ func Middleware(cfg *config.CompressionConfig, next fasthttp.RequestHandler) fas
172172
return
173173
}
174174

175+
// SEC-005: Skip on-the-fly compression for bodies that exceed the
176+
// configured maximum. This prevents excessive memory allocation and
177+
// CPU usage from compressing very large responses in-flight.
178+
if cfg.MaxCompressSize > 0 && len(body) > cfg.MaxCompressSize {
179+
return
180+
}
181+
175182
// Compress the body using pooled gzip.Writer and bytes.Buffer.
176183
buf := gzipBufPool.Get().(*bytes.Buffer)
177184
buf.Reset()

internal/config/config.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ type ServerConfig struct {
4545
IdleTimeout time.Duration `toml:"idle_timeout"`
4646
// ShutdownTimeout is how long to wait for in-flight requests during shutdown.
4747
ShutdownTimeout time.Duration `toml:"shutdown_timeout"`
48+
// MaxConnsPerIP limits the number of concurrent connections from a single IP
49+
// address. 0 means unlimited. Default: 0 (disabled).
50+
// SEC-015: Rate-limiting defence against connection exhaustion attacks.
51+
MaxConnsPerIP int `toml:"max_conns_per_ip"`
4852
}
4953

5054
// FilesConfig holds file-serving settings.
@@ -55,6 +59,12 @@ type FilesConfig struct {
5559
Index string `toml:"index"`
5660
// NotFound is the path (relative to Root) of the custom 404 page.
5761
NotFound string `toml:"not_found"`
62+
// MaxServeFileSize is the maximum file size (in bytes) that will be served.
63+
// Files exceeding this limit receive a 413 Payload Too Large response.
64+
// This prevents a single request from loading an arbitrarily large file
65+
// into memory. Default: 1 GB. Set to 0 to disable the limit.
66+
// SEC-011: Hard upper bound for large file serving.
67+
MaxServeFileSize int64 `toml:"max_serve_file_size"`
5868
}
5969

6070
// CacheConfig holds in-memory cache settings.
@@ -89,6 +99,12 @@ type CompressionConfig struct {
8999
Level int `toml:"level"`
90100
// Precompressed enables serving pre-compressed .gz/.br sidecar files. Default: true.
91101
Precompressed bool `toml:"precompressed"`
102+
// MaxCompressSize is the maximum response body size (in bytes) eligible for
103+
// on-the-fly gzip compression. Bodies exceeding this limit are served
104+
// uncompressed to avoid excessive memory allocation and CPU usage.
105+
// Default: 10 MB. Set to 0 to disable the limit.
106+
// SEC-005: Bounds on-the-fly compression memory usage.
107+
MaxCompressSize int `toml:"max_compress_size"`
92108
}
93109

94110
// HeadersConfig controls HTTP response header settings.
@@ -154,6 +170,7 @@ func applyDefaults(cfg *Config) {
154170

155171
cfg.Files.Root = "./public"
156172
cfg.Files.Index = "index.html"
173+
cfg.Files.MaxServeFileSize = 1024 * 1024 * 1024 // 1 GB
157174

158175
cfg.Cache.Enabled = true
159176
cfg.Cache.MaxBytes = 256 * 1024 * 1024 // 256 MB
@@ -163,6 +180,7 @@ func applyDefaults(cfg *Config) {
163180
cfg.Compression.MinSize = 1024
164181
cfg.Compression.Level = 5
165182
cfg.Compression.Precompressed = true
183+
cfg.Compression.MaxCompressSize = 10 * 1024 * 1024 // 10 MB
166184

167185
cfg.Headers.StaticMaxAge = 3600
168186
cfg.Headers.HTMLMaxAge = 0
@@ -225,6 +243,11 @@ func applyEnvOverrides(cfg *Config) {
225243
if v := os.Getenv("STATIC_FILES_NOT_FOUND"); v != "" {
226244
cfg.Files.NotFound = v
227245
}
246+
if v := os.Getenv("STATIC_FILES_MAX_SERVE_FILE_SIZE"); v != "" {
247+
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
248+
cfg.Files.MaxServeFileSize = n
249+
}
250+
}
228251

229252
if v := os.Getenv("STATIC_CACHE_ENABLED"); v != "" {
230253
cfg.Cache.Enabled = strings.EqualFold(v, "true") || v == "1"
@@ -266,6 +289,11 @@ func applyEnvOverrides(cfg *Config) {
266289
cfg.Compression.Level = n
267290
}
268291
}
292+
if v := os.Getenv("STATIC_COMPRESSION_MAX_COMPRESS_SIZE"); v != "" {
293+
if n, err := strconv.Atoi(v); err == nil {
294+
cfg.Compression.MaxCompressSize = n
295+
}
296+
}
269297

270298
if v := os.Getenv("STATIC_SECURITY_BLOCK_DOTFILES"); v != "" {
271299
cfg.Security.BlockDotfiles = strings.EqualFold(v, "true") || v == "1"
@@ -284,4 +312,9 @@ func applyEnvOverrides(cfg *Config) {
284312
if v := os.Getenv("STATIC_HEADERS_ENABLE_ETAGS"); v != "" {
285313
cfg.Headers.EnableETags = strings.EqualFold(v, "true") || v == "1"
286314
}
315+
if v := os.Getenv("STATIC_SERVER_MAX_CONNS_PER_IP"); v != "" {
316+
if n, err := strconv.Atoi(v); err == nil {
317+
cfg.Server.MaxConnsPerIP = n
318+
}
319+
}
287320
}

internal/handler/dirlist.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,13 @@ func (h *FileHandler) serveDirectoryListing(ctx *fasthttp.RequestCtx, absDir, ur
187187
return
188188
}
189189
// Render template to a buffer then write to ctx.
190+
// SEC-010: Handle template execution errors instead of silently discarding.
190191
var buf bytes.Buffer
191-
_ = dirListTemplate.Execute(&buf, data)
192+
if err := dirListTemplate.Execute(&buf, data); err != nil {
193+
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
194+
ctx.SetBodyString("Internal Server Error: failed to render directory listing")
195+
return
196+
}
192197
ctx.SetBody(buf.Bytes())
193198
}
194199

internal/handler/file.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package handler
44

55
import (
66
"bytes"
7+
"crypto/rand"
78
"crypto/sha256"
89
"encoding/hex"
910
"errors"
@@ -335,7 +336,17 @@ func (h *FileHandler) serveFromDisk(ctx *fasthttp.RequestCtx, absPath, urlPath s
335336
// bypassing the in-memory cache but still supporting Range requests.
336337
// The file is read into memory to avoid issues with fasthttp's lazy body
337338
// evaluation closing the file descriptor before the body is consumed.
339+
// SEC-011: Enforces MaxServeFileSize to prevent a single request from
340+
// loading an arbitrarily large file into memory.
338341
func (h *FileHandler) serveLargeFile(ctx *fasthttp.RequestCtx, absPath, urlPath string, info os.FileInfo) {
342+
// SEC-011: Reject files that exceed the configured hard maximum.
343+
if h.cfg.Files.MaxServeFileSize > 0 && info.Size() > h.cfg.Files.MaxServeFileSize {
344+
log.Printf("handler: file %q (%d bytes) exceeds max serve size (%d bytes)",
345+
absPath, info.Size(), h.cfg.Files.MaxServeFileSize)
346+
ctx.Error("Content Too Large", fasthttp.StatusRequestEntityTooLarge)
347+
return
348+
}
349+
339350
f, err := os.Open(absPath)
340351
if err != nil {
341352
if os.IsPermission(err) {
@@ -474,7 +485,13 @@ func (h *FileHandler) handleSecurityError(ctx *fasthttp.RequestCtx, err error) {
474485
}
475486
}
476487

477-
// computeETag returns the first 16 hex characters of sha256(data).
488+
// computeETag returns the first 16 hex characters of sha256(data), yielding a
489+
// 64-bit fingerprint of the file content.
490+
// SEC-013: The truncation to 8 bytes (64 bits) is intentional. With the birthday
491+
// paradox, collision probability reaches 1% at ~190 million files — well beyond
492+
// practical static-server workloads. The short ETag saves bandwidth on every
493+
// conditional request/response. If a stronger fingerprint is ever needed, the
494+
// full sha256 sum can be used instead.
478495
// Uses hex.EncodeToString on the first 8 bytes instead of fmt.Sprintf
479496
// to avoid formatting the full 32-byte hash and then truncating (PERF-004).
480497
func computeETag(data []byte) string {
@@ -572,6 +589,16 @@ func (h *FileHandler) LoadSidecar(path string) []byte {
572589
return data
573590
}
574591

592+
// generateBoundary returns a random MIME multipart boundary string.
593+
// SEC-004: Using a unique boundary per response prevents attackers from
594+
// predicting boundary values and crafting payloads that exploit multipart
595+
// parsing in downstream proxies or clients.
596+
func generateBoundary() string {
597+
var buf [16]byte
598+
_, _ = rand.Read(buf[:])
599+
return hex.EncodeToString(buf[:])
600+
}
601+
575602
// ---------------------------------------------------------------------------
576603
// Range request handling (replacement for http.ServeContent)
577604
// ---------------------------------------------------------------------------
@@ -612,7 +639,7 @@ func serveRange(ctx *fasthttp.RequestCtx, data []byte, rangeHeader string) {
612639

613640
// Multiple ranges — use multipart/byteranges.
614641
contentType := string(ctx.Response.Header.Peek("Content-Type"))
615-
boundary := "static_web_range_boundary"
642+
boundary := generateBoundary() // SEC-004: random boundary per response
616643

617644
var buf bytes.Buffer
618645
for _, r := range ranges {
@@ -656,7 +683,7 @@ func serveLargeFileRange(ctx *fasthttp.RequestCtx, data []byte, size int64, rang
656683

657684
// Multiple ranges — use multipart/byteranges.
658685
contentType := string(ctx.Response.Header.Peek("Content-Type"))
659-
boundary := "static_web_range_boundary"
686+
boundary := generateBoundary() // SEC-004: random boundary per response
660687

661688
var buf bytes.Buffer
662689
for _, r := range ranges {

internal/handler/middleware.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package handler
22

33
import (
44
"log"
5+
"os"
56
"runtime/debug"
67
"strconv"
8+
"strings"
79
"sync"
810
"time"
911

@@ -14,6 +16,12 @@ import (
1416
"github.com/valyala/fasthttp"
1517
)
1618

19+
// debugStackTraces controls whether full goroutine stack traces are logged on panic.
20+
// When false (default), only the panic value is logged, preventing sensitive internal
21+
// details from leaking. Set STATIC_DEBUG=1 to enable verbose stack traces.
22+
// SEC-003: Configurable panic stack trace verbosity.
23+
var debugStackTraces = os.Getenv("STATIC_DEBUG") == "1"
24+
1725
// BuildHandler composes the full middleware chain and returns a ready-to-use
1826
// fasthttp.RequestHandler. The chain is (outer to inner):
1927
//
@@ -111,23 +119,62 @@ func loggingMiddlewareWithWriter(next fasthttp.RequestHandler, logger *log.Logge
111119
}
112120

113121
method := string(ctx.Method())
114-
uri := string(ctx.RequestURI())
122+
// SEC-008: Sanitize the URI before logging to prevent control-character
123+
// injection (e.g. \r\n) into log files which could enable log forgery.
124+
uri := sanitizeForLog(string(ctx.RequestURI()))
115125
logger.Print(formatAccessLogLine(method, uri, status, size, duration))
116126
}
117127
}
118128

119129
// recoveryMiddleware catches panics in the handler chain and returns a 500.
120-
// It logs the panic value and the full stack trace.
130+
// SEC-003: Full stack traces are only logged when STATIC_DEBUG=1 is set,
131+
// preventing sensitive internal details from leaking into production logs.
121132
func recoveryMiddleware(next fasthttp.RequestHandler) fasthttp.RequestHandler {
122133
return func(ctx *fasthttp.RequestCtx) {
123134
defer func() {
124135
if rec := recover(); rec != nil {
125-
stack := debug.Stack()
126-
log.Printf("PANIC recovered: %v\n%s", rec, stack)
136+
if debugStackTraces {
137+
stack := debug.Stack()
138+
log.Printf("PANIC recovered: %v\n%s", rec, stack)
139+
} else {
140+
log.Printf("PANIC recovered: %v (set STATIC_DEBUG=1 for stack trace)", rec)
141+
}
127142
// Only write the header if not already sent.
128143
ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError)
129144
}
130145
}()
131146
next(ctx)
132147
}
133148
}
149+
150+
// sanitizeForLog replaces ASCII control characters (0x00–0x1F, 0x7F) with
151+
// their hex-escaped form (e.g. \x0a) to prevent log injection attacks where
152+
// crafted URIs containing \r\n could forge log entries.
153+
// SEC-008: Log sanitization for untrusted request data.
154+
func sanitizeForLog(s string) string {
155+
// Fast path: no control characters → return as-is.
156+
clean := true
157+
for i := 0; i < len(s); i++ {
158+
if s[i] < 0x20 || s[i] == 0x7f {
159+
clean = false
160+
break
161+
}
162+
}
163+
if clean {
164+
return s
165+
}
166+
167+
var b strings.Builder
168+
b.Grow(len(s))
169+
for i := 0; i < len(s); i++ {
170+
c := s[i]
171+
if c < 0x20 || c == 0x7f {
172+
b.WriteString(`\x`)
173+
b.WriteByte("0123456789abcdef"[c>>4])
174+
b.WriteByte("0123456789abcdef"[c&0x0f])
175+
} else {
176+
b.WriteByte(c)
177+
}
178+
}
179+
return b.String()
180+
}

0 commit comments

Comments
 (0)