Files
pad/internal/server/middleware_security.go
T
xarmian a2eaac4a37 fix(server): reject CORS wildcard when credentials are on (TASK-664) (#188)
PAD_CORS_ORIGINS accepted any string (including '*') while the CORS
middleware ran with AllowCredentials=true unconditionally. Browsers
refuse the combination per the Fetch spec, so a typo like
PAD_CORS_ORIGINS=* "worked" in curl but failed silently from every
real browser — and without an explicit carve-out, an anon cross-origin
fetch still rode the victim's cookies when origins were empty.

- parseCORSOrigins: explicitly drop '*' with a log warning. When '*'
  was the ONLY configured origin, fall back to localhost defaults
  rather than producing an empty allowlist.
- corsAllowCredentials: new helper — AllowCredentials=true only when
  an operator has set PAD_CORS_ORIGINS. Default false keeps a browser
  on a different origin from piggy-backing cookies on the user's
  session when no remote origin was expected in the first place.
- server.go: wire up corsAllowCredentials(s.corsOrigins) into the
  cors.Options.

Tests:
- TestParseCORSOrigins gains three '*'-handling cases (lone '*',
  mixed, trailing '*').
- TestCorsAllowCredentials covers empty/whitespace default, explicit
  origins, and tab-only input.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-21 23:59:54 -04:00

105 lines
3.9 KiB
Go

package server
import (
"crypto/rand"
"encoding/base64"
"log/slog"
"net/http"
"strings"
)
// SecurityHeaders adds standard security headers to all responses.
// These protect against common web vulnerabilities like XSS, clickjacking,
// and MIME type sniffing.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Prevent the browser from MIME-sniffing the content type
h.Set("X-Content-Type-Options", "nosniff")
// Prevent the page from being embedded in frames (clickjacking protection)
h.Set("X-Frame-Options", "DENY")
// Control referrer information sent with requests
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Restrict browser features the app doesn't need
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// CSP: strict policy for API responses. HTML pages served by spaHandler
// override this with a nonce-based script-src for SvelteKit inline scripts.
//
// script-src-attr 'none' blocks inline event handlers (onerror=, onclick=,
// onload=, …). Those bypass script-src per the CSP spec, so without this
// directive an attacker who slips markup past the sanitizer can still
// execute JS via event attributes. Defense-in-depth for the comment-XSS
// fix (TASK-647) and for any future sanitizer regression.
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
// StrictTransportSecurity adds HSTS header when secure cookies are enabled
// (indicating the server is behind TLS).
func StrictTransportSecurity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
// generateCSPNonce generates a cryptographically random nonce for
// Content-Security-Policy headers. Returns a 16-byte base64-encoded string.
func generateCSPNonce() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}
// parseCORSOrigins parses a comma-separated list of origins into a slice.
// Returns default localhost origins if the input is empty.
//
// The '*' wildcard is explicitly dropped (with a log warning): the CORS
// middleware is configured with AllowCredentials=true, and per the Fetch
// spec browsers refuse to honor `Access-Control-Allow-Origin: *` when
// credentials are being sent. Rejecting wildcards here prevents an
// operator misconfiguration (e.g. `PAD_CORS_ORIGINS=*`) from looking
// like it works in curl but failing silently in every real browser.
func parseCORSOrigins(origins string) []string {
if origins == "" {
return []string{"http://localhost:*", "http://127.0.0.1:*"}
}
var result []string
for _, origin := range strings.Split(origins, ",") {
origin = strings.TrimSpace(origin)
if origin == "" {
continue
}
if origin == "*" {
slog.Warn("PAD_CORS_ORIGINS: dropping '*' — incompatible with AllowCredentials=true",
"hint", "list specific origins instead, e.g. https://pad.example.com")
continue
}
result = append(result, origin)
}
if len(result) == 0 {
return []string{"http://localhost:*", "http://127.0.0.1:*"}
}
return result
}
// corsAllowCredentials reports whether the CORS middleware should set
// Access-Control-Allow-Credentials: true. The current CLI tooling uses
// Bearer tokens rather than cookies for cross-origin calls, so when no
// operator-configured origins are present we default to false — keeping
// a browser on a different origin from piggy-backing user cookies on
// its fetches. When operators provide explicit origins they opt into
// credential sharing.
func corsAllowCredentials(corsOrigins string) bool {
return strings.TrimSpace(corsOrigins) != ""
}