check / check (push) Waiting to run
Every answer carries the six security headers REPO_POLICIES.md lists. The Content-Security-Policy allows inline content only with a nonce made for that answer; the pages put it on their style and script blocks and drop their four style attributes. The request log and /ip/ take a forwarded address only from a proxy listed in TRUSTED_PROXIES, which stops the start when set but invalid: the last X-Forwarded-For entry that is not a trusted proxy. X-Real-IP and True-Client-IP are no longer read. The JSON routes and the health check send fixed messages in place of database, parser and resolver errors, and log the detail. Model: opus-5-5
409 lines
12 KiB
Go
409 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// responseWriter wraps http.ResponseWriter to capture the response
|
|
type responseWriter struct {
|
|
http.ResponseWriter
|
|
|
|
body *bytes.Buffer
|
|
statusCode int
|
|
written bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (rw *responseWriter) Write(b []byte) (int, error) {
|
|
rw.mu.Lock()
|
|
defer rw.mu.Unlock()
|
|
|
|
if !rw.written {
|
|
rw.written = true
|
|
}
|
|
|
|
return rw.body.Write(b)
|
|
}
|
|
|
|
func (rw *responseWriter) WriteHeader(statusCode int) {
|
|
rw.mu.Lock()
|
|
defer rw.mu.Unlock()
|
|
|
|
if !rw.written {
|
|
rw.statusCode = statusCode
|
|
rw.written = true
|
|
}
|
|
}
|
|
|
|
func (rw *responseWriter) Header() http.Header {
|
|
return rw.ResponseWriter.Header()
|
|
}
|
|
|
|
// JSONResponseMiddleware is an HTTP middleware that wraps all JSON responses
|
|
// with a @meta field containing execution metadata. The metadata includes the
|
|
// time zone (always UTC), API version, and request execution time in milliseconds.
|
|
//
|
|
// Endpoints "/" and "/status" are excluded from this processing and passed through
|
|
// unchanged. Non-JSON responses and empty responses are also passed through unchanged.
|
|
func JSONResponseMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Skip non-JSON endpoints
|
|
if r.URL.Path == "/" || r.URL.Path == "/status" {
|
|
next.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
// Create a custom response writer to capture the response
|
|
rw := &responseWriter{
|
|
ResponseWriter: w,
|
|
body: &bytes.Buffer{},
|
|
statusCode: http.StatusOK,
|
|
}
|
|
|
|
// Serve the request
|
|
next.ServeHTTP(rw, r)
|
|
|
|
// Calculate execution time
|
|
execTime := time.Since(startTime)
|
|
|
|
// Only process JSON responses
|
|
contentType := rw.Header().Get("Content-Type")
|
|
if contentType != "application/json" || rw.body.Len() == 0 {
|
|
// Write the original response
|
|
w.WriteHeader(rw.statusCode)
|
|
_, _ = w.Write(rw.body.Bytes())
|
|
|
|
return
|
|
}
|
|
|
|
// Parse the original response
|
|
var originalResponse map[string]any
|
|
|
|
err := json.Unmarshal(rw.body.Bytes(), &originalResponse)
|
|
if err != nil {
|
|
// If we can't parse it, just write original
|
|
w.WriteHeader(rw.statusCode)
|
|
_, _ = w.Write(rw.body.Bytes())
|
|
|
|
return
|
|
}
|
|
|
|
// Add @meta field
|
|
originalResponse["@meta"] = map[string]any{
|
|
"time_zone": "UTC",
|
|
"api_version": 1,
|
|
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
|
|
}
|
|
|
|
// Write the enhanced response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(rw.statusCode)
|
|
_ = json.NewEncoder(w).Encode(originalResponse)
|
|
})
|
|
}
|
|
|
|
// requestTimeoutMessage is the error message sent with 504 Gateway Timeout.
|
|
const requestTimeoutMessage = "Request timeout"
|
|
|
|
// internalErrorMessage is the error message sent with 500 Internal Server
|
|
// Error, whatever went wrong; the handler logs the detail.
|
|
const internalErrorMessage = "Internal server error"
|
|
|
|
// deadlinePassed reports whether ctx ended because its deadline passed.
|
|
//
|
|
// A handler calls it right after each database read or host name lookup,
|
|
// before it looks at what came back, error or not, and answers 504 if it
|
|
// reports true. A read cut off by the time limit can return an error, or part
|
|
// of its rows or none of them without one, so only this check tells a cut-off
|
|
// read from a complete one.
|
|
func deadlinePassed(ctx context.Context) bool {
|
|
return errors.Is(ctx.Err(), context.DeadlineExceeded)
|
|
}
|
|
|
|
// JSONValidationMiddleware is an HTTP middleware that validates JSON API responses.
|
|
// It ensures that responses with Content-Type "application/json" contain valid JSON.
|
|
//
|
|
// If a response is not valid JSON or is empty when JSON is expected, the middleware
|
|
// returns a properly formatted JSON error response. If the request's time limit
|
|
// has passed, it answers 504 with the error message "Request timeout". For other
|
|
// errors, it returns "Internal server error" with status 500 if the original
|
|
// status was 200.
|
|
//
|
|
// Non-JSON responses are passed through unchanged.
|
|
func JSONValidationMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Create a custom response writer to capture the response
|
|
rw := &responseWriter{
|
|
ResponseWriter: w,
|
|
body: &bytes.Buffer{},
|
|
statusCode: http.StatusOK,
|
|
}
|
|
|
|
// Serve the request
|
|
next.ServeHTTP(rw, r)
|
|
|
|
// Check if it's meant to be a JSON response
|
|
contentType := rw.Header().Get("Content-Type")
|
|
isJSON := contentType == "application/json" || contentType == ""
|
|
|
|
// If it's not JSON or has content, pass through
|
|
if !isJSON && rw.body.Len() > 0 {
|
|
w.WriteHeader(rw.statusCode)
|
|
_, _ = w.Write(rw.body.Bytes())
|
|
|
|
return
|
|
}
|
|
|
|
// For JSON responses, validate the JSON
|
|
if rw.body.Len() > 0 {
|
|
var testParse any
|
|
|
|
err := json.Unmarshal(rw.body.Bytes(), &testParse)
|
|
if err == nil {
|
|
// Valid JSON, write it out
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(rw.statusCode)
|
|
_, _ = w.Write(rw.body.Bytes())
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
// If we get here, either there's no body or invalid JSON
|
|
// Write a proper error response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Determine appropriate status code
|
|
statusCode := rw.statusCode
|
|
if statusCode == http.StatusOK {
|
|
statusCode = http.StatusInternalServerError
|
|
}
|
|
|
|
if deadlinePassed(r.Context()) {
|
|
statusCode = http.StatusGatewayTimeout
|
|
}
|
|
|
|
w.WriteHeader(statusCode)
|
|
|
|
errorMsg := internalErrorMessage
|
|
if statusCode == http.StatusGatewayTimeout {
|
|
errorMsg = requestTimeoutMessage
|
|
}
|
|
|
|
response := map[string]any{
|
|
jsonKeyStatus: jsonStatusError,
|
|
jsonKeyError: map[string]any{
|
|
jsonKeyMsg: errorMsg,
|
|
jsonKeyCode: statusCode,
|
|
},
|
|
}
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
})
|
|
}
|
|
|
|
// statusWriter wraps http.ResponseWriter to capture the status code
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
|
|
statusCode int
|
|
written bool
|
|
}
|
|
|
|
func (sw *statusWriter) WriteHeader(statusCode int) {
|
|
if !sw.written {
|
|
sw.statusCode = statusCode
|
|
sw.written = true
|
|
}
|
|
|
|
sw.ResponseWriter.WriteHeader(statusCode)
|
|
}
|
|
|
|
func (sw *statusWriter) Write(b []byte) (int, error) {
|
|
if !sw.written {
|
|
sw.statusCode = http.StatusOK
|
|
sw.written = true
|
|
}
|
|
|
|
return sw.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// RequestLoggerMiddleware creates a structured logging middleware using slog.
|
|
func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
|
|
// Wrap response writer to capture status
|
|
sw := &statusWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
|
|
|
// Log request start
|
|
logger.Debug("HTTP request started",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"remote_addr", r.RemoteAddr,
|
|
"user_agent", r.UserAgent(),
|
|
)
|
|
|
|
// Serve the request
|
|
next.ServeHTTP(sw, r)
|
|
|
|
// Log request completion
|
|
duration := time.Since(start)
|
|
logLevel := slog.LevelInfo
|
|
|
|
// Slow query threshold (1 second)
|
|
const slowQueryThreshold = 1 * time.Second
|
|
|
|
switch {
|
|
case sw.statusCode >= http.StatusInternalServerError:
|
|
logLevel = slog.LevelError
|
|
case sw.statusCode >= http.StatusBadRequest:
|
|
logLevel = slog.LevelWarn
|
|
case duration >= slowQueryThreshold:
|
|
logLevel = slog.LevelWarn
|
|
}
|
|
|
|
logger.Log(r.Context(), logLevel, "HTTP request completed",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", sw.statusCode,
|
|
"duration_ms", duration.Milliseconds(),
|
|
"remote_addr", r.RemoteAddr,
|
|
"slow", duration >= slowQueryThreshold,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// DecodedPathMiddleware makes the router match routes against the decoded
|
|
// path, so that the handlers read their URL parameters decoded. Without it,
|
|
// chi matches against the escaped path whenever the request has one and hands
|
|
// the handlers their parameters still escaped: /ip/2001%3Adb8%3A%3A1 would
|
|
// look up "2001%3Adb8%3A%3A1".
|
|
func DecodedPathMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
chi.RouteContext(r.Context()).RoutePath = r.URL.Path
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// The values of the headers SecurityHeadersMiddleware sets.
|
|
const (
|
|
// strictTransportSecurity tells browsers to reach this host and its
|
|
// subdomains only over HTTPS for a year. The server listens on plain
|
|
// HTTP behind a TLS-terminating proxy, which passes the header on.
|
|
strictTransportSecurity = "max-age=31536000; includeSubDomains"
|
|
|
|
// contentSecurityPolicy lets a page load content only from this server,
|
|
// and run or apply an inline script or style block only when the block
|
|
// carries the nonce made for that answer, which replaces %[1]s. No other
|
|
// site may frame the pages.
|
|
contentSecurityPolicy = "default-src 'self'; " +
|
|
"script-src 'nonce-%[1]s'; style-src 'nonce-%[1]s'; " +
|
|
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
|
|
|
frameOptions = "DENY"
|
|
contentTypeOptions = "nosniff"
|
|
referrerPolicy = "strict-origin-when-cross-origin"
|
|
|
|
// permissionsPolicy turns off browser features the pages do not use.
|
|
permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), " +
|
|
"gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
|
)
|
|
|
|
// cspNonceKey is the request context key of the nonce SecurityHeadersMiddleware
|
|
// made for the answer.
|
|
type cspNonceKey struct{}
|
|
|
|
// SecurityHeadersMiddleware sets the security headers REPO_POLICIES.md requires
|
|
// on every answer. Each answer's Content-Security-Policy names a nonce of its
|
|
// own; a page reads it with cspNonce and writes it on its script and style
|
|
// blocks.
|
|
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nonce := rand.Text()
|
|
|
|
header := w.Header()
|
|
header.Set("Strict-Transport-Security", strictTransportSecurity)
|
|
header.Set("Content-Security-Policy",
|
|
fmt.Sprintf(contentSecurityPolicy, nonce))
|
|
header.Set("X-Frame-Options", frameOptions)
|
|
header.Set("X-Content-Type-Options", contentTypeOptions)
|
|
header.Set("Referrer-Policy", referrerPolicy)
|
|
header.Set("Permissions-Policy", permissionsPolicy)
|
|
|
|
ctx := context.WithValue(r.Context(), cspNonceKey{}, nonce)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// cspNonce returns the nonce SecurityHeadersMiddleware made for the answer to
|
|
// the request with ctx.
|
|
func cspNonce(ctx context.Context) string {
|
|
nonce, _ := ctx.Value(cspNonceKey{}).(string)
|
|
|
|
return nonce
|
|
}
|
|
|
|
// RealIPMiddleware gives a request from one of trustedProxies the address of
|
|
// the client the proxies forwarded it for as its RemoteAddr: the last
|
|
// X-Forwarded-For entry that is not a trusted proxy. Each proxy appends the
|
|
// address it took the request from, so the entries left of that one are
|
|
// whatever the client sent. A request from any other peer keeps the peer's
|
|
// address, whatever headers it carries.
|
|
func RealIPMiddleware(trustedProxies []netip.Prefix) func(http.Handler) http.Handler {
|
|
isTrusted := func(addr netip.Addr) bool {
|
|
return slices.ContainsFunc(trustedProxies, func(proxy netip.Prefix) bool {
|
|
return proxy.Contains(addr.Unmap())
|
|
})
|
|
}
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
peer, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
if err != nil || !isTrusted(peer.Addr()) {
|
|
next.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
// A header sent on several lines is one list.
|
|
entries := strings.Split(
|
|
strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
|
|
|
|
for _, entry := range slices.Backward(entries) {
|
|
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
r.RemoteAddr = addr.String()
|
|
|
|
if !isTrusted(addr) {
|
|
break
|
|
}
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|