Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
)
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
82 changes: 47 additions & 35 deletions internal/api/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"net"
"net/http"
"net/url"
"strings"
)

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading