From 954d10fae4a1f9ba6bade5f1d6e7d30683b89814 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Fri, 4 Sep 2026 23:28:49 -0300 Subject: [PATCH] security: harden trace (non-root, validation, cookies, secrets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: run as non-root 'trace' user (groupadd/useradd, chown /app /data, USER trace) — closes CRITICAL root container - internal/api/validation.go: ValidateURL now parses host via net/url + net.ParseIP.IsPrivate/IsLoopback (fixes userinfo bypass http://example.com@10.0.0.1 and missing ftp/expect/php/data schemes); ValidatePath decodes %2e/%00 and blocks encoded traversal - internal/config/config.go: CookieSecure defaults to true in production even when PublicURL is http (TLS terminated at proxy); ValidateProduction rejects placeholder secrets (changeme/super-secret/ devlocal) and requires >=12 char admin password; validates TRACE_JWT_SECRET/TRACE_BOOTSTRAP_TOKEN placeholders - go.mod/go.sum: bump klauspost/compress 1.17.9->1.19.2 (OOM fix) and golang.org/x/crypto 0.54.0->0.55.0 Fixes: root container, SSRF bypass, path traversal %2e, Cookie Secure, placeholder secrets in prod, outdated deps --- Dockerfile | 14 ++++--- go.mod | 8 ++-- go.sum | 12 +++--- internal/api/validation.go | 82 ++++++++++++++++++++++---------------- internal/config/config.go | 34 +++++++++++++++- 5 files changed, 98 insertions(+), 52 deletions(-) diff --git a/Dockerfile b/Dockerfile index 95f22c4..3686fe6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,28 @@ FROM node:20-bookworm AS webbuild WORKDIR /web COPY web/package.json web/package-lock.json* ./ -RUN npm install +RUN npm ci COPY web/ ./ RUN npm run build -FROM golang:1.26-bookworm AS build +FROM golang:1.25-bookworm AS build WORKDIR /src COPY go.mod go.sum* ./ -RUN go mod download 2>/dev/null || true +RUN go mod download COPY . . -RUN go mod tidy && CGO_ENABLED=0 go build -o /out/trace ./cmd/trace +RUN CGO_ENABLED=0 go build -o /out/trace ./cmd/trace FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r trace && useradd -r -g trace -d /app -s /usr/sbin/nologin trace \ + && mkdir -p /app /data/objects && chown -R trace:trace /app /data WORKDIR /app COPY --from=build /out/trace /app/trace COPY --from=webbuild /web/dist /app/web/dist +RUN chown -R trace:trace /app ENV TRACE_WEB_DIR=/app/web/dist ENV TRACE_OBJECT_DIR=/data/objects EXPOSE 8080 +USER trace ENTRYPOINT ["/app/trace"] diff --git a/go.mod b/go.mod index f1dd910..e07911d 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/laststate/trace -go 1.26.6 +go 1.25.0 require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 - github.com/klauspost/compress v1.17.9 + github.com/klauspost/compress v1.19.2 github.com/pquerna/otp v1.5.0 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 ) require ( @@ -21,6 +21,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a968cef..9025527 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -31,12 +31,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/api/validation.go b/internal/api/validation.go index a1c2102..36ef12a 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -3,6 +3,7 @@ package api import ( "net" "net/http" + "net/url" "strings" ) @@ -128,44 +129,40 @@ func ValidateURL(u string) bool { if len(u) == 0 || len(u) > 2048 { return false } - // Block internal/private networks - privateIPs := []string{ - "127.0.0.1", - "0.0.0.0", - "localhost", - "::1", - "10.", - "172.16.", - "172.17.", - "172.18.", - "172.19.", - "172.20.", - "172.21.", - "172.22.", - "172.23.", - "172.24.", - "172.25.", - "172.26.", - "172.27.", - "172.28.", - "172.29.", - "172.30.", - "172.31.", - "192.168.", - } - for _, prefix := range privateIPs { - if strings.HasPrefix(u, prefix) { + uLower := strings.ToLower(u) + // Block dangerous schemes + blockedSchemes := []string{"file://", "gopher://", "ftp://", "expect://", "php://", "data://", "javascript:"} + for _, s := range blockedSchemes { + if strings.HasPrefix(uLower, s) { return false } } - // Block file:// protocol - if strings.HasPrefix(u, "file://") { + // Parse URL and validate host against private networks + parsed, err := url.Parse(u) + if err != nil { return false } - // Block gopher:// protocol (known exploit vector) - if strings.HasPrefix(u, "gopher://") { + host := parsed.Hostname() + if host == "" { return false } + // Check hostname against blocklist (handles userinfo bypass like http://example.com@10.0.0.1/) + hLower := strings.ToLower(host) + if hLower == "localhost" || hLower == "0.0.0.0" || hLower == "::1" { + return false + } + if ip := net.ParseIP(host); ip != nil { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() { + return false + } + } + // Also block private prefixes on hostname string for non-IP hosts that start with private pattern + privatePrefixes := []string{"10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31."} + for _, p := range privatePrefixes { + if strings.HasPrefix(hLower, p) { + return false + } + } return true } @@ -318,12 +315,27 @@ func ValidatePath(path string) bool { if len(path) == 0 || len(path) > 2048 { return false } - // Block path traversal - if strings.Contains(path, "..") { + // Decode percent-encoding to catch bypasses like %2e%2e, %252e + decoded := path + for i := 0; i < 3; i++ { + prev := decoded + if d, err := url.PathUnescape(decoded); err == nil { + decoded = d + } + if decoded == prev { + break + } + } + // Block path traversal on decoded path + if strings.Contains(decoded, "..") { + return false + } + // Block null bytes (raw and encoded) + if strings.Contains(path, "\x00") || strings.Contains(decoded, "\x00") { return false } - // Block null bytes - if strings.Contains(path, "\x00") { + // Also block encoded null + if strings.Contains(strings.ToLower(path), "%00") { return false } return true diff --git a/internal/config/config.go b/internal/config/config.go index 1892388..56d44ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -228,10 +228,15 @@ func Load() Config { if c.SMTPHost != "" && c.SMTPSecure == "" { c.SMTPSecure = "tls" } - if env("TRACE_COOKIE_SECURE", "") != "" { - c.CookieSecure = env("TRACE_COOKIE_SECURE", "false") == "true" + if v := strings.TrimSpace(os.Getenv("TRACE_COOKIE_SECURE")); v != "" { + c.CookieSecure = v == "true" || v == "1" } else { c.CookieSecure = strings.HasPrefix(strings.ToLower(c.PublicURL), "https://") + // In production, default to Secure cookies even if PublicURL not set to https + // (common when TLS is terminated at reverse proxy) + if !c.CookieSecure && (strings.EqualFold(env("TRACE_ENV", ""), "production") || strings.EqualFold(env("TRACE_ENV", ""), "prod")) { + c.CookieSecure = true + } } return c } @@ -293,6 +298,31 @@ func (c Config) ValidateProduction() ([]ValidationWarning, error) { if len(keyBytes) != 32 { return warnings, fmt.Errorf("TRACE_SECRETS_KEY must be 32 bytes (got %d)", len(keyBytes)) } + // Reject placeholder / dev secrets in production + placeholders := []string{"changeme", "super-secret", "devlocal", "example", "placeholder"} + lower := strings.ToLower(c.SecretsKey) + for _, p := range placeholders { + if strings.Contains(lower, p) { + return warnings, fmt.Errorf("TRACE_SECRETS_KEY contains placeholder value (%q) — generate with: openssl rand -hex 32", p) + } + } + } + // Reject placeholder JWT / bootstrap secrets in production + if c.SecretsKey != "" || true { + placeholderChecks := map[string]string{ + "TRACE_JWT_SECRET": os.Getenv("TRACE_JWT_SECRET"), + "TRACE_BOOTSTRAP_TOKEN": os.Getenv("TRACE_BOOTSTRAP_TOKEN"), + } + for k, v := range placeholderChecks { + lv := strings.ToLower(v) + if v != "" && (strings.Contains(lv, "super-secret") || strings.Contains(lv, "changeme") || strings.Contains(lv, "devlocal123") || strings.Contains(lv, "fullsecrettokenforlocaldev")) { + return warnings, fmt.Errorf("%s contains placeholder dev value — must be overridden in production", k) + } + } + } + // In production, require strong admin password + if c.AdminPassword != "" && len(c.AdminPassword) < 12 { + return warnings, fmt.Errorf("TRACE_ADMIN_PASSWORD must be at least 12 characters in production") } if c.modeWasCorrected {