fix: improve pagination handling and prevent decompression bombs in backup service
fix: enhance JWT secret management to avoid hardcoded values and ensure security feat: add SMTP address sanitization to prevent email header injection vulnerabilities
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user