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:
+23
-7
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user