Add 80% limit warning, stop repeat notifications, fix review findings
Notifications: - Warn once when a counter reaches warn_percent of its limit, through separate warn_*_url endpoints. - Write ntf/ markers so a lost Redis reply cannot re-send a breach. - Retry only when the request never left or the web app answered 429/5xx. Fixes: - Retry transactions Redis rejected; treat pool timeouts as definite. - Install the signal handler before startup work. - Stop logging the login suffix; redact notify URL credentials. - Reject corrupt or empty CDBs, truncated hash tables included. - Require a Sentinel password, refuse example placeholders, and default admin_addr to localhost. - Normalise /counters domains, count on the arrival day, log rejections, hide internals in errors and exit 1 on a failed drain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c43ff17627
commit
54f9626121
@@ -22,7 +22,7 @@ func TestValidateOut(t *testing.T) {
|
||||
// Support/token logins (review #3): mizu forwards the raw SASL login
|
||||
// user@dom@SUFFIX; the base address is counted, the suffix logged.
|
||||
{"login suffix", OutRequest{AuthUser: "x@yagolivas.com@SUPPORTTOKEN", Recipient: "y@example.com"}, "",
|
||||
ValidatedOut{AuthUser: Address{Value: "x@yagolivas.com"}, LoginSuffix: "SUPPORTTOKEN", Recipient: Address{Value: "y@example.com"}}},
|
||||
ValidatedOut{AuthUser: Address{Value: "x@yagolivas.com"}, TokenLogin: true, Recipient: Address{Value: "y@example.com"}}},
|
||||
// mail_from never rejects (review #2): kept normalised when possible,
|
||||
// otherwise as a sanitised string for the log.
|
||||
{"mail_from empty", OutRequest{AuthUser: "x@example.com", Recipient: "y@example.com", MailFrom: ""}, "",
|
||||
@@ -253,3 +253,34 @@ func TestErrorResponseJSON(t *testing.T) {
|
||||
t.Errorf("got %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDomain(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"Example.COM", "example.com"},
|
||||
{" example.com. ", "example.com"},
|
||||
{"bücher.de", "xn--bcher-kva.de"},
|
||||
{"xn--bcher-kva.de", "xn--bcher-kva.de"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := NormalizeDomain(tt.in)
|
||||
if err != nil || got != tt.want {
|
||||
t.Errorf("NormalizeDomain(%q) = %q, %v; want %q", tt.in, got, err, tt.want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", ".", "a..b", ".a", "host_name.example.com", "[192.0.2.1]", "a b.com"} {
|
||||
if got, err := NormalizeDomain(bad); err == nil {
|
||||
t.Errorf("NormalizeDomain(%q) = %q, want an error", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unusable login is logged, but never with what follows a second "@".
|
||||
func TestValidateOut_UnusableLoginDropsSuffix(t *testing.T) {
|
||||
v, err := ValidateOut(OutRequest{AuthUser: "x@bad..domain@SECRET", Recipient: "y@example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.AuthUser.Usable() || v.AuthUser.Raw != "x@bad..domain" {
|
||||
t.Errorf("AuthUser = %+v, want the raw value without the suffix", v.AuthUser)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-12
@@ -40,7 +40,7 @@ func (a Address) Usable() bool { return a.Value != "" }
|
||||
type ValidatedOut struct {
|
||||
TraceID string
|
||||
AuthUser Address
|
||||
LoginSuffix string // "SUPPORTTOKEN" of a user@dom@SUPPORTTOKEN login
|
||||
TokenLogin bool // a user@dom@SUFFIX login; the suffix may be a credential and is dropped
|
||||
MailFrom string // normalised, or the sanitised input when not an address
|
||||
MailFromUsable bool
|
||||
Recipient Address
|
||||
@@ -60,7 +60,7 @@ type ValidatedIn struct {
|
||||
// mail_from only feeds the exempt check and never rejects.
|
||||
func ValidateOut(r OutRequest) (ValidatedOut, error) {
|
||||
v := ValidatedOut{TraceID: SanitizeTraceID(r.TraceID)}
|
||||
v.AuthUser, v.LoginSuffix = validateAuthUser(r.AuthUser)
|
||||
v.AuthUser, v.TokenLogin = validateAuthUser(r.AuthUser)
|
||||
v.Recipient = validateAddress(r.Recipient)
|
||||
v.MailFrom, v.MailFromUsable = lenient(r.MailFrom)
|
||||
if !v.AuthUser.Usable() && !v.Recipient.Usable() {
|
||||
@@ -93,15 +93,26 @@ func validateAddress(s string) Address {
|
||||
return Address{Value: n}
|
||||
}
|
||||
|
||||
func validateAuthUser(s string) (Address, string) {
|
||||
func validateAuthUser(s string) (Address, bool) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return Address{Reason: "missing"}, ""
|
||||
return Address{Reason: "missing"}, false
|
||||
}
|
||||
n, suffix, err := NormalizeAuthUser(s)
|
||||
if err != nil {
|
||||
return Address{Raw: SanitizeForLog(s), Reason: "invalid e-mail address"}, ""
|
||||
return Address{Raw: redactSuffix(s), Reason: "invalid e-mail address"}, false
|
||||
}
|
||||
return Address{Value: n}, suffix
|
||||
return Address{Value: n}, suffix != ""
|
||||
}
|
||||
|
||||
// redactSuffix prepares an unusable login for the log: anything after a second
|
||||
// "@" may be a credential, so it is cut off first.
|
||||
func redactSuffix(s string) string {
|
||||
if first := strings.Index(s, "@"); first >= 0 {
|
||||
if second := strings.Index(s[first+1:], "@"); second >= 0 {
|
||||
s = s[:first+1+second]
|
||||
}
|
||||
}
|
||||
return SanitizeForLog(s)
|
||||
}
|
||||
|
||||
// lenient normalises when possible and otherwise keeps a sanitised copy for
|
||||
@@ -159,7 +170,21 @@ func NormalizeEmail(s string) (string, error) {
|
||||
return "", errors.New("local part contains whitespace or control characters")
|
||||
}
|
||||
}
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
ascii, err := NormalizeDomain(domain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := local + "@" + ascii
|
||||
if len(out) > MaxEmailLen {
|
||||
return "", errors.New("longer than 254 characters")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NormalizeDomain trims and lowercases a domain, drops one trailing dot and
|
||||
// converts a Unicode domain to punycode, the form the CDB keys it under.
|
||||
func NormalizeDomain(s string) (string, error) {
|
||||
domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".")
|
||||
if domain == "" || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
|
||||
return "", errors.New("domain has an empty label")
|
||||
}
|
||||
@@ -173,11 +198,7 @@ func NormalizeEmail(s string) (string, error) {
|
||||
return "", errors.New("domain contains characters other than a-z, 0-9, '-' and '.'")
|
||||
}
|
||||
}
|
||||
out := local + "@" + ascii
|
||||
if len(out) > MaxEmailLen {
|
||||
return "", errors.New("longer than 254 characters")
|
||||
}
|
||||
return out, nil
|
||||
return ascii, nil
|
||||
}
|
||||
|
||||
// SanitizeTraceID trims the id, strips control characters (it ends up in log
|
||||
|
||||
Reference in New Issue
Block a user