From dc68043c92cd9494b81c659d8a692046683a54a6 Mon Sep 17 00:00:00 2001 From: Jairus Tanaka Date: Tue, 7 Jul 2026 15:32:06 -0700 Subject: [PATCH] fix(security): SMTP header-injection guard, cap installs ?days=, harden state-cookie clear --- backend/internal/api/auth.go | 2 +- backend/internal/auth/session.go | 9 +++++++++ backend/internal/email/email.go | 12 ++++++++++++ backend/internal/store/jsonstore.go | 3 +++ backend/internal/store/pebblestore.go | 3 +++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 4a70c49..b60845a 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -65,7 +65,7 @@ func (a *App) handleCallback(w http.ResponseWriter, r *http.Request) { fail("store") return } - http.SetCookie(w, &http.Cookie{Name: auth.StateCookieName, Path: "/", MaxAge: -1}) + http.SetCookie(w, a.Sessions.ClearStateCookie()) // CLI login: mint an API token and hand it back to the CLI's loopback // listener instead of setting a browser session. diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go index c974b9d..b0d960e 100644 --- a/backend/internal/auth/session.go +++ b/backend/internal/auth/session.go @@ -181,6 +181,15 @@ func (s *Sessions) ClearCLICookie() *http.Cookie { return &http.Cookie{Name: CLICookieName, Path: "/", MaxAge: -1, Secure: !s.devMode, HttpOnly: true} } +// ClearStateCookie expires the OAuth state cookie with the same security flags +// it was set with (HttpOnly, Secure in prod, SameSite=Lax). +func (s *Sessions) ClearStateCookie() *http.Cookie { + return &http.Cookie{ + Name: StateCookieName, Path: "/", MaxAge: -1, + Secure: !s.devMode, HttpOnly: true, SameSite: http.SameSiteLaxMode, + } +} + // VerifyState checks the OAuth state cookie against the state query parameter. func (s *Sessions) VerifyState(r *http.Request, state string) bool { c, err := r.Cookie(StateCookieName) diff --git a/backend/internal/email/email.go b/backend/internal/email/email.go index ce34c50..d7d27e7 100644 --- a/backend/internal/email/email.go +++ b/backend/internal/email/email.go @@ -5,11 +5,17 @@ package email import ( + "errors" "fmt" "log" "net/smtp" + "strings" ) +// errHeaderInjection is returned when a header field contains a newline, which +// could otherwise inject additional SMTP headers. +var errHeaderInjection = errors.New("email: header field contains a newline") + // Config holds the SMTP settings, all sourced from the environment. type Config struct { Host string @@ -41,6 +47,12 @@ func (s *Sender) Send(to, subject, body string) (sent bool, err error) { if from == "" { from = s.cfg.User } + // Guard against SMTP header injection: a CR/LF in any header field (notably + // the user-supplied recipient) would let an attacker inject extra headers. + if strings.ContainsAny(to, "\r\n") || strings.ContainsAny(subject, "\r\n") || + strings.ContainsAny(from, "\r\n") { + return false, errHeaderInjection + } msg := []byte(fmt.Sprintf( "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\n"+ "Content-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", diff --git a/backend/internal/store/jsonstore.go b/backend/internal/store/jsonstore.go index e1e7245..3077df9 100644 --- a/backend/internal/store/jsonstore.go +++ b/backend/internal/store/jsonstore.go @@ -552,6 +552,9 @@ func (s *JSONStore) InstallSeries(short string, sinceDays int) []InstallPoint { if sinceDays <= 0 { sinceDays = 90 } + if sinceDays > 366 { + sinceDays = 366 // cap so an untrusted ?days= can't force a huge allocation + } now := time.Now().UTC() out := make([]InstallPoint, 0, sinceDays) days := s.doc.Installs[short] diff --git a/backend/internal/store/pebblestore.go b/backend/internal/store/pebblestore.go index fdcb4f4..c9f45c5 100644 --- a/backend/internal/store/pebblestore.go +++ b/backend/internal/store/pebblestore.go @@ -647,6 +647,9 @@ func (s *PebbleStore) InstallSeries(short string, sinceDays int) []InstallPoint if sinceDays <= 0 { sinceDays = 90 } + if sinceDays > 366 { + sinceDays = 366 // cap so an untrusted ?days= can't force a huge allocation + } now := time.Now().UTC() out := make([]InstallPoint, 0, sinceDays) days := s.doc.Installs[short]