1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2026-10-28 15:02:28 +00:00

Reject invalid characters in domains

Domains in addresses had very lax validation, which allowed them to
include characters like "/".

That could be exploited to trigger a daemon panic: a MAIL FROM
"x@evil/foo" could be accepted and, on failed delivery, a DSN queued.
Attempting to deliver the DSN would cause an MTA-STS cache lookup, which
would panic because the domain includes a "/" and crash the server.
Because the DSN would be queued, the server would crash again on
restarts, and only self-recover after give_up_send_after (20h by
default).

To fix this, we now explicitly validate the domain when normalizing it,
make MTA-STS cache not panic on invalid domains, and also make the SMTP
courier fail permanently on invalid domains.

Thanks to John JEAN <john@wargan.com> for finding and reporting this
problem. The detailed report can be found in
https://johnjean.io/advisories/chasquid-unauthenticated-remote-dos/.
This commit is contained in:
Alberto Bertogli
2026-10-08 23:54:15 +01:00
parent d2260180c6
commit 61792e3bb4
14 changed files with 255 additions and 35 deletions
+23 -7
View File
@@ -22,6 +22,7 @@ import (
"time"
"blitiri.com.ar/go/chasquid/internal/expvarom"
"blitiri.com.ar/go/chasquid/internal/normalize"
"blitiri.com.ar/go/chasquid/internal/safeio"
"blitiri.com.ar/go/chasquid/internal/trace"
@@ -174,6 +175,12 @@ func (p *Policy) MXIsAllowed(mx string) bool {
// This can be useful for debugging and troubleshooting, but you should always
// call Check on the policy before using it.
func UncheckedFetch(ctx context.Context, domain string) (*Policy, error) {
// Check the domain is valid before doing any lookups, as we use it to
// construct the URL.
if _, err := normalize.Domain(domain); err != nil {
return nil, err
}
// Convert the domain to ascii form, as httpGet does not support IDNs in
// any other way.
domain, err := idna.ToASCII(domain)
@@ -358,19 +365,25 @@ func NewCache(dir string) (*PolicyCache, error) {
const pathPrefix = "pol:"
func (c *PolicyCache) domainPath(domain string) string {
// We assume the domain is well formed, sanity check just in case.
if strings.Contains(domain, "/") {
panic("domain contains slash")
func (c *PolicyCache) domainPath(domain string) (string, error) {
// Domains should have been normalized already, but they can come from
// sources we don't control (e.g. queue items written by older versions),
// so check they're valid. Among other things, this guarantees they don't
// contain "/".
if _, err := normalize.Domain(domain); err != nil {
return "", err
}
return c.dir + "/" + pathPrefix + domain
return c.dir + "/" + pathPrefix + domain, nil
}
var errExpired = errors.New("cache entry expired")
func (c *PolicyCache) load(domain string) (*Policy, error) {
fname := c.domainPath(domain)
fname, err := c.domainPath(domain)
if err != nil {
return nil, err
}
fi, err := os.Stat(fname)
if err != nil {
@@ -421,7 +434,10 @@ func (c *PolicyCache) store(domain string, p *Policy) error {
return os.Chtimes(fname, expires, expires)
}
fname := c.domainPath(domain)
fname, err := c.domainPath(domain)
if err != nil {
return err
}
err = safeio.WriteFile(fname, data, 0640, chTime)
if err != nil {
cacheIOErrors.Add(1)
+23 -10
View File
@@ -289,7 +289,11 @@ func TestCacheBasics(t *testing.T) {
// Simulate an expired cache entry by changing the mtime of domain.com's
// entry to the past.
expires := time.Now().Add(-1 * time.Minute)
os.Chtimes(c.domainPath("domain.com"), expires, expires)
fname, err := c.domainPath("domain.com")
if err != nil {
t.Fatalf("domainPath failed: %v", err)
}
os.Chtimes(fname, expires, expires)
// Do a third fetch, check that we don't get a cache hit.
p, err = c.Fetch(ctx, "domain.com")
@@ -349,7 +353,10 @@ func TestCacheBadData(t *testing.T) {
expvarMustEq(t, "cacheHits", cacheHits, 0)
// Edit the file, filling it with the bad content for this case.
fname := c.domainPath("domain.com")
fname, err := c.domainPath("domain.com")
if err != nil {
t.Fatalf("domainPath failed: %v", err)
}
mustRewriteAndChtime(t, fname, badContent)
// We now expect Fetch to fall back to getting the policy from the
@@ -457,15 +464,21 @@ func TestCacheSlashSafe(t *testing.T) {
t.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
t.Logf("recovered: %v", r)
} else {
t.Fatalf("check did not panic as expected")
}
}()
if fname, err := c.domainPath("a/b"); err == nil {
t.Errorf("domainPath did not fail as expected, returned %q", fname)
}
c.domainPath("a/b")
if p, err := c.Fetch(t.Context(), "a/b"); err == nil {
t.Errorf("cache Fetch did not fail as expected, returned %v", p)
}
if err := c.store("a/b", &Policy{}); err == nil {
t.Errorf("store did not fail as expected")
}
if p, err := Fetch(t.Context(), "a/b"); err == nil {
t.Errorf("Fetch did not fail as expected, returned %v", p)
}
}
func TestURLForDomain(t *testing.T) {