Files
vikingowl 5821547a73 feat(security): close audit waves 1-4 (C1-C6, H1, H2, H4, H11, H13, H14, H16)
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.
2026-04-30 23:41:48 +02:00

107 lines
2.8 KiB
Go

package ai
import (
"context"
"errors"
"fmt"
"net"
"strings"
)
type ErrorCode int
const (
ErrInternal ErrorCode = iota
ErrRateLimited
ErrQuotaExceeded
ErrTimeout
ErrInvalidRequest
ErrUnavailable
ErrSchemaViolation
// ErrBudgetExceeded is returned by BudgetGate when today's AI spend exceeds
// the configured cap. Treated as 503 by handlers — operators should bump the
// cap or wait for the daily reset. Audit H14.
ErrBudgetExceeded
)
func (c ErrorCode) String() string {
switch c {
case ErrInternal:
return "internal"
case ErrRateLimited:
return "rate_limited"
case ErrQuotaExceeded:
return "quota_exceeded"
case ErrTimeout:
return "timeout"
case ErrInvalidRequest:
return "invalid_request"
case ErrUnavailable:
return "unavailable"
case ErrSchemaViolation:
return "schema_violation"
case ErrBudgetExceeded:
return "budget_exceeded"
default:
return "internal"
}
}
type ProviderError struct {
Code ErrorCode
Message string
Retryable bool
Inner error
RawOutput string
PromptHash string // sha256(system+"\x00"+user)[:12], set on ErrSchemaViolation
}
func (e *ProviderError) Error() string {
if e.Inner != nil {
return fmt.Sprintf("ai: %s: %s: %v", e.Code, e.Message, e.Inner)
}
return fmt.Sprintf("ai: %s: %s", e.Code, e.Message)
}
func (e *ProviderError) Unwrap() error { return e.Inner }
func ClassifyError(err error) *ProviderError {
if err == nil {
return nil
}
var pe *ProviderError
if errors.As(err, &pe) {
return pe
}
if errors.Is(err, context.DeadlineExceeded) {
return &ProviderError{Code: ErrTimeout, Message: "context deadline exceeded", Retryable: true, Inner: err}
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "429"),
strings.Contains(msg, "too many requests"),
strings.Contains(msg, "rate limit"):
return &ProviderError{Code: ErrRateLimited, Message: err.Error(), Retryable: true, Inner: err}
case strings.Contains(msg, "deadline exceeded"),
strings.Contains(msg, "timeout"):
return &ProviderError{Code: ErrTimeout, Message: err.Error(), Retryable: true, Inner: err}
case strings.Contains(msg, "connection refused"),
strings.Contains(msg, "no such host"),
isNetError(err):
return &ProviderError{Code: ErrUnavailable, Message: err.Error(), Retryable: true, Inner: err}
case strings.Contains(msg, "quota"),
strings.Contains(msg, "insufficient"):
return &ProviderError{Code: ErrQuotaExceeded, Message: err.Error(), Retryable: false, Inner: err}
case strings.Contains(msg, "400"),
strings.Contains(msg, "invalid"):
return &ProviderError{Code: ErrInvalidRequest, Message: err.Error(), Retryable: false, Inner: err}
}
return &ProviderError{Code: ErrInternal, Message: err.Error(), Retryable: false, Inner: err}
}
func isNetError(err error) bool {
var ne net.Error
return errors.As(err, &ne)
}