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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user