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
@@ -33,6 +33,10 @@ type Outcome struct {
|
||||
State model.State
|
||||
Code string
|
||||
RetryAfter time.Duration
|
||||
// Provider diagnostics are sanitized operational data, not public failures.
|
||||
HTTPStatus int
|
||||
ProviderError string
|
||||
ProviderMessage string
|
||||
}
|
||||
type Sender interface {
|
||||
Send(context.Context, string, model.Email) Outcome
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/notifications/model"
|
||||
)
|
||||
|
||||
func TestProviderErrorDiagnostics(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"resend", `{"name":"validation_error","message":"The sxchnge.com domain is not verified"}`, "validation_error", "The sxchnge.com domain is not verified"},
|
||||
{"sendgrid", `{"errors":[{"message":"The sender identity is not verified","field":"from","help":"private URL"}]}`, "", "The sender identity is not verified"},
|
||||
{"no message", `{"unrecognized":"private response"}`, "", "Provider did not include an error message"},
|
||||
{"malformed", `{"message":"private response"`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"wrong type", `{"message":{"secret":"private response"}}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"trailing", `{"message":"private response"} {}`, "", "Provider error details unavailable: invalid JSON response"},
|
||||
{"oversized", `{"message":"` + strings.Repeat("x", maxErrorBody) + `"}`, "", "Provider error details unavailable: incomplete or oversized response"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
out := c.post(context.Background(), "", struct{}{})
|
||||
if out.State != model.Failed || out.Code != "provider_authentication_failed" || out.HTTPStatus != 403 || out.ProviderError != tc.code || out.ProviderMessage != tc.message {
|
||||
t.Fatalf("unexpected provider outcome: %+v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDiagnosticsRedactRequestDataBeforeTruncation(t *testing.T) {
|
||||
key := "re_credential-secret"
|
||||
request := []byte(`{"from":"Sender <sender@example.com>","to":["recipient@example.com"],"personalizations":[{"dynamic_template_data":{"secret":"short-private-value"}}],"subject":"Private subject","text":"Private text","html":"<p>Private HTML</p>","content":[{"value":"Your verification code is 123456; account 9876543210"}]}`)
|
||||
token := strings.Repeat("a", 43)
|
||||
message := "Credential " + key + "; recipient@example.com; Private subject; Private text; <p>Private HTML</p>; short-private-value; https://example.com/verify?token=" + token + "; " + token + "; 123456; 9876543210; job-secret\n\r\x00\u202eforged-log"
|
||||
body, err := json.Marshal(map[string]string{"name": "validation_error", "message": message})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, result := providerFailure(body, request, key, "job-secret")
|
||||
if name != "validation_error" || !strings.Contains(result, "forged-log") || !strings.Contains(result, "[redacted]") {
|
||||
t.Fatal("lost useful diagnostic", name, result)
|
||||
}
|
||||
for _, forbidden := range []string{key, "recipient@example.com", "Private subject", "Private text", "Private HTML", "short-private-value", "https://", token, "123456", "9876543210", "job-secret", "\n", "\r", "\x00", "\u202e"} {
|
||||
if strings.Contains(result, forbidden) {
|
||||
t.Fatalf("diagnostic contains sensitive value or control %q", forbidden)
|
||||
}
|
||||
}
|
||||
body, err = json.Marshal(map[string]string{"name": "validation_error", "message": strings.Repeat("ą", maxErrorMessage+20) + key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, result = providerFailure(body, request, key)
|
||||
if !utf8.ValidString(result) || utf8.RuneCountInString(result) != maxErrorMessage+1 || !strings.HasSuffix(result, "…") || strings.Contains(result, "re_") {
|
||||
t.Fatal("invalid or unbounded diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulResponsesDoNotExposeProviderContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"name":"private","message":"private provider response"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
c := client{http: server.Client(), endpoint: server.URL}
|
||||
if out := c.post(context.Background(), "", struct{}{}); out.State != model.Accepted || out.HTTPStatus != 200 || out.ProviderMessage != "" || out.ProviderError != "" {
|
||||
t.Fatal("success leaked provider content", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportFailureHasSanitizedDiagnostic(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c := client{http: http.DefaultClient, key: "credential-secret", endpoint: "https://example.com/emails?credential=credential-secret"}
|
||||
out := c.post(ctx, "job-secret", struct{}{})
|
||||
if out.State != model.Unknown || !strings.Contains(out.ProviderMessage, "context canceled") || strings.Contains(out.ProviderMessage, "credential-secret") || strings.Contains(out.ProviderMessage, "https://") {
|
||||
t.Fatal("transport cause missing or unsafe", out)
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,15 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderMessage: safeErrorText(err.Error(), []string{c.key, key})}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Discard a bounded response without logging provider-controlled content.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
// Never pass raw provider bodies through the outcome or log them directly.
|
||||
details, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
|
||||
outcome := Outcome{HTTPStatus: resp.StatusCode}
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return Outcome{State: model.Accepted}
|
||||
outcome.State = model.Accepted
|
||||
case resp.StatusCode == 429:
|
||||
delay := time.Minute
|
||||
if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 && n <= 86400 {
|
||||
@@ -43,12 +44,20 @@ func (c *client) post(ctx context.Context, key string, payload any) Outcome {
|
||||
} else if date, e := http.ParseTime(resp.Header.Get("Retry-After")); e == nil && time.Until(date) > 0 {
|
||||
delay = time.Until(date)
|
||||
}
|
||||
return Outcome{State: model.RetryWait, Code: "provider_rate_limited", RetryAfter: delay}
|
||||
outcome.State, outcome.Code, outcome.RetryAfter = model.RetryWait, "provider_rate_limited", delay
|
||||
case resp.StatusCode >= 500 || resp.StatusCode == 408:
|
||||
return Outcome{State: model.Unknown, Code: "provider_outcome_unknown"}
|
||||
outcome.State, outcome.Code = model.Unknown, "provider_outcome_unknown"
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return Outcome{State: model.Failed, Code: "provider_authentication_failed"}
|
||||
outcome.State, outcome.Code = model.Failed, "provider_authentication_failed"
|
||||
default:
|
||||
return Outcome{State: model.Failed, Code: "provider_rejected"}
|
||||
outcome.State, outcome.Code = model.Failed, "provider_rejected"
|
||||
}
|
||||
if outcome.State != model.Accepted {
|
||||
if readErr != nil || len(details) > maxErrorBody {
|
||||
outcome.ProviderMessage = "Provider error details unavailable: incomplete or oversized response"
|
||||
} else {
|
||||
outcome.ProviderError, outcome.ProviderMessage = providerFailure(details, body, c.key, key)
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
Reference in new issue
Block a user