struct spf_resolved_element {
GPtrArray *elts;
char *cur_domain;
+ unsigned int nested; /* Depth of this element in the include/redirect chain */
gboolean redirected; /* Ignore level, it's redirected */
};
struct spf_record {
- int nested;
int dns_requests;
unsigned int dns_expansions; /* Address requests spawned by mx/ptr expansion */
int requests_inflight;
static inline bool
spf_record_can_dns(const struct spf_record *rec)
{
- if (spf_lib_ctx->max_dns_nesting > 0 &&
- rec->nested > spf_lib_ctx->max_dns_nesting) {
- msg_warn_spf("spf nesting limit: %d > %d is reached, domain: %s",
- rec->nested, spf_lib_ctx->max_dns_nesting,
+ if (spf_lib_ctx->max_dns_requests > 0 &&
+ rec->dns_requests >= spf_lib_ctx->max_dns_requests) {
+ msg_warn_spf("spf dns requests limit: %d >= %d is reached, domain: %s",
+ rec->dns_requests, spf_lib_ctx->max_dns_requests,
rec->sender_domain);
return false;
}
- if (spf_lib_ctx->max_dns_requests > 0 &&
- rec->dns_requests > spf_lib_ctx->max_dns_requests) {
- msg_warn_spf("spf dns requests limit: %d > %d is reached, domain: %s",
- rec->dns_requests, spf_lib_ctx->max_dns_requests,
+
+ return true;
+}
+
+/*
+ * Check whether a new element can be nested under `parent`, that is whether an
+ * include or a redirect found in `parent` may be followed. Depth is stored per
+ * element as the resolution is asynchronous and there is no call stack to
+ * reflect the position in the include/redirect tree.
+ */
+static inline bool
+spf_record_can_nest(const struct spf_record *rec,
+ const struct spf_resolved_element *parent)
+{
+ unsigned int nested = parent->nested + 1;
+
+ if (nested > SPF_MAX_NESTING_HARD) {
+ msg_warn_spf("spf hard nesting limit: %ud > %ud is reached, domain: %s",
+ nested, (unsigned int) SPF_MAX_NESTING_HARD,
+ rec->sender_domain);
+ return false;
+ }
+
+ if (spf_lib_ctx->max_dns_nesting > 0 &&
+ nested > spf_lib_ctx->max_dns_nesting) {
+ msg_warn_spf("spf nesting limit: %ud > %ud is reached, domain: %s",
+ nested, spf_lib_ctx->max_dns_nesting,
rec->sender_domain);
return false;
}
if (!spf_record_can_dns(rec)) return FALSE; \
} while (0)
+#define CHECK_NESTING(rec, parent) \
+ do { \
+ if (!spf_record_can_nest(rec, parent)) return FALSE; \
+ } while (0)
+
RSPAMD_CONSTRUCTOR(rspamd_spf_lib_ctx_ctor)
{
spf_lib_ctx = g_malloc0(sizeof(*spf_lib_ctx));
static void
rspamd_spf_process_reference(struct spf_resolved *target,
- struct spf_addr *addr, struct spf_record *rec, gboolean top)
+ struct spf_addr *addr, struct spf_record *rec,
+ gboolean top, unsigned int depth)
{
struct spf_resolved_element *elt, *relt;
struct spf_addr *cur = NULL, taddr, *cur_addr;
unsigned int i;
+ /*
+ * References always point to an element that has been added after the
+ * current one, so the graph cannot contain loops. Depth, however, follows
+ * the include/redirect chain, hence the same bound as for the resolution
+ * stage is applied here to keep this recursion shallow
+ */
+ if (depth > SPF_MAX_NESTING_HARD) {
+ msg_warn_spf("spf flattening depth limit: %ud > %ud is reached, "
+ "domain: %s",
+ depth, (unsigned int) SPF_MAX_NESTING_HARD,
+ rec->sender_domain);
+ target->flags |= RSPAMD_SPF_RESOLVED_PERM_FAILED;
+
+ return;
+ }
+
if (addr) {
g_assert(addr->m.idx < rec->resolved->len);
/* Process reference */
if (cur->flags & RSPAMD_SPF_FLAG_REDIRECT) {
/* Stop on redirected domain */
- rspamd_spf_process_reference(target, cur, rec, top);
+ rspamd_spf_process_reference(target, cur, rec, top, depth + 1);
break;
}
else {
- rspamd_spf_process_reference(target, cur, rec, FALSE);
+ rspamd_spf_process_reference(target, cur, rec, FALSE, depth + 1);
}
}
else {
rec->resolved->len);
if (rec->resolved->len > 0) {
- rspamd_spf_process_reference(res, NULL, rec, TRUE);
+ rspamd_spf_process_reference(res, NULL, rec, TRUE, 0);
}
}
else {
static gboolean
-parse_spf_include(struct spf_record *rec, struct spf_addr *addr)
+parse_spf_include(struct spf_record *rec,
+ struct spf_resolved_element *resolved, struct spf_addr *addr)
{
struct spf_dns_cb *cb;
const char *domain;
struct rspamd_task *task = rec->task;
CHECK_REC(rec);
+ CHECK_NESTING(rec, resolved);
domain = strchr(addr->spf_string, ':');
if (domain == NULL) {
cb->initiated_by = SPF_RESOLVE_INCLUDE;
addr->m.idx = rec->resolved->len;
cb->resolved = rspamd_spf_new_addr_list(rec, domain);
+ cb->resolved->nested = resolved->nested + 1;
cb->initiated_dns_name = domain;
/* Set reference */
addr->flags |= RSPAMD_SPF_FLAG_REFERENCE;
struct rspamd_task *task = rec->task;
CHECK_REC(rec);
+ CHECK_NESTING(rec, resolved);
domain = strchr(addr->spf_string, '=');
cb->addr = addr;
cb->initiated_by = SPF_RESOLVE_REDIRECT;
cb->resolved = rspamd_spf_new_addr_list(rec, domain);
+ cb->resolved->nested = resolved->nested + 1;
cb->initiated_dns_name = domain;
msg_debug_spf("resolve redirect %s", domain);
res = parse_spf_ip4(rec, addr);
}
else if (g_ascii_strncasecmp(begin, SPF_INCLUDE, sizeof(SPF_INCLUDE) - 1) == 0) {
- res = parse_spf_include(rec, addr);
+ res = parse_spf_include(rec, resolved, addr);
}
else if (g_ascii_strncasecmp(begin, SPF_IP6, sizeof(SPF_IP6) - 1) == 0) {
res = parse_spf_ip6(rec, addr);
... Settings=${SETTINGS_SPF}
Expect Symbol R_SPF_PERMFAIL
Do Not Expect Symbol R_SPF_FAIL
+
+SPF NESTING LIMIT
+ [Documentation] An include chain is followed up to max_dns_nesting levels
+ ... (10 by default) and no further: 8.8.8.8 is listed by the last allowed
+ ... level, 8.8.4.4 by the level below it that must never be resolved
+ Scan File ${RSPAMD_TESTDIR}/messages/dmarc/bad_dkim1.eml
+ ... IP=8.8.8.8 From=x@nest.org.org.za
+ ... Settings=${SETTINGS_SPF}
+ Expect Symbol R_SPF_ALLOW
+ Scan File ${RSPAMD_TESTDIR}/messages/dmarc/bad_dkim1.eml
+ ... IP=8.8.4.4 From=x@nest.org.org.za
+ ... Settings=${SETTINGS_SPF}
+ Expect Symbol R_SPF_FAIL
+ Do Not Expect Symbol R_SPF_ALLOW
+
+SPF DNS REQUESTS LIMIT
+ [Documentation] Exactly max_dns_requests DNS elements (30 by default) are
+ ... evaluated, the 31st one is not, so only the shorter record authorises
+ ... 8.8.8.8 by its last element
+ Scan File ${RSPAMD_TESTDIR}/messages/dmarc/bad_dkim1.eml
+ ... IP=8.8.8.8 From=x@fewreq.org.org.za
+ ... Settings=${SETTINGS_SPF}
+ Expect Symbol R_SPF_ALLOW
+ Scan File ${RSPAMD_TESTDIR}/messages/dmarc/bad_dkim1.eml
+ ... IP=8.8.8.8 From=x@manyreq.org.org.za
+ ... Settings=${SETTINGS_SPF}
+ Expect Symbol R_SPF_FAIL
+ Do Not Expect Symbol R_SPF_ALLOW
{name = "mx1.fewmx.org.org.za", type = "aaaa", rcode = "norec"},
{name = "mx2.fewmx.org.org.za", type = "a", replies = ["192.0.2.20"]},
{name = "mx2.fewmx.org.org.za", type = "aaaa", rcode = "norec"},
+ # 117_spf: an include chain that is exactly as deep as max_dns_nesting
+ # (10 by default) plus one more level that must not be followed:
+ # 8.8.8.8 comes from the last allowed level, 8.8.4.4 from beyond it
+ {name = "nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l1.nest.org.org.za -all"]},
+ {name = "l1.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l2.nest.org.org.za -all"]},
+ {name = "l2.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l3.nest.org.org.za -all"]},
+ {name = "l3.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l4.nest.org.org.za -all"]},
+ {name = "l4.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l5.nest.org.org.za -all"]},
+ {name = "l5.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l6.nest.org.org.za -all"]},
+ {name = "l6.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l7.nest.org.org.za -all"]},
+ {name = "l7.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l8.nest.org.org.za -all"]},
+ {name = "l8.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l9.nest.org.org.za -all"]},
+ {name = "l9.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 include:l10.nest.org.org.za -all"]},
+ {name = "l10.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 ip4:8.8.8.8 include:l11.nest.org.org.za -all"]},
+ {name = "l11.nest.org.org.za", type = "txt",
+ replies = ["v=spf1 ip4:8.8.4.4 -all"]},
+ # 117_spf: records with exactly max_dns_requests (30 by default) and one
+ # more DNS element, the last element of each is the one matching 8.8.8.8
+ {name = "manyreq.org.org.za", type = "txt",
+ replies = ["v=spf1 a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:hit.req.org.org.za -all"]},
+ {name = "fewreq.org.org.za", type = "txt",
+ replies = ["v=spf1 a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:pad.req.org.org.za a:hit.req.org.org.za -all"]},
+ {name = "pad.req.org.org.za", type = "a", replies = ["192.0.2.30"]},
+ {name = "pad.req.org.org.za", type = "aaaa", rcode = "norec"},
+ {name = "hit.req.org.org.za", type = "a", replies = ["8.8.8.8"]},
+ {name = "hit.req.org.org.za", type = "aaaa", rcode = "norec"},
{
name = "fail7.org.org.za",
type = "aaaa";