diff --git a/README.md b/README.md index 1a52a72..315cdfd 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ HTTP request │ • Method whitelist (GET/HEAD/OPTIONS only) │ │ • Security headers (set BEFORE path check) │ │ • PathSafe: null bytes, path.Clean, EvalSymlinks│ -│ • Path-safety cache (sync.Map, pre-warmed) │ +│ • Path-safety cache (bounded LRU, pre-warmed) │ │ • Dotfile blocking │ │ • CORS (preflight + per-origin or wildcard *) │ │ • Injects validated path into ctx.SetUserValue │ @@ -129,7 +129,7 @@ GET /app.js NO → os.Stat → os.ReadFile → cache.Put → serveFromCache ``` -When `preload = true`, every eligible file is loaded into cache at startup. The path-safety cache (`sync.Map`) is also pre-warmed, so the very first request for any preloaded file skips both filesystem I/O and `EvalSymlinks`. +When `preload = true`, every eligible file is loaded into cache at startup. The path-safety cache (bounded LRU) is also pre-warmed, so the very first request for any preloaded file skips both filesystem I/O and `EvalSymlinks`. Symlink targets are validated against the root during the preload walk — symlinks pointing outside root are skipped. --- @@ -164,7 +164,7 @@ Measured on Apple M2 Pro (`go test -bench=. -benchtime=5s`): - **Direct `ctx.SetBody()` fast path**: cache hits bypass range/conditional logic entirely; pre-formatted `Content-Type` and `Content-Length` headers are assigned directly. - **Custom Range implementation**: `parseRange()`/`serveRange()` handle byte-range requests without `http.ServeContent`. - **Post-processing compression**: compress middleware runs after the handler, compressing the response body in a single pass. -- **Path-safety cache**: `sync.Map`-based cache eliminates per-request `filepath.EvalSymlinks` syscalls. Pre-warmed from preload. +- **Path-safety cache**: Bounded LRU cache (default 10,000 entries) eliminates per-request `filepath.EvalSymlinks` syscalls. Pre-warmed from preload. - **GC tuning**: `gc_percent = 400` reduces garbage collection frequency — the hot path avoids all formatting allocations, with only minimal byte-to-string conversions from fasthttp's `[]byte` API. - **Cache-before-stat**: `os.Stat` is never called on a cache hit — the hot path is pure memory. - **Zero-alloc `AcceptsEncoding`**: walks the `Accept-Encoding` header byte-by-byte without `strings.Split`. @@ -214,7 +214,8 @@ Only `GET`, `HEAD`, and `OPTIONS` are accepted. All other methods (including `TR | `ReadTimeout` | 10 s (covers full read phase including headers — Slowloris protection) | | `WriteTimeout` | 10 s | | `IdleTimeout` | 75 s (keep-alive) | -| `MaxRequestBodySize` | 0 (no body accepted — static server) | +| `MaxRequestBodySize` | 1024 bytes (static file server needs no large request bodies) | +| `MaxConnsPerIP` | Configurable (default 0 = unlimited) | --- @@ -235,6 +236,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start | `write_timeout` | duration | `10s` | Response write deadline | | `idle_timeout` | duration | `75s` | Keep-alive idle timeout | | `shutdown_timeout` | duration | `15s` | Graceful drain window | +| `max_conns_per_ip` | int | `0` | Max concurrent connections per IP (0 = unlimited) | ### `[files]` @@ -243,6 +245,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start | `root` | string | `./public` | Directory to serve | | `index` | string | `index.html` | Index file for directory requests | | `not_found` | string | — | Custom 404 page (relative to `root`) | +| `max_serve_file_size` | int | `1073741824` | Max file size to serve in bytes (0 = unlimited; default 1 GB). Files exceeding this limit receive 413. | ### `[cache]` @@ -263,6 +266,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start | `min_size` | int | `1024` | Minimum bytes to compress | | `level` | int | `5` | gzip level (1–9) | | `precompressed` | bool | `true` | Serve `.gz`/`.br`/`.zst` sidecar files | +| `max_compress_size` | int | `10485760` | Max body size for on-the-fly gzip compression in bytes (0 = unlimited; default 10 MB) | ### `[headers]` @@ -303,9 +307,11 @@ All environment variables override the corresponding TOML setting. Useful for co | `STATIC_SERVER_WRITE_TIMEOUT` | `server.write_timeout` | | `STATIC_SERVER_IDLE_TIMEOUT` | `server.idle_timeout` | | `STATIC_SERVER_SHUTDOWN_TIMEOUT` | `server.shutdown_timeout` | +| `STATIC_SERVER_MAX_CONNS_PER_IP` | `server.max_conns_per_ip` | | `STATIC_FILES_ROOT` | `files.root` | | `STATIC_FILES_INDEX` | `files.index` | | `STATIC_FILES_NOT_FOUND` | `files.not_found` | +| `STATIC_FILES_MAX_SERVE_FILE_SIZE` | `files.max_serve_file_size` | | `STATIC_CACHE_ENABLED` | `cache.enabled` | | `STATIC_CACHE_PRELOAD` | `cache.preload` | | `STATIC_CACHE_MAX_BYTES` | `cache.max_bytes` | @@ -315,6 +321,7 @@ All environment variables override the corresponding TOML setting. Useful for co | `STATIC_COMPRESSION_ENABLED` | `compression.enabled` | | `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` | | `STATIC_COMPRESSION_LEVEL` | `compression.level` | +| `STATIC_COMPRESSION_MAX_COMPRESS_SIZE` | `compression.max_compress_size` | | `STATIC_HEADERS_ENABLE_ETAGS` | `headers.enable_etags` | | `STATIC_SECURITY_BLOCK_DOTFILES` | `security.block_dotfiles` | | `STATIC_SECURITY_CSP` | `security.csp` | diff --git a/SECURITY_EVAL_2026-04-11.md b/SECURITY_EVAL_2026-04-11.md new file mode 100644 index 0000000..1045c80 --- /dev/null +++ b/SECURITY_EVAL_2026-04-11.md @@ -0,0 +1,752 @@ +# Security Audit Report — static-web + +**Project:** BackendStack21/static-web +**Language:** Go 1.26 +**Framework:** valyala/fasthttp +**Audit Date:** April 11, 2026 +**Overall Grade:** **B+** → **A** (post-remediation) +**Findings:** 0 CRITICAL · 1 HIGH · 5 MEDIUM · 5 LOW · 5 INFO — **All 16 resolved** + +--- + +## Executive Summary + +`static-web` demonstrates strong security fundamentals — multi-layer path traversal prevention, XSS-safe templating, excellent TLS configuration, and a CI pipeline with `govulncheck` and race detection. The single HIGH-severity finding is an unbounded in-memory path cache (`sync.Map`) that enables a straightforward memory exhaustion DoS. Five MEDIUM findings cover weakened shipped defaults, compression resource limits, server fingerprinting, cache key normalization, and verbose panic logging. No critical vulnerabilities were found. + +> **Remediation Status:** All 16 findings have been addressed in branch `fix/security-audit-remediations` (commits `d26183c`, `6c1948d`). The overall grade has been upgraded from **B+** to **A**. + +--- + +## Findings Summary + +| # | Finding | Severity | Category | File | Status | +| ------- | ---------------------------------------------- | -------------- | -------------------- | ------------------------------- | ------------ | +| SEC-001 | Unbounded `PathCache` (DoS) | **HIGH** | Resource Exhaustion | `security/security.go:49–70` | ✅ Resolved | +| SEC-002 | Shipped `config.toml` weakens code defaults | MEDIUM | Misconfiguration | `config.toml:28–38` | ✅ Resolved | +| SEC-003 | Full stack traces logged on panic | MEDIUM | Information Disclosure | `handler/middleware.go:121–132` | ✅ Resolved | +| SEC-004 | Static multipart range boundary | MEDIUM | Fingerprinting | `handler/file.go:615, 659` | ✅ Resolved | +| SEC-005 | No max body size for gzip compression | MEDIUM | Resource Exhaustion | `compress/compress.go:170–187` | ✅ Resolved | +| SEC-006 | Cache keys not explicitly normalized | MEDIUM | Access Control | `headers/headers.go:19–33` | ✅ Resolved | +| SEC-007 | Server name disclosed in headers | LOW | Fingerprinting | `server/server.go:70, 112` | ✅ Resolved | +| SEC-008 | Unsanitized paths in log output | LOW | Log Injection | `handler/middleware.go:113–115` | ✅ Resolved | +| SEC-009 | Deprecated `PreferServerCipherSuites` | LOW | Cryptography | `server/server.go:93` | ✅ Resolved | +| SEC-010 | Template execution error silently discarded | LOW | Error Handling | `handler/dirlist.go:191` | ✅ Resolved | +| SEC-011 | Large files read entirely into memory | LOW | Resource Exhaustion | `handler/file.go:338–377` | ✅ Resolved | +| SEC-012 | CORS wildcard `Vary` header note | INFO | CORS | `security/security.go:313` | ✅ Resolved | +| SEC-013 | ETag truncated to 64 bits | INFO | Cryptography | `handler/file.go:480–483` | ✅ Resolved | +| SEC-014 | `MaxRequestBodySize: 0` uses fasthttp default | INFO | Misconfiguration | `server/server.go:74` | ✅ Resolved | +| SEC-015 | No built-in rate limiting | INFO | Resource Exhaustion | Architectural | ✅ Resolved | +| SEC-016 | Preload walker doesn't validate symlink targets | INFO | Access Control | `cache/preload.go:74–158` | ✅ Resolved | + +--- + +## Detailed Findings + +### SEC-001: Unbounded Path Validation Cache (Denial of Service) + +| Attribute | Value | +| ----------- | ------------------------------------------------------ | +| **Severity**| **HIGH** | +| **Status** | ✅ **Resolved** — Replaced `sync.Map` with `hashicorp/golang-lru/v2` bounded LRU cache (10,000 entries). `NewPathCache(maxEntries int)` constructor added. | +| **CWE** | CWE-400 (Uncontrolled Resource Consumption) | +| **OWASP** | A05:2021 — Security Misconfiguration | +| **File** | `internal/security/security.go:49–70` | + +#### Description + +The `PathCache` struct wraps a bare `sync.Map` with no upper bound on the number of entries. Every unique URL path that passes `PathSafe` validation is unconditionally cached (line 304 of `security.go`). Because `PathSafe` successfully validates *non-existent* file paths (they pass the prefix check and return the unresolved candidate at line 165), an attacker doesn't even need to target real files — any fabricated path like `/aaa`, `/aab`, `/aac`, … will be validated, cached, and never evicted. + +#### Evidence + +```go +// security.go:49-51 — no size limit declared +type PathCache struct { + m sync.Map // urlPath (string) -> safePath (string) +} + +// security.go:68-70 — unconditional store, no eviction +func (pc *PathCache) Store(urlPath, safePath string) { + pc.m.Store(urlPath, safePath) +} + +// security.go:302-305 — stored on every cache miss +if pathCache != nil { + pathCache.Store(urlPath, safePath) +} +``` + +#### Attack Scenario + +1. Attacker scripts HTTP requests to unique, non-existent paths: `GET /rand_000001`, `GET /rand_000002`, …, `GET /rand_99999999`. +2. Each path passes `PathSafe` (it's a valid path that simply doesn't exist on disk). +3. Each path is stored in `sync.Map` — two strings (URL path + resolved filesystem path) per entry. +4. With ~100-byte average key+value per entry, 100 million requests consume ~10 GB of RAM. +5. The `sync.Map` has no eviction, no TTL, no maximum size. Memory grows monotonically until OOM kill. +6. The `Flush()` method (line 74) is only called on SIGHUP — not automatically. + +#### Recommendation + +Replace `sync.Map` with a bounded LRU cache (the project already depends on `hashicorp/golang-lru/v2`), or only cache paths for files that actually exist on disk: + +```go +// Option A: Bounded LRU +import lru "github.com/hashicorp/golang-lru/v2" + +type PathCache struct { + m *lru.Cache[string, string] +} + +func NewPathCache(maxEntries int) *PathCache { + c, _ := lru.New[string, string](maxEntries) // e.g., 65536 + return &PathCache{m: c} +} + +// Option B: Only cache existing files (in Middleware, after PathSafe) +if pathCache != nil { + if _, err := os.Stat(safePath); err == nil { + pathCache.Store(urlPath, safePath) + } +} +``` + +--- + +### SEC-002: Shipped `config.toml` Weakens Code Defaults + +| Attribute | Value | +| ----------- | ------------------------------------------------------ | +| **Severity**| MEDIUM | +| **Status** | ✅ **Resolved** — `config.toml` (gitignored, local only) updated with secure defaults. Tracked `config.toml.example` already had correct values. | +| **CWE** | CWE-1188 (Insecure Default Initialization of Resource) | +| **OWASP** | A05:2021 — Security Misconfiguration | +| **File** | `config.toml:28–38` | + +#### Description + +The code in `config.go:147–178` sets strong security defaults (`EnableETags = true`, `CSP = "default-src 'self'"`, `ReferrerPolicy = "strict-origin-when-cross-origin"`, `PermissionsPolicy = "geolocation=(), microphone=(), camera=()"`, `HSTSMaxAge = 31536000`). However, the shipped `config.toml` overrides several of these with weaker values. + +#### Evidence + +```toml +# config.toml:33 — disables ETag generation +enable_etags = false + +# config.toml:38 — empties CSP entirely +csp = "" + +# config.toml — MISSING these keys entirely (reset to zero-values): +# referrer_policy = "" <- code default: "strict-origin-when-cross-origin" +# permissions_policy = "" <- code default: "geolocation=(), microphone=(), camera=()" +# hsts_max_age = 0 <- code default: 31536000 +``` + +Because `toml.DecodeFile` merges into the struct *after* `applyDefaults` runs (config.go:131–138), any key present in the TOML file replaces the secure code default. Keys absent from the TOML get reset to Go zero values. + +#### Attack Scenario + +1. Operator deploys with the shipped `config.toml` without reviewing every security field. +2. CSP is empty — no Content-Security-Policy header — XSS payloads injected via user-uploaded HTML files execute freely. +3. ETags disabled — clients can't use `If-None-Match` for conditional requests. +4. No Referrer-Policy — browser uses default (leaks full URL to third parties). +5. No Permissions-Policy — embedded iframes can request geolocation, camera, microphone. +6. No HSTS — first-visit connections on HTTP are not upgraded, enabling SSL-stripping attacks. + +#### Recommendation + +Update `config.toml` to include all security defaults matching the code: + +```toml +[headers] +enable_etags = true + +[security] +block_dotfiles = true +directory_listing = false +cors_origins = [] +csp = "default-src 'self'" +referrer_policy = "strict-origin-when-cross-origin" +permissions_policy = "geolocation=(), microphone=(), camera=()" +hsts_max_age = 31536000 +hsts_include_subdomains = false +``` + +--- + +### SEC-003: Full Stack Trace Logged on Panic in Production + +| Attribute | Value | +| ----------- | ------------------------------------------------------------ | +| **Severity**| MEDIUM | +| **Status** | ✅ **Resolved** — Stack traces now only logged when `STATIC_DEBUG=1` env var is set. Default: panic value only. | +| **CWE** | CWE-209 (Error Message Containing Sensitive Information) | +| **OWASP** | A04:2021 — Insecure Design | +| **File** | `internal/handler/middleware.go:121–132` | + +#### Description + +The `recoveryMiddleware` calls `debug.Stack()` on every panic and logs the full Go stack trace. This trace includes absolute file paths, function names, goroutine IDs, and line numbers — information that aids an attacker in understanding the server's internals. While the stack trace is NOT sent to the client (only "Internal Server Error" is returned), it is an information disclosure risk in logging pipelines. + +#### Evidence + +```go +// middleware.go:121-132 +func recoveryMiddleware(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + defer func() { + if rec := recover(); rec != nil { + stack := debug.Stack() + log.Printf("PANIC recovered: %v\n%s", rec, stack) + ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError) + } + }() + next(ctx) + } +} +``` + +#### Attack Scenario + +1. Attacker finds a way to trigger a panic (e.g., a malformed Range header causing a slice-bounds-out-of-range). +2. Full stack trace is written to stdout/stderr, which may be forwarded to a centralized logging system. +3. If logs are accessible to a broader team or leak through a log aggregation UI, the stack trace reveals internal file structure, function names, and Go version/module paths. + +#### Recommendation + +Make stack trace logging configurable, defaulting to a truncated version in production: + +```go +func recoveryMiddleware(next fasthttp.RequestHandler, verbose bool) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + defer func() { + if rec := recover(); rec != nil { + if verbose { + log.Printf("PANIC recovered: %v\n%s", rec, debug.Stack()) + } else { + log.Printf("PANIC recovered: %v", rec) + } + ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError) + } + }() + next(ctx) + } +} +``` + +--- + +### SEC-004: Static Multipart Range Boundary Enables Server Fingerprinting + +| Attribute | Value | +| ----------- | -------------------------------------------------- | +| **Severity**| MEDIUM | +| **Status** | ✅ **Resolved** — Boundary now generated per-response using `crypto/rand` (16 random bytes → hex). | +| **CWE** | CWE-200 (Exposure of Sensitive Information) | +| **OWASP** | A05:2021 — Security Misconfiguration | +| **File** | `internal/handler/file.go:615` and `file.go:659` | + +#### Description + +Multi-range responses use a hardcoded boundary string `"static_web_range_boundary"`. This same string appears in two separate functions (`serveRange` and `serveLargeFileRange`). This boundary is globally constant across all responses and all server instances, uniquely identifying the server software. + +#### Evidence + +```go +// file.go:615 +boundary := "static_web_range_boundary" + +// file.go:659 +boundary := "static_web_range_boundary" +``` + +#### Attack Scenario + +1. Attacker sends a multi-range request: `Range: bytes=0-0,1-1`. +2. Response contains `Content-Type: multipart/byteranges; boundary=static_web_range_boundary`. +3. This uniquely identifies the server software as "static-web" even if the `Server` header is stripped by a reverse proxy. +4. The static boundary also has a theoretical MIME confusion risk if an attacker can control file content containing the exact boundary string. + +#### Recommendation + +Generate a random boundary per response: + +```go +import "crypto/rand" + +func randomBoundary() string { + var buf [16]byte + _, _ = rand.Read(buf[:]) + return hex.EncodeToString(buf[:]) +} + +// Usage: +boundary := randomBoundary() +``` + +--- + +### SEC-005: No Upper Bound on On-The-Fly Gzip Compression Body Size + +| Attribute | Value | +| ----------- | ----------------------------------------------- | +| **Severity**| MEDIUM | +| **Status** | ✅ **Resolved** — Added `MaxCompressSize` config field (default 10 MB). Bodies exceeding limit skip on-the-fly compression. Env: `STATIC_COMPRESSION_MAX_COMPRESS_SIZE`. | +| **CWE** | CWE-400 (Uncontrolled Resource Consumption) | +| **OWASP** | A05:2021 — Security Misconfiguration | +| **File** | `internal/compress/compress.go:170–187` | + +#### Description + +The compression middleware checks `len(body) < cfg.MinSize` (minimum threshold) but has no *maximum* threshold. If a large compressible response bypasses the file cache (e.g., `serveLargeFile` serving a 500 MB HTML file), the entire body is gzip-compressed in memory. + +#### Evidence + +```go +// compress.go:170-187 +body := ctx.Response.Body() +if len(body) < cfg.MinSize { + return +} +// No upper bound check here! + +buf := gzipBufPool.Get().(*bytes.Buffer) +buf.Reset() +buf.Grow(len(body) / 2) // Allocates len(body)/2 upfront + +gz := gzipWriterPool.Get().(*gzip.Writer) +gz.Reset(buf) +gz.Write(body) // Compresses entire body in memory +gz.Close() +``` + +#### Attack Scenario + +1. Operator configures `max_file_size = 1073741824` (1 GB). +2. Attacker requests a 500 MB `.html` file with `Accept-Encoding: gzip`. +3. The file handler reads 500 MB into memory. +4. Compression middleware allocates an additional ~250 MB buffer and compresses. +5. Peak memory usage for this single request: ~750 MB. A handful of concurrent requests exhaust available memory. + +#### Recommendation + +Add a maximum compression threshold: + +```go +const maxCompressSize = 10 * 1024 * 1024 // 10 MB + +body := ctx.Response.Body() +if len(body) < cfg.MinSize || len(body) > maxCompressSize { + return +} +``` + +--- + +### SEC-006: Cache Keys Not Explicitly Normalized Before Lookup + +| Attribute | Value | +| ----------- | ------------------------------------------------------------- | +| **Severity**| MEDIUM | +| **Status** | ✅ **Resolved** — Added `path.Clean("/" + urlPath)` in `CacheKeyForPath`. Trailing-slash semantics preserved via `isDir` check before cleaning. | +| **CWE** | CWE-706 (Use of Incorrectly-Resolved Name or Reference) | +| **OWASP** | A01:2021 — Broken Access Control | +| **File** | `internal/headers/headers.go:19–33` and `cache/cache.go:209` | + +#### Description + +Cache keys are derived from `ctx.Path()` (which fasthttp normalizes) and passed through `CacheKeyForPath()`, but this function does NOT call `path.Clean()`. While fasthttp does normalize most paths, edge cases with percent-encoding or unusual Unicode normalization could theoretically produce distinct cache keys that resolve to the same filesystem file. + +#### Evidence + +```go +// headers.go:19-33 +func CacheKeyForPath(urlPath, indexFile string) string { + // No path.Clean() call + if urlPath == "" || urlPath == "/" { + if indexFile == "index.html" { + return "/index.html" + } + return "/" + indexFile + } + if strings.HasSuffix(urlPath, "/") { + return urlPath + indexFile + } + return urlPath // passed through verbatim +} +``` + +#### Attack Scenario + +1. If fasthttp's URI normalization has a bypass, two different URL strings could map to the same file but produce different cache keys. +2. Request A (`/styles/app.css`) is served and cached. +3. Request B (`/styles/./app.css` — if somehow not normalized) would get a cache MISS and be re-read from disk, bypassing cache-based controls. +4. Low probability because fasthttp *does* normalize paths, but defense-in-depth argues for explicit normalization. + +#### Recommendation + +Apply `path.Clean` in the cache key function: + +```go +func CacheKeyForPath(urlPath, indexFile string) string { + urlPath = path.Clean("/" + urlPath) // explicit normalization + if indexFile == "" { + indexFile = "index.html" + } + // ... rest of function +} +``` + +--- + +### SEC-007: Server Name Disclosed in HTTP Response Headers + +| Attribute | Value | +| ----------- | ------------------------------------------------------------------ | +| **Severity**| LOW | +| **Status** | ✅ **Resolved** — Set `Name: ""` on both HTTP and HTTPS `fasthttp.Server` instances. `Server` header no longer emitted. | +| **CWE** | CWE-200 (Exposure of Sensitive Information to Unauthorized Actor) | +| **OWASP** | A05:2021 — Security Misconfiguration | +| **File** | `internal/server/server.go:70` and `server.go:112` | + +#### Description + +Both the HTTP and HTTPS `fasthttp.Server` instances set `Name: "static-web"`. Fasthttp uses this value to populate the `Server` response header on every response, identifying the software. + +#### Evidence + +```go +s.http = &fasthttp.Server{ + Handler: httpHandler, + Name: "static-web", // disclosed +} +s.https = &fasthttp.Server{ + Handler: httpsHandler, + Name: "static-web", // disclosed +} +``` + +#### Recommendation + +Set `Name` to an empty string to suppress the `Server` header, or make it configurable: + +```go +s.http = &fasthttp.Server{ + Name: "", // suppress Server header +} +``` + +--- + +### SEC-008: Unsanitized File Paths in Log Output + +| Attribute | Value | +| ----------- | --------------------------------------------------------- | +| **Severity**| LOW | +| **Status** | ✅ **Resolved** — Added `sanitizeForLog()` that replaces ASCII control chars (0x00–0x1F, 0x7F) with `\xNN` hex escapes. Applied to URI in access logging. | +| **CWE** | CWE-117 (Improper Output Neutralization for Logs) | +| **OWASP** | A09:2021 — Security Logging and Monitoring Failures | +| **File** | `internal/handler/middleware.go:113–115` and `file.go:257` | + +#### Description + +Access logs include the raw request URI without sanitizing control characters (newlines, carriage returns, ANSI escape sequences). An attacker can inject fake log lines via specially crafted URLs. + +#### Attack Scenario + +1. Attacker sends: `GET /legit%0a2026/04/11%2012:00:00%20GET%20/admin%20200%200%201us HTTP/1.1` +2. When decoded, the URI contains a newline, creating a fake log line that appears to show a successful request to `/admin`. +3. Log analysis tools or human reviewers may be misled. + +#### Recommendation + +Sanitize URIs before logging by replacing control characters: + +```go +func sanitizeForLog(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return '?' + } + return r + }, s) +} + +uri := sanitizeForLog(string(ctx.RequestURI())) +``` + +--- + +### SEC-009: Deprecated `PreferServerCipherSuites` TLS Field + +| Attribute | Value | +| ----------- | ------------------------------------------------------ | +| **Severity**| LOW | +| **Status** | ✅ **Resolved** — Removed `PreferServerCipherSuites: true`. Added explanatory comment noting Go ≥1.21 manages cipher order automatically. | +| **CWE** | CWE-327 (Use of a Broken or Risky Cryptographic Algorithm) | +| **OWASP** | A02:2021 — Cryptographic Failures | +| **File** | `internal/server/server.go:93` | + +#### Description + +The TLS configuration sets `PreferServerCipherSuites: true`, which has been deprecated since Go 1.17 and is a no-op since Go 1.21. The cipher suite selection itself is excellent (all AEAD ciphers, no CBC, no RSA key exchange). This is purely a code hygiene issue. + +#### Recommendation + +Remove the deprecated field: + +```go +tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{ + tls.X25519, + tls.CurveP256, + }, + CipherSuites: []uint16{ + // ... same excellent suite list + }, + // PreferServerCipherSuites removed -- Go >=1.21 always prefers server order +} +``` + +--- + +### SEC-010: Template Execution Error Silently Discarded + +| Attribute | Value | +| ----------- | -------------------------------------------------- | +| **Severity**| LOW | +| **Status** | ✅ **Resolved** — Template error now checked; returns 500 Internal Server Error with log message on failure. | +| **CWE** | CWE-755 (Improper Handling of Exceptional Conditions) | +| **OWASP** | A04:2021 — Insecure Design | +| **File** | `internal/handler/dirlist.go:191` | + +#### Description + +The directory listing template execution assigns the error to the blank identifier `_`. If the template fails to render, the client receives a 200 OK with an empty or partial HTML body and no indication of failure. + +#### Evidence + +```go +var buf bytes.Buffer +_ = dirListTemplate.Execute(&buf, data) +ctx.SetBody(buf.Bytes()) +``` + +#### Recommendation + +Handle the error and return 500: + +```go +var buf bytes.Buffer +if err := dirListTemplate.Execute(&buf, data); err != nil { + log.Printf("handler: directory listing template error: %v", err) + ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError) + return +} +ctx.SetBody(buf.Bytes()) +``` + +--- + +### SEC-011: Large Files Read Entirely Into Memory + +| Attribute | Value | +| ----------- | ----------------------------------------------- | +| **Severity**| LOW | +| **Status** | ✅ **Resolved** — Added `MaxServeFileSize` config field (default 1 GB). Files exceeding limit receive 413 Payload Too Large. Env: `STATIC_FILES_MAX_SERVE_FILE_SIZE`. | +| **CWE** | CWE-770 (Allocation of Resources Without Limits or Throttling) | +| **OWASP** | A05:2021 — Security Misconfiguration | +| **File** | `internal/handler/file.go:338–377` | + +#### Description + +The `serveLargeFile` function reads the entire file into memory via `io.ReadAll`. For very large files, this can consume significant RAM per concurrent request. This is a known limitation of fasthttp's buffered response model. + +#### Recommendation + +1. Document the constraint — warn operators that `MaxFileSize` also implicitly limits the maximum servable file size before memory pressure. +2. Add a hard maximum: + +```go +const absoluteMaxFileSize = 512 * 1024 * 1024 // 512 MB + +if info.Size() > absoluteMaxFileSize { + ctx.Error("File too large", fasthttp.StatusRequestEntityTooLarge) + return +} +``` + +3. Consider `net/http` for a future streaming path. + +--- + +### SEC-012: CORS Wildcard Does Not Set `Vary: Origin` + +| Attribute | Value | +| ----------- | ------------------------- | +| **Severity**| INFO | +| **Status** | ✅ **Resolved** — Expanded inline comment in `security.go` explaining why `Vary: Origin` is NOT set with wildcard (per Fetch spec). | +| **CWE** | N/A (informational) | +| **File** | `internal/security/security.go:313–316` | + +This is actually **correct** per the Fetch specification. A literal `*` response is not origin-dependent, so `Vary: Origin` would needlessly fragment proxy caches. No code change needed. + +--- + +### SEC-013: ETag Truncation to 16 Hex Characters + +| Attribute | Value | +| ----------- | ------------------------- | +| **Severity**| INFO | +| **Status** | ✅ **Resolved** — Expanded `computeETag` doc comment with collision analysis and rationale for 64-bit truncation. | +| **CWE** | CWE-328 (Use of Weak Hash) | +| **File** | `internal/handler/file.go:480–483` | + +ETags are computed as `sha256(data)[:8]` (64 bits). For cache validation purposes, the ~10^19 possible values yield negligible collision probability at realistic file counts. ETags are not used for authentication. No change needed — appropriate trade-off for header size. + +--- + +### SEC-014: `MaxRequestBodySize: 0` Relies on Fasthttp Default + +| Attribute | Value | +| ----------- | ------------------------- | +| **Severity**| INFO | +| **Status** | ✅ **Resolved** — Set `MaxRequestBodySize: 1024` (1 KB) on both HTTP and HTTPS servers. Static file server needs no request body. | +| **CWE** | CWE-770 (Allocation of Resources Without Limits or Throttling) | +| **File** | `internal/server/server.go:74` | + +In fasthttp, `0` means "use the default" (4 MB). For a static file server that should never receive request bodies, consider setting an explicit small value: + +```go +MaxRequestBodySize: 1024, // 1 KB -- static server needs no request body +``` + +--- + +### SEC-015: No Rate Limiting or Request Throttling + +| Attribute | Value | +| ----------- | ------------------------- | +| **Severity**| INFO | +| **Status** | ✅ **Resolved** — Added `MaxConnsPerIP` config field (default 0 = unlimited) wired to `fasthttp.Server.MaxConnsPerIP`. Env: `STATIC_SERVER_MAX_CONNS_PER_IP`. | +| **CWE** | CWE-770 (Allocation of Resources Without Limits or Throttling) | +| **File** | N/A (architectural) | + +No built-in rate limiting. This is typical for a static file server (usually handled by a reverse proxy or CDN). Consider adding `MaxConnsPerIP` via fasthttp's built-in support for direct-exposure deployments. + +--- + +### SEC-016: Preload Walker Does Not Validate Symlink Targets + +| Attribute | Value | +| ----------- | ------------------------- | +| **Severity**| INFO | +| **Status** | ✅ **Resolved** — Added symlink detection (`d.Type()&os.ModeSymlink`), target resolution via `filepath.EvalSymlinks`, and validation that target stays within `absRoot`. | +| **CWE** | CWE-59 (Improper Link Resolution Before File Access) | +| **File** | `internal/cache/preload.go:74–158` | + +#### Description + +The `Preload` function uses `filepath.WalkDir` to traverse the root directory and load files into cache. Files that are symlinks are read via `os.ReadFile(fpath)`, which follows the symlink without verifying that the target is still within the root directory. The request-time path via `PathSafe` **does** perform symlink resolution and blocks this — the vulnerability is only during preload at startup. + +#### Recommendation + +Add symlink target validation in the preload walker: + +```go +realPath, err := filepath.EvalSymlinks(fpath) +if err != nil { + stats.Skipped++ + return nil +} +if !strings.HasPrefix(realPath, absRoot+string(filepath.Separator)) && realPath != absRoot { + stats.Skipped++ + return nil +} +``` + +--- + +## Positive Security Observations + +The following practices demonstrate strong security awareness and are worth preserving: + +### 1. Multi-Layer Path Traversal Prevention +**File:** `internal/security/security.go:120–187` + +The `PathSafe` function implements defense-in-depth with 5 sequential checks: null byte rejection, `path.Clean` normalization, `filepath.Join` with prefix verification, `filepath.EvalSymlinks` with re-verification, and dotfile component blocking. Textbook path traversal prevention. + +### 2. HTTP Method Whitelist (GET/HEAD/OPTIONS Only) +**File:** `internal/security/security.go:272–275` + +Prevents TRACE (XST attacks), PUT/POST/DELETE, and any other method. Correct for a static file server. + +### 3. XSS-Safe Directory Listing via `html/template` +**File:** `internal/handler/dirlist.go:40` + +Using `html/template` (not `text/template`) ensures all interpolated values are automatically HTML-escaped. + +### 4. CORS Wildcard Does Not Reflect Origin +**File:** `internal/security/security.go:313–316` + +Emits a literal `*` rather than reflecting the `Origin` header. Prevents credential-based cross-origin attacks. + +### 5. CI/CD Actions Pinned to Commit SHAs +**Files:** `.github/workflows/ci.yml`, `.github/workflows/release.yml` + +GitHub Actions are pinned to specific commit SHAs rather than mutable tags, preventing supply-chain attacks. + +### 6. `govulncheck` in CI Pipeline +Proactive vulnerability scanning against the Go vulnerability database on every CI run. + +### 7. Race Detector in Tests +Tests run with `-race`, detecting data races in concurrent code (`sync.Map`, `sync.Pool`, atomics, goroutines). + +### 8. Zero Hardcoded Secrets +No API keys, tokens, passwords, or credentials found in any source file. All sensitive configuration loaded from config/environment. + +### 9. Sidecar Path Validation +**File:** `internal/handler/file.go:509–552` + +`ValidateSidecarPath` ensures `.gz`, `.br`, `.zst` sidecar files haven't escaped the root directory via symlink. + +### 10. Robust TLS Configuration +**File:** `internal/server/server.go:79–94` + +TLS 1.2+ minimum, only AEAD cipher suites (GCM, ChaCha20-Poly1305), modern curve preferences (X25519, P-256), HTTP-to-HTTPS redirect, and HSTS support. + +### 11. Security Headers on Error Responses +**File:** `internal/security/security.go:248–260` + +Security headers are set before calling the inner handler, ensuring even 400/403/404/405 responses carry X-Content-Type-Options, X-Frame-Options, CSP, etc. + +### 12. Custom 404 Page Path Validated via PathSafe +**File:** `internal/handler/file.go:450` + +Even the custom 404 page path from configuration is validated through `PathSafe`, preventing config-driven path injection. + +--- + +## Prioritized Remediation Plan + +| Priority | Finding | Severity | Effort | Impact | Status | +| -------- | ------- | -------- | ------ | ------ | ------ | +| **P1** | SEC-001: Bound the PathCache with LRU | HIGH | Low (~30 LOC) | Eliminates DoS vector | ✅ Done | +| **P2** | SEC-002: Align `config.toml` with secure code defaults | MEDIUM | Low (~10 lines TOML) | Restores secure-by-default | ✅ Done | +| **P3** | SEC-005: Add `maxCompressSize` threshold | MEDIUM | Low (~3 LOC) | Prevents memory exhaustion | ✅ Done | +| **P4** | SEC-004: Randomize multipart boundary | MEDIUM | Low (~10 LOC) | Eliminates fingerprinting | ✅ Done | +| **P5** | SEC-006: `path.Clean` in cache key function | MEDIUM | Low (~2 LOC) | Defense-in-depth | ✅ Done | +| **P6** | SEC-003: Make stack trace logging configurable | MEDIUM | Low (~10 LOC) | Reduces info leakage | ✅ Done | +| **P7** | SEC-007: Suppress server name header | LOW | Trivial (1 LOC) | Reduces fingerprinting | ✅ Done | +| **P8** | SEC-008: Sanitize log output | LOW | Low (~15 LOC) | Prevents log forgery | ✅ Done | +| **P9** | SEC-009: Remove deprecated TLS field | LOW | Trivial (1 LOC) | Code hygiene | ✅ Done | +| **P10** | SEC-010: Handle template execution errors | LOW | Low (~5 LOC) | Better error handling | ✅ Done | +| **P11** | SEC-011: Document large file memory constraint | LOW | Medium (docs + config) | Operator awareness | ✅ Done | +| **P12** | SEC-012: Expand CORS wildcard Vary comment | INFO | Trivial | Documentation | ✅ Done | +| **P13** | SEC-013: Expand ETag truncation doc comment | INFO | Trivial | Documentation | ✅ Done | +| **P14** | SEC-014: Set explicit MaxRequestBodySize | INFO | Trivial (1 LOC) | Reduces attack surface | ✅ Done | +| **P15** | SEC-015: Add MaxConnsPerIP config | INFO | Low (~15 LOC) | DoS mitigation option | ✅ Done | +| **P16** | SEC-016: Validate symlink targets in preload | INFO | Low (~10 LOC) | Closes preload escape | ✅ Done | + +--- + +*Report generated by Kai security audit pipeline. All findings verified against source code as of commit `fcfe429`. All 16 findings remediated in commits `d26183c` and `6c1948d` on branch `fix/security-audit-remediations`.* diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4d620b8..50aa1bb 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -117,11 +117,13 @@ read_timeout = "10s" # full read deadline (covers headers; Slowloris write_timeout = "10s" idle_timeout = "75s" shutdown_timeout = "15s" # graceful drain window on SIGTERM/SIGINT +# max_conns_per_ip = 0 # max concurrent connections per IP (0 = unlimited) [files] root = "./public" # directory to serve index = "index.html" # index file for directory requests (e.g. GET /) not_found = "404.html" # custom 404 page, relative to root (optional) +# max_serve_file_size = 1073741824 # files > 1 GB get 413 (0 = no limit) [cache] enabled = true @@ -136,6 +138,7 @@ enabled = true min_size = 1024 # don't compress responses smaller than 1 KB level = 5 # gzip level 1 (fastest) – 9 (best) precompressed = true # serve .gz / .br / .zst sidecar files when available +# max_compress_size = 10485760 # skip on-the-fly gzip for bodies > 10 MB (0 = no limit) [headers] immutable_pattern = "" # glob for fingerprinted assets → Cache-Control: immutable @@ -169,9 +172,11 @@ Every config field can also be set via an environment variable, which takes prec | `STATIC_SERVER_WRITE_TIMEOUT` | `server.write_timeout` | | `STATIC_SERVER_IDLE_TIMEOUT` | `server.idle_timeout` | | `STATIC_SERVER_SHUTDOWN_TIMEOUT` | `server.shutdown_timeout` | +| `STATIC_SERVER_MAX_CONNS_PER_IP` | `server.max_conns_per_ip` | | `STATIC_FILES_ROOT` | `files.root` | | `STATIC_FILES_INDEX` | `files.index` | | `STATIC_FILES_NOT_FOUND` | `files.not_found` | +| `STATIC_FILES_MAX_SERVE_FILE_SIZE` | `files.max_serve_file_size` | | `STATIC_CACHE_ENABLED` | `cache.enabled` | | `STATIC_CACHE_PRELOAD` | `cache.preload` | | `STATIC_CACHE_MAX_BYTES` | `cache.max_bytes` | @@ -181,6 +186,7 @@ Every config field can also be set via an environment variable, which takes prec | `STATIC_COMPRESSION_ENABLED` | `compression.enabled` | | `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` | | `STATIC_COMPRESSION_LEVEL` | `compression.level` | +| `STATIC_COMPRESSION_MAX_COMPRESS_SIZE` | `compression.max_compress_size` | | `STATIC_HEADERS_ENABLE_ETAGS` | `headers.enable_etags` | | `STATIC_SECURITY_BLOCK_DOTFILES` | `security.block_dotfiles` | | `STATIC_SECURITY_CSP` | `security.csp` | @@ -644,10 +650,11 @@ STATIC_CACHE_PRELOAD=true STATIC_CACHE_GC_PERCENT=400 ./bin/static-web ### What preloading does 1. At startup, walks every file under `files.root`. -2. Files smaller than `max_file_size` are read into the LRU cache. -3. Pre-formatted `Content-Type` and `Content-Length` response headers are computed once per file. -4. The path-safety cache (`sync.Map`) is pre-warmed — the first request for any preloaded file skips `filepath.EvalSymlinks`. -5. Preload statistics (file count, total bytes, duration) are logged at startup. +2. Symlink targets are validated — symlinks pointing outside root are skipped. +3. Files smaller than `max_file_size` are read into the LRU cache. +4. Pre-formatted `Content-Type` and `Content-Length` response headers are computed once per file. +5. The path-safety cache (bounded LRU) is pre-warmed — the first request for any preloaded file skips `filepath.EvalSymlinks`. +6. Preload statistics (file count, total bytes, duration) are logged at startup. ### When to use preload @@ -783,6 +790,17 @@ The most common causes: The server only accepts `GET`, `HEAD`, and `OPTIONS`. Any other method (POST, PUT, DELETE, PATCH, TRACE, etc.) is rejected with `405`. This is intentional — it's a static file server, not an API. If your browser is sending a `POST` request, check your HTML form actions and JavaScript fetch calls. +### `413 Payload Too Large` + +A file exceeds the `max_serve_file_size` limit (default 1 GB). Increase the limit in config: + +```toml +[files] +max_serve_file_size = 2147483648 # 2 GB +``` + +Or set to 0 to disable the limit entirely. This also applies via the `STATIC_FILES_MAX_SERVE_FILE_SIZE` environment variable. + ### Files are stale after a deploy The in-memory cache serves files from memory after the first request (or immediately if `preload = true`). After deploying new files to disk, flush both the file cache and the path-safety cache: diff --git a/cmd/static-web/main.go b/cmd/static-web/main.go index 692df00..106345c 100644 --- a/cmd/static-web/main.go +++ b/cmd/static-web/main.go @@ -207,7 +207,7 @@ func runServe(args []string) { } // Pre-warm the path cache with every URL key the file cache knows about. - pathCache = security.NewPathCache() + pathCache = security.NewPathCache(security.DefaultPathCacheSize) pathCache.PreWarm(stats.Paths, cfg.Files.Root, cfg.Security.BlockDotfiles) if !effectiveQuiet { log.Printf("path cache pre-warmed with %d entries", pathCache.Len()) diff --git a/config.toml.example b/config.toml.example index 320d561..5b2f4a1 100644 --- a/config.toml.example +++ b/config.toml.example @@ -32,6 +32,11 @@ idle_timeout = "75s" # How long to wait for in-flight requests to complete during graceful shutdown. shutdown_timeout = "15s" +# Maximum concurrent connections allowed from a single IP address. +# 0 means unlimited (default). Set to a positive value (e.g. 100) to limit +# per-IP connection count and mitigate connection exhaustion attacks. +# max_conns_per_ip = 0 + [files] # Directory to serve files from. root = "./public" @@ -42,6 +47,10 @@ index = "index.html" # Custom 404 page path, relative to root. Leave empty for the built-in 404. not_found = "404.html" +# Maximum file size (bytes) that will be served. Files exceeding this limit +# receive a 413 Payload Too Large response. Default: 1 GB. Set to 0 to disable. +# max_serve_file_size = 1073741824 + [cache] # Enable or disable the in-memory LRU file cache. enabled = true @@ -69,6 +78,11 @@ level = 5 # Encoding priority: br > zstd > gzip precompressed = true +# Maximum response body size (bytes) eligible for on-the-fly compression. +# Responses larger than this are sent uncompressed to avoid excessive memory use. +# Default: 10 MB (10485760). Set to 0 to disable the limit. +# max_compress_size = 10485760 + [headers] # Glob pattern for fingerprinted/immutable assets (gets "Cache-Control: immutable"). # Example: "**-[0-9a-f]*.{js,css}" diff --git a/docs/index.html b/docs/index.html index ec949ce..97ab19a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -319,14 +319,14 @@
- Path traversal prevention, dotfile blocking, CSP, HSTS, Referrer-Policy, Permissions-Policy — set on + Path traversal prevention, dotfile blocking, bounded LRU path-safety cache, log injection sanitisation, CSP, HSTS, Referrer-Policy, Permissions-Policy — set on every response.
Byte-accurate LRU cache with startup preloading, configurable max size, per-file size cap, optional TTL expiry, optional ETag (disable via --no-etag), and live flush via SIGHUP without downtime.
+Byte-accurate LRU cache with startup preloading, configurable max size, per-file size cap, optional TTL expiry, optional ETag (disable via --no-etag), bounded path-safety LRU, and live flush via SIGHUP without downtime.
Panic → 500, log stack trace
+Panic → 500, sanitised log (stack traces only with STATIC_DEBUG=1)
max_serve_file_size1073741824true
max_compress_size10485760ReadTimeout10sWriteTimeout10sIdleTimeout75s (keep-alive)MaxRequestBodySize0 (no body accepted)MaxRequestBodySize1024 bytes (static server needs no large bodies)MaxConnsPerIPConfigurable (default unlimited)Method whitelistGET, HEAD, OPTIONS only@@ -1193,13 +1209,14 @@
Modern ciphers, enforced server preference, automatic HTTPS redirect.
+Modern ciphers, Go-managed cipher ordering, automatic HTTPS redirect.
Min versionTLS 1.2CurvesX25519, P-256Cipher suitesECDHE + AEAD onlyServer preferenceEnforcedCipher orderingGo runtime managedHTTP → HTTPSAuto 301 redirectServer bannerSuppressed (empty)HSTS subdomainsOptional opt-inAES-256-GCM, ChaCha20-Poly1305, AES-128-GCM — no CBC, no RSA key exchange.
@@ -1213,14 +1230,19 @@Multiple layers of protection keep the server stable under adversity.
STATIC_DEBUG=1)
+ PathSafecrypto/rand boundaries, not static strings