Implements the remediation pass described in planning/19-security-audit-2026-04-30.md. All Critical findings and the Wave 1-4 High findings are closed; PoC tests added; full backend test suite green; helm chart lints clean. Wave 1 - Auth & identity - C1 OAuth state nonce: PutOAuthState / ConsumeOAuthState (valkey, GETDEL single-use, 15min TTL); Callback rejects missing/forged/cross- provider state before token exchange. - C2 OAuth identity linking: refuse silent linking to existing user unless info.EmailVerified is true. fetchGitHubUser now consults the /user/emails endpoint for the verified flag (no more hardcoded true); fetchFacebookUser sets EmailVerified=false (FB exposes no per-email verification flag). - H1 Magic-link verify: replaced Get + MarkUsed with a single atomic UPDATE...RETURNING (ConsumeMagicLink) - TOCTOU-free. - H2 TOTP code replay: MarkTOTPCodeConsumed (valkey SET NX, 120s TTL) prevents replay of a successfully validated code; fails closed on transient store errors. - H3 Backup-code orphan: DisableTOTP now also wipes totp_backup_codes. Wave 2 - Middleware & network - C3 CORS/CSRF regex anchoring: NewCORSConfig wraps each pattern with \A...\z so substring spoofing of origins is impossible. - H4 ClientIP: server reads APP_TRUSTED_PROXIES; gin SetTrustedProxies is called explicitly (empty default = no proxy trust). - H11 Body limit + DisallowUnknownFields: BodyLimitBytes middleware (1 MiB default) wraps every request; validate.BindJSON now uses a json.Decoder with DisallowUnknownFields and rejects trailing tokens; 413 envelope on body-limit overflow. - H16 NetworkPolicy: backend.networkPolicy.enabled defaults to true; new web-networkpolicy.yaml restricts web pod ingress to nginx-gateway and egress to backend service + DNS + 443. Wave 3 - Encryption at rest - C4 TOTP secrets: CreateTOTPSecret writes encrypted secret_v2; GetTOTPSecret prefers v2 with legacy fallback. - C5 OAuth tokens: migration 000033 adds *_v2 columns; CreateOAuthAccount and UpdateOAuthTokens write encrypted; GetOAuthAccount reads v2 with legacy fallback. - M1 Domain separation: crypto.DeriveKeyFor(secret, purpose) replaces single-purpose DeriveKey; settings, totp, oauth each use a distinct HKDF-derived subkey. DeriveKey kept as back-compat alias for settings. Wave 4 - Input & AI safety - C6 SSRF: new pkg/safehttp refuses to dial RFC1918, loopback, link- local, ULA, multicast, unspecified, or cloud-metadata IPs; scheme allowlist (http/https). Wired into pkg/scrape, discovery LinkChecker, and imageURLReachable. NewForTesting opt-in for httptest. - H13 PromptGuard German + Unicode: NFKC + Cf-class strip pre-pass closes zero-width and full-width-homoglyph bypasses; new German rules for ignoriere/missachte/vergiss/role-escalation/prompt-exfil/verbatim; Gemma-style and pipe-delimited chat-template tokens covered; source-fence rule prevents '=== Quelle:' splice in scraped text. - H14 BudgetGate: new ai.BudgetGate interface; UsageRepo.CheckBudget reads today's SUM(estimated_cost_usd) (10s cache) and refuses calls when AI_DAILY_CAP_USD is exceeded; GeminiProvider.Chat checks the gate before contacting Gemini. OAuth routes remain disabled in server/routes.go, so C1/C2 are not actively reachable today; fixes ensure correctness when re-enabled.
86 lines
2.0 KiB
Go
86 lines
2.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/valkey-io/valkey-go"
|
|
|
|
"marktvogt.de/backend/internal/config"
|
|
"marktvogt.de/backend/internal/middleware"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
router *gin.Engine
|
|
http *http.Server
|
|
db *pgxpool.Pool
|
|
valkey valkey.Client
|
|
}
|
|
|
|
func New(cfg *config.Config, db *pgxpool.Pool, vk valkey.Client) *Server {
|
|
if !cfg.IsDev() {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
}
|
|
|
|
router := gin.New()
|
|
|
|
// Trust only the configured reverse-proxy CIDRs for X-Forwarded-For /
|
|
// X-Real-IP. Empty list disables proxy-header trust entirely (gin reads
|
|
// RemoteAddr) — this is the safe production default until the ingress
|
|
// pod CIDR is wired into APP_TRUSTED_PROXIES. Audit H4.
|
|
if err := router.SetTrustedProxies(cfg.App.TrustedProxies); err != nil {
|
|
slog.Warn("invalid APP_TRUSTED_PROXIES; disabling proxy trust", "error", err)
|
|
_ = router.SetTrustedProxies(nil)
|
|
}
|
|
|
|
// NewCORSConfig only errors on bad regexes; config.Load already validates them.
|
|
corsCfg, _ := middleware.NewCORSConfig(cfg.CORS.Origins, cfg.CORS.OriginPatterns)
|
|
|
|
router.Use(
|
|
middleware.Recovery(),
|
|
middleware.RequestID(),
|
|
middleware.Logging(),
|
|
middleware.CORS(corsCfg),
|
|
middleware.CSRF(corsCfg),
|
|
middleware.BodyLimitBytes(middleware.DefaultBodyLimitBytes),
|
|
middleware.RateLimit(cfg.Rate.RPS, cfg.Rate.Burst),
|
|
)
|
|
|
|
s := &Server{
|
|
cfg: cfg,
|
|
router: router,
|
|
db: db,
|
|
valkey: vk,
|
|
http: &http.Server{
|
|
Addr: cfg.Addr(),
|
|
Handler: router,
|
|
},
|
|
}
|
|
|
|
s.registerRoutes()
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *Server) Start() error {
|
|
slog.Info("starting server", "addr", s.cfg.Addr(), "env", s.cfg.App.Env)
|
|
if err := s.http.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
return fmt.Errorf("server listen: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
slog.Info("shutting down server")
|
|
return s.http.Shutdown(ctx)
|
|
}
|
|
|
|
func (s *Server) Router() *gin.Engine {
|
|
return s.router
|
|
}
|