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
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(commitsd26183c,6c1948d). The overall grade has been upgraded from B+ to A.
| # | 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 |
| 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 |
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.
// 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)
}- Attacker scripts HTTP requests to unique, non-existent paths:
GET /rand_000001,GET /rand_000002, …,GET /rand_99999999. - Each path passes
PathSafe(it's a valid path that simply doesn't exist on disk). - Each path is stored in
sync.Map— two strings (URL path + resolved filesystem path) per entry. - With ~100-byte average key+value per entry, 100 million requests consume ~10 GB of RAM.
- The
sync.Maphas no eviction, no TTL, no maximum size. Memory grows monotonically until OOM kill. - The
Flush()method (line 74) is only called on SIGHUP — not automatically.
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:
// 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)
}
}| 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 |
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.
# 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: 31536000Because 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.
- Operator deploys with the shipped
config.tomlwithout reviewing every security field. - CSP is empty — no Content-Security-Policy header — XSS payloads injected via user-uploaded HTML files execute freely.
- ETags disabled — clients can't use
If-None-Matchfor conditional requests. - No Referrer-Policy — browser uses default (leaks full URL to third parties).
- No Permissions-Policy — embedded iframes can request geolocation, camera, microphone.
- No HSTS — first-visit connections on HTTP are not upgraded, enabling SSL-stripping attacks.
Update config.toml to include all security defaults matching the code:
[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| 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 |
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.
// 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)
}
}- Attacker finds a way to trigger a panic (e.g., a malformed Range header causing a slice-bounds-out-of-range).
- Full stack trace is written to stdout/stderr, which may be forwarded to a centralized logging system.
- 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.
Make stack trace logging configurable, defaulting to a truncated version in production:
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)
}
}| 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 |
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.
// file.go:615
boundary := "static_web_range_boundary"
// file.go:659
boundary := "static_web_range_boundary"- Attacker sends a multi-range request:
Range: bytes=0-0,1-1. - Response contains
Content-Type: multipart/byteranges; boundary=static_web_range_boundary. - This uniquely identifies the server software as "static-web" even if the
Serverheader is stripped by a reverse proxy. - The static boundary also has a theoretical MIME confusion risk if an attacker can control file content containing the exact boundary string.
Generate a random boundary per response:
import "crypto/rand"
func randomBoundary() string {
var buf [16]byte
_, _ = rand.Read(buf[:])
return hex.EncodeToString(buf[:])
}
// Usage:
boundary := randomBoundary()| 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 |
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.
// 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()- Operator configures
max_file_size = 1073741824(1 GB). - Attacker requests a 500 MB
.htmlfile withAccept-Encoding: gzip. - The file handler reads 500 MB into memory.
- Compression middleware allocates an additional ~250 MB buffer and compresses.
- Peak memory usage for this single request: ~750 MB. A handful of concurrent requests exhaust available memory.
Add a maximum compression threshold:
const maxCompressSize = 10 * 1024 * 1024 // 10 MB
body := ctx.Response.Body()
if len(body) < cfg.MinSize || len(body) > maxCompressSize {
return
}| 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 |
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.
// 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
}- If fasthttp's URI normalization has a bypass, two different URL strings could map to the same file but produce different cache keys.
- Request A (
/styles/app.css) is served and cached. - Request B (
/styles/./app.css— if somehow not normalized) would get a cache MISS and be re-read from disk, bypassing cache-based controls. - Low probability because fasthttp does normalize paths, but defense-in-depth argues for explicit normalization.
Apply path.Clean in the cache key function:
func CacheKeyForPath(urlPath, indexFile string) string {
urlPath = path.Clean("/" + urlPath) // explicit normalization
if indexFile == "" {
indexFile = "index.html"
}
// ... rest of function
}| 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 |
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.
s.http = &fasthttp.Server{
Handler: httpHandler,
Name: "static-web", // disclosed
}
s.https = &fasthttp.Server{
Handler: httpsHandler,
Name: "static-web", // disclosed
}Set Name to an empty string to suppress the Server header, or make it configurable:
s.http = &fasthttp.Server{
Name: "", // suppress Server header
}| 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 |
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.
- Attacker sends:
GET /legit%0a2026/04/11%2012:00:00%20GET%20/admin%20200%200%201us HTTP/1.1 - When decoded, the URI contains a newline, creating a fake log line that appears to show a successful request to
/admin. - Log analysis tools or human reviewers may be misled.
Sanitize URIs before logging by replacing control characters:
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()))| 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 |
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.
Remove the deprecated field:
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
}| 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 |
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.
var buf bytes.Buffer
_ = dirListTemplate.Execute(&buf, data)
ctx.SetBody(buf.Bytes())Handle the error and return 500:
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())| 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 |
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.
- Document the constraint — warn operators that
MaxFileSizealso implicitly limits the maximum servable file size before memory pressure. - Add a hard maximum:
const absoluteMaxFileSize = 512 * 1024 * 1024 // 512 MB
if info.Size() > absoluteMaxFileSize {
ctx.Error("File too large", fasthttp.StatusRequestEntityTooLarge)
return
}- Consider
net/httpfor a future streaming path.
| 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.
| 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.
| 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:
MaxRequestBodySize: 1024, // 1 KB -- static server needs no request body| 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.
| 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 |
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.
Add symlink target validation in the preload walker:
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
}The following practices demonstrate strong security awareness and are worth preserving:
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.
File: internal/security/security.go:272–275
Prevents TRACE (XST attacks), PUT/POST/DELETE, and any other method. Correct for a static file server.
File: internal/handler/dirlist.go:40
Using html/template (not text/template) ensures all interpolated values are automatically HTML-escaped.
File: internal/security/security.go:313–316
Emits a literal * rather than reflecting the Origin header. Prevents credential-based cross-origin attacks.
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.
Proactive vulnerability scanning against the Go vulnerability database on every CI run.
Tests run with -race, detecting data races in concurrent code (sync.Map, sync.Pool, atomics, goroutines).
No API keys, tokens, passwords, or credentials found in any source file. All sensitive configuration loaded from config/environment.
File: internal/handler/file.go:509–552
ValidateSidecarPath ensures .gz, .br, .zst sidecar files haven't escaped the root directory via symlink.
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.
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.
File: internal/handler/file.go:450
Even the custom 404 page path from configuration is validated through PathSafe, preventing config-driven path injection.
| 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.