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
29 changes: 29 additions & 0 deletions .env.example
Comment thread
smallwat3r marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copy this file to .env and fill in the values before running docker-compose.

# ── Server ────────────────────────────────────────────────────────────────────
PORT=8080
SHUTDOWN_TIMEOUT=5s

# ── Redis ─────────────────────────────────────────────────────────────────────
# Password used by docker-compose to configure the Redis instance AND embedded
# into REDIS_URL below. Generate a strong value: openssl rand -hex 32
REDIS_PASSWORD=change-me

# Full Redis connection URL. Must include the password set above.
REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:6379/0
REDIS_POOL_SIZE=10
REDIS_MIN_IDLE=2

# ── Security ──────────────────────────────────────────────────────────────────
# Canonical hostname for HTTPS redirects (prevents open redirect via spoofed
# Host header). Set to the public hostname of your deployment.
# Example: CANONICAL_HOST=secretapi.example.com
CANONICAL_HOST=

# CIDR range of your trusted reverse proxy. X-Real-IP / X-Forwarded-For headers
# are only trusted when the connection comes from within this range.
# Example: TRUSTED_PROXY_CIDR=10.0.0.0/8
TRUSTED_PROXY_CIDR=

# Set to 1 to disable HTTPS enforcement and HSTS (development only).
NO_HTTPS=
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.24
go-version: 1.26
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
- name: Vulnerability scan
run: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./...
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frontend build
FROM node:20-alpine AS frontend-builder
FROM node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 AS frontend-builder
Comment thread
smallwat3r marked this conversation as resolved.

WORKDIR /src/web
COPY web/package.json web/package-lock.json* ./
Expand All @@ -8,7 +8,7 @@ COPY web .
RUN npm run build

# backend build
FROM golang:1.24-alpine AS builder
FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder

ENV CGO_ENABLED=0 GOOS=linux GO111MODULE=on

Expand All @@ -31,7 +31,7 @@ RUN go build -trimpath -mod=readonly -buildvcs=false -ldflags="-s -w" \
-o /out/secret-api ./cmd/server

# runtime
FROM gcr.io/distroless/base:nonroot
FROM gcr.io/distroless/base:nonroot@sha256:746b9dbe3065a124395d4a7698241dbd6f3febbf01b73e48f942aabd7b8e5eac

WORKDIR /app

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ Environment variables:
| `REDIS_MIN_IDLE` | `2` | Minimum idle Redis connections |
| `SHUTDOWN_TIMEOUT` | `5s` | Graceful shutdown timeout |
| `NO_HTTPS` | (unset) | Set to `1` to disable HTTPS enforcement (for development) |
| `CANONICAL_HOST` | (unset) | Canonical hostname for HTTPS redirects; prevents open redirect via a spoofed `Host` header. Example: `secretapi.example.com` |
| `TRUSTED_PROXY_CIDR` | (unset) | CIDR range of your trusted reverse proxy. `X-Real-IP`/`X-Forwarded-For` headers are only trusted from this range. Example: `10.0.0.0/8` |
| `REDIS_PASSWORD` | (unset) | Redis password. Used by `docker-compose` to configure Redis and embedded in `REDIS_URL` (`redis://:password@host:port/db`). Not read directly by the Go binary. |

## Usage

Expand Down
8 changes: 6 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,14 @@ func main() {
handler := app.NewHandler(repo, cfg.DefaultTheme)

secCfg := app.SecurityHeadersConfig{
RequireHTTPS: cfg.RequireHTTPS,
RequireHTTPS: cfg.RequireHTTPS,
CanonicalHost: cfg.CanonicalHost,
}

router := app.NewRouter(handler, rdb, secCfg)
rlCfg := app.DefaultRateLimitConfig()
rlCfg.TrustedProxyCIDR = cfg.TrustedProxyCIDR

router := app.NewRouter(handler, rdb, secCfg, rlCfg)

srv := &http.Server{
Addr: cfg.ListenAddr(),
Expand Down
7 changes: 4 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ services:
restart: unless-stopped
environment:
PORT: 8080
REDIS_URL: redis://redis:6379/0
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
NO_HTTPS: 1 # Disable HTTPS enforcement for local development
depends_on:
redis:
Expand All @@ -28,9 +28,10 @@ services:
restart: unless-stopped
volumes:
- redis_data:/data
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning",
"--requirepass", "${REDIS_PASSWORD}"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
Expand Down
8 changes: 3 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
module github.com/smallwat3r/secretapi

go 1.24.0

toolchain go1.24.9
go 1.26

require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/chi/v5 v5.2.4
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.6.1
github.com/redis/go-redis/v9 v9.6.3
golang.org/x/crypto v0.27.0
)

Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/redis/go-redis/v9 v9.6.3 h1:8Dr5ygF1QFXRxIH/m3Xg9MMG1rS8YCtAgosrsewT6i0=
github.com/redis/go-redis/v9 v9.6.3/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
Expand Down
83 changes: 60 additions & 23 deletions internal/app/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
Expand Down Expand Up @@ -41,7 +42,8 @@ func ContentLengthValidator(maxSize int64) func(http.Handler) http.Handler {

// SecurityHeadersConfig holds configuration for security headers middleware.
type SecurityHeadersConfig struct {
RequireHTTPS bool
RequireHTTPS bool
CanonicalHost string // used for HTTPS redirects; falls back to r.Host if empty
}

// SecurityHeaders adds security-related HTTP headers to responses.
Expand All @@ -54,8 +56,13 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler
// Check if request is over HTTPS (direct TLS or via proxy)
isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
if !isHTTPS {
// Redirect HTTP to HTTPS
target := "https://" + r.Host + r.URL.RequestURI()
// Use configured canonical host to prevent open redirect via
// attacker-controlled Host header.
host := cfg.CanonicalHost
if host == "" {
host = r.Host
}
target := "https://" + host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
Expand All @@ -72,7 +79,7 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy
csp := "default-src 'self'; script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"style-src 'self'; " +
"img-src 'self' data:; font-src 'self'; connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
w.Header().Set("Content-Security-Policy", csp)
Expand All @@ -89,9 +96,10 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler

// RateLimitConfig holds configuration for rate limiting.
type RateLimitConfig struct {
PostLimit int // max POST requests per window
GetLimit int // max GET requests per window
Window time.Duration // time window for rate limiting
PostLimit int // max POST requests per window
GetLimit int // max GET requests per window
Window time.Duration // time window for rate limiting
TrustedProxyCIDR string // CIDR from which X-Real-IP/X-Forwarded-For are trusted
}

// DefaultRateLimitConfig returns sensible default rate limits.
Expand All @@ -103,21 +111,44 @@ func DefaultRateLimitConfig() RateLimitConfig {
}
}

// stripPort removes the port from an addr of the form "host:port".
func stripPort(addr string) string {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}

// ipInCIDR reports whether addr (host or host:port) falls within the given CIDR.
func ipInCIDR(addr, cidr string) bool {
ip := net.ParseIP(stripPort(addr))
if ip == nil {
return false
}
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return network.Contains(ip)
}

// RateLimiterMiddleware uses Redis for distributed rate limiting.
type RateLimiterMiddleware struct {
rdb *redis.Client
postLimit int
getLimit int
window time.Duration
rdb *redis.Client
postLimit int
getLimit int
window time.Duration
trustedProxyCIDR string
}

// NewRateLimiter creates a new Redis-based rate limiter middleware.
func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiterMiddleware {
return &RateLimiterMiddleware{
rdb: rdb,
postLimit: cfg.PostLimit,
getLimit: cfg.GetLimit,
window: cfg.Window,
rdb: rdb,
postLimit: cfg.PostLimit,
getLimit: cfg.GetLimit,
window: cfg.Window,
trustedProxyCIDR: cfg.TrustedProxyCIDR,
}
}

Expand All @@ -130,14 +161,20 @@ func (m *RateLimiterMiddleware) Handler(next http.Handler) http.Handler {
return
}

ip := r.RemoteAddr
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
ip = realIP
} else if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
if idx := strings.Index(forwardedFor, ","); idx != -1 {
ip = strings.TrimSpace(forwardedFor[:idx])
} else {
ip = strings.TrimSpace(forwardedFor)
// Only trust proxy headers when the request originates from within the
// configured trusted CIDR. Without this guard a client that bypasses
// the reverse proxy can spoof X-Real-IP / X-Forwarded-For and rotate
// IPs freely to defeat rate limiting.
ip := stripPort(r.RemoteAddr)
if m.trustedProxyCIDR != "" && ipInCIDR(r.RemoteAddr, m.trustedProxyCIDR) {
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
ip = realIP
} else if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
if idx := strings.Index(forwardedFor, ","); idx != -1 {
ip = strings.TrimSpace(forwardedFor[:idx])
} else {
ip = strings.TrimSpace(forwardedFor)
}
}
}

Expand Down
Loading