98 lines
2.9 KiB
Go
98 lines
2.9 KiB
Go
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)...)
|
|
}
|
|
}
|
|
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 {
|
|
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
|
|
var replacements []string
|
|
for _, secret := range secrets {
|
|
if secret != "" {
|
|
replacements = append(replacements, secret, "[redacted]")
|
|
}
|
|
}
|
|
text = strings.NewReplacer(replacements...).Replace(text)
|
|
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
|
|
}
|