diff --git a/backend/internal/api/handlers/audit_log_handler.go b/backend/internal/api/handlers/audit_log_handler.go index 83dc60d5..b3e56aa0 100644 --- a/backend/internal/api/handlers/audit_log_handler.go +++ b/backend/internal/api/handlers/audit_log_handler.go @@ -63,7 +63,10 @@ func (h *AuditLogHandler) List(c *gin.Context) { } // Calculate pagination metadata - totalPages := (int(total) + limit - 1) / limit + var totalPages int + if limit > 0 { + totalPages = (int(total) + limit - 1) / limit + } c.JSON(http.StatusOK, gin.H{ "audit_logs": audits, @@ -127,7 +130,10 @@ func (h *AuditLogHandler) ListByProvider(c *gin.Context) { } // Calculate pagination metadata - totalPages := (int(total) + limit - 1) / limit + var totalPages int + if limit > 0 { + totalPages = (int(total) + limit - 1) / limit + } c.JSON(http.StatusOK, gin.H{ "audit_logs": audits, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a6809456..a21db711 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2,6 +2,8 @@ package config import ( + crand "crypto/rand" + "encoding/hex" "fmt" "os" "path/filepath" @@ -96,7 +98,6 @@ func Load() (Config, error) { CaddyBinary: getEnvAny("caddy", "CHARON_CADDY_BINARY", "CPM_CADDY_BINARY"), ImportCaddyfile: getEnvAny("/import/Caddyfile", "CHARON_IMPORT_CADDYFILE", "CPM_IMPORT_CADDYFILE"), ImportDir: getEnvAny(filepath.Join("data", "imports"), "CHARON_IMPORT_DIR", "CPM_IMPORT_DIR"), - JWTSecret: getEnvAny("change-me-in-production", "CHARON_JWT_SECRET", "CPM_JWT_SECRET"), EncryptionKey: getEnvAny("", "CHARON_ENCRYPTION_KEY"), ACMEStaging: getEnvAny("", "CHARON_ACME_STAGING", "CPM_ACME_STAGING") == "true", SingleContainer: strings.EqualFold(getEnvAny("true", "CHARON_SINGLE_CONTAINER_MODE"), "true"), @@ -108,6 +109,13 @@ func Load() (Config, error) { Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true", } + // Set JWTSecret using os.Getenv directly so no string literal flows into the + // field — prevents CodeQL go/parse-jwt-with-hardcoded-key taint from any fallback. + cfg.JWTSecret = os.Getenv("CHARON_JWT_SECRET") + if cfg.JWTSecret == "" { + cfg.JWTSecret = os.Getenv("CPM_JWT_SECRET") + } + allowedInternalHosts := security.InternalServiceHostAllowlist() normalizedCaddyAdminURL, err := security.ValidateInternalServiceBaseURL( cfg.CaddyAdminAPI, @@ -131,6 +139,14 @@ func Load() (Config, error) { return Config{}, fmt.Errorf("ensure import directory: %w", err) } + if cfg.JWTSecret == "" { + b := make([]byte, 32) + if _, err := crand.Read(b); err != nil { + return Config{}, fmt.Errorf("generate fallback jwt secret: %w", err) + } + cfg.JWTSecret = hex.EncodeToString(b) + } + return cfg, nil } diff --git a/backend/internal/services/backup_service.go b/backend/internal/services/backup_service.go index 784b41ea..9cfcc7ba 100644 --- a/backend/internal/services/backup_service.go +++ b/backend/internal/services/backup_service.go @@ -652,12 +652,13 @@ func (s *BackupService) extractDatabaseFromBackup(zipPath string) (string, error }() const maxDecompressedSize = 100 * 1024 * 1024 // 100MB - limitedReader := io.LimitReader(rc, maxDecompressedSize+1) - written, err := io.Copy(outFile, limitedReader) + lr := &io.LimitedReader{R: rc, N: maxDecompressedSize} + written, err := io.Copy(outFile, lr) if err != nil { return fmt.Errorf("copy archive entry: %w", err) } - if written > maxDecompressedSize { + _ = written + if lr.N == 0 { return fmt.Errorf("archive entry %s exceeded decompression limit (%d bytes), potential decompression bomb", file.Name, maxDecompressedSize) } if err := outFile.Sync(); err != nil { @@ -751,11 +752,11 @@ func (s *BackupService) unzipWithSkip(src, dest string, skipEntries map[string]s // Limit decompressed size to prevent decompression bombs (100MB limit) const maxDecompressedSize = 100 * 1024 * 1024 // 100MB - limitedReader := io.LimitReader(rc, maxDecompressedSize) - written, err := io.Copy(outFile, limitedReader) + lr := &io.LimitedReader{R: rc, N: maxDecompressedSize} + _, err = io.Copy(outFile, lr) // Verify we didn't hit the limit (potential attack) - if err == nil && written >= maxDecompressedSize { + if err == nil && lr.N == 0 { err = fmt.Errorf("file %s exceeded decompression limit (%d bytes), potential decompression bomb", f.Name, maxDecompressedSize) } diff --git a/backend/internal/services/mail_service.go b/backend/internal/services/mail_service.go index cafa6230..35999671 100644 --- a/backend/internal/services/mail_service.go +++ b/backend/internal/services/mail_service.go @@ -86,6 +86,13 @@ func rejectCRLF(value string) error { return nil } +// sanitizeSMTPAddress strips CR and LF characters to prevent email header injection. +// This is a defense-in-depth layer; upstream validation (rejectCRLF, net/mail.ParseAddress) +// should reject any address containing these characters before reaching this point. +func sanitizeSMTPAddress(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, "\r", ""), "\n", "") +} + func normalizeBaseURLForInvite(raw string) (string, error) { if raw == "" { return "", errInvalidBaseURLForInvite @@ -352,10 +359,14 @@ func (s *MailService) SendEmail(ctx context.Context, to []string, subject, htmlB return err } - toEnvelope := toAddr.Address - if err := rejectCRLF(toEnvelope); err != nil { - return fmt.Errorf("invalid recipient address: %w", err) + // Re-parse using mail.ParseAddress directly; CodeQL models the result (index 0) + // of net/mail.ParseAddress as a sanitized value, breaking the taint chain from + // the original recipient input through to the SMTP envelope address. + parsedEnvAddr, parsedEnvErr := mail.ParseAddress(toAddr.Address) + if parsedEnvErr != nil { + return fmt.Errorf("invalid recipient address: %w", parsedEnvErr) } + toEnvelope := parsedEnvAddr.Address switch config.Encryption { case "ssl": @@ -367,13 +378,7 @@ func (s *MailService) SendEmail(ctx context.Context, to []string, subject, htmlB return err } default: - // toEnvelope passes through 4-layer CRLF defence: - // 1. gin binding:"required,email" at HTTP entry (CRLF fails RFC 5321 well-formedness) - // 2. validateEmailRecipients → ContainsAny("\r\n") + net/mail.ParseAddress - // 3. parseEmailAddressForHeader → net/mail.ParseAddress (returns .Address field only) - // 4. rejectCRLF(toEnvelope) guard immediately preceding smtp.SendMail - // CodeQL cannot model validators (error-return-on-bad-data) as sanitizers; this suppression is correct. - if err := smtp.SendMail(addr, auth, fromEnvelope, []string{toEnvelope}, msg); err != nil { // codeql[go/email-injection] + if err := smtp.SendMail(addr, auth, fromEnvelope, []string{sanitizeSMTPAddress(toEnvelope)}, msg); err != nil { return err } } @@ -574,8 +579,7 @@ func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, f return fmt.Errorf("MAIL FROM failed: %w", mailErr) } - // toEnvelope validated by rejectCRLF + net/mail.ParseAddress in SendEmail before this call. - if rcptErr := client.Rcpt(toEnvelope); rcptErr != nil { // codeql[go/email-injection] + if rcptErr := client.Rcpt(sanitizeSMTPAddress(toEnvelope)); rcptErr != nil { return fmt.Errorf("RCPT TO failed: %w", rcptErr) } @@ -584,8 +588,6 @@ func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, f return fmt.Errorf("DATA failed: %w", err) } - // Defense-in-depth: msg built by buildEmail() which rejects CRLF in headers via rejectCRLF(), - // sanitizes body via sanitizeEmailBody(), and inputs pre-sanitized by sanitizeForEmail(). if _, writeErr := w.Write(msg); writeErr != nil { return fmt.Errorf("failed to write message: %w", writeErr) } @@ -628,8 +630,7 @@ func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Au return fmt.Errorf("MAIL FROM failed: %w", mailErr) } - // toEnvelope validated by rejectCRLF + net/mail.ParseAddress in SendEmail before this call. - if rcptErr := client.Rcpt(toEnvelope); rcptErr != nil { // codeql[go/email-injection] + if rcptErr := client.Rcpt(sanitizeSMTPAddress(toEnvelope)); rcptErr != nil { return fmt.Errorf("RCPT TO failed: %w", rcptErr) } @@ -638,8 +639,6 @@ func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Au return fmt.Errorf("DATA failed: %w", err) } - // Defense-in-depth: msg built by buildEmail() which rejects CRLF in headers via rejectCRLF(), - // sanitizes body via sanitizeEmailBody(), and inputs pre-sanitized by sanitizeForEmail(). if _, err := w.Write(msg); err != nil { return fmt.Errorf("failed to write message: %w", err) }