5821547a73
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.
107 lines
2.8 KiB
Go
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)
|
|
}
|