Log redacted email provider errors and diagnose sender configuration (#57)
This commit is contained in:
1 parent
c1d28e705e
commit
3e4e496037
8 files changed
+327
-23
No files matched your search
@@ -0,0 +1,95 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxErrorBody = 16 << 10
|
||||
const maxErrorMessage = 512
|
||||
|
||||
var (
|
||||
errorName = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,64}$`)
|
||||
addresses = regexp.MustCompile(`[^\s<>"'(),;:]+@[^\s<>"'(),;:]+`)
|
||||
urls = regexp.MustCompile(`(?i)https?://[^\s<>"']+`)
|
||||
tokens = regexp.MustCompile(`[a-zA-Z0-9_+/-]{32,}={0,2}`)
|
||||
numbers = regexp.MustCompile(`[0-9]{4,}`)
|
||||
)
|
||||
|
||||
// Only the documented human-readable error fields cross the adapter boundary.
|
||||
// Full bodies, headers and request data must never reach logs or public failures.
|
||||
func providerFailure(body, request []byte, secrets ...string) (string, string) {
|
||||
var dto struct {
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &dto); err != nil {
|
||||
return "", "Provider error details unavailable: invalid JSON response"
|
||||
}
|
||||
if dto.Message == "" && len(dto.Errors) > 0 {
|
||||
dto.Message = dto.Errors[0].Message
|
||||
}
|
||||
// Providers can echo any scalar string from their request DTOs. Strip those
|
||||
// values before truncation, in addition to credentials and embedded tokens.
|
||||
var payload any
|
||||
if json.Unmarshal(request, &payload) == nil {
|
||||
for _, value := range requestStrings(payload) {
|
||||
secrets = append(secrets, value)
|
||||
// Rendered login/verification codes and numeric account references
|
||||
// can be echoed as fragments of an otherwise longer email body.
|
||||
secrets = append(secrets, numbers.FindAllString(value, -1)...)
|
||||
}
|
||||
}
|
||||
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
|
||||
name := safeErrorText(dto.Name, secrets)
|
||||
if !errorName.MatchString(name) {
|
||||
name = ""
|
||||
}
|
||||
message := safeErrorText(dto.Message, secrets)
|
||||
if message == "" {
|
||||
message = "Provider did not include an error message"
|
||||
}
|
||||
return name, message
|
||||
}
|
||||
|
||||
func requestStrings(value any) []string {
|
||||
var values []string
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
values = append(values, value)
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
values = append(values, requestStrings(item)...)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range value {
|
||||
values = append(values, requestStrings(item)...)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func safeErrorText(text string, secrets []string) string {
|
||||
for _, secret := range secrets {
|
||||
if secret != "" {
|
||||
text = strings.ReplaceAll(text, secret, "[redacted]")
|
||||
}
|
||||
}
|
||||
text = addresses.ReplaceAllString(text, "[redacted-address]")
|
||||
text = urls.ReplaceAllString(text, "[redacted-url]")
|
||||
text = tokens.ReplaceAllString(text, "[redacted-token]")
|
||||
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r)
|
||||
}), " ")
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxErrorMessage {
|
||||
text = string(runes[:maxErrorMessage]) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
Reference in new issue
Block a user