defmodule Spf.Eval do @moduledoc """ Functions to evaluate an SPF context. """ alias Spf.DNS import Spf.Context @type dns_result :: Spf.DNS.dns_result() # API @doc """ Say whether `str` contains the start of an SPF string. Leading whitespace is not considered an error, although technically it is a syntax error. """ @spec spf?(binary) :: boolean def spf?(str) when is_binary(str), # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.5 do: String.match?(str, ~r/^\s*v=spf1(\s|$)/i) def spf?(_), do: false @doc """ Given a [`context`](`t:Spf.Context.t/0`) retrieve and evaluate the associated SPF record. After an (attempted) evaluation, returns an updated context where: - `:verdict` will be one of `:pass, :fail, :softfail, :neutral` - `:reason` show the mechanism responsible for the verdict - `:explanation` the expanded explain-string (if possible and applicable) - `:error` in case one occurred - `:ipt` which maps the prefixes seen during evaluation to their source - `:msg` which lists log messages accumulated during evaluation and other fields containing information gathered during the evaluation. The context is passed around accumulating information and tracks the state of the evaluation. Its `:log` is either `nil` or points to a `log_function/4` that then called with the `context`, `facility`, `severity` and a `message` so it can dump it to screen or somewhere else. """ @spec evaluate(Spf.Context.t()) :: Spf.Context.t() def evaluate(ctx) do ctx |> check_domain() |> grep_spf() |> Spf.Parser.parse() |> eval() # |> check_spf() end @doc """ Returns true if `name` is a validated name for given `domain`. The [`dns_result`](`t:dns_result/0`) should contain the ip addresses associated with given `name`. If any of the ip adresss match the given `ip`, the `name` is a validated domain name for given `domain`. If the `exact` flag is true, then the `name` is also required to end with given `domain` as well. Note that when trying to validate names during the expansion of the p-marco, this flag will be false. """ # a name is validated iff it's ip == && possibly when name endswith? domain @spec validate?(dns_result, binary, binary, binary, boolean) :: boolean def validate?(dns_result, ip, name, domain, exact) def validate?({:ok, rrs}, ip, name, domain, exact) do pfx = Pfx.new(ip) if Enum.any?(rrs, fn ip -> Pfx.member?(ip, pfx) end) do if exact do String.downcase(name) |> String.ends_with?(String.downcase(domain)) else true end else false end end def validate?({:error, _}, _ip, _name, _domain, _exact), do: false # Helpers @spec ascii?(binary) :: boolean defp ascii?(string) when is_binary(string), do: string == for(<>, c < 128, into: "", do: <>) @spec evalname(Spf.Context.t(), binary, list, any) :: Spf.Context.t() defp evalname(ctx, domain, dual, value) do {ctx, dns} = DNS.resolve(ctx, domain, type: ctx.atype) case dns do {:error, reason} -> log(ctx, :eval, :warn, "#{ctx.atype} #{domain} - DNS error #{inspect(reason)}") {:ok, rrs} -> addip(ctx, rrs, dual, value) end end @spec explain(Spf.Context.t()) :: Spf.Context.t() defp explain(ctx) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-6.2 # - donot track void answers # - get & store the explain-string pointed to by the ctx.explain token # - expand explain-string and store as ctx.explanation if ctx.verdict == :fail and ctx.explain do {_token, [domain], _range} = ctx.explain {ctx, dns} = DNS.resolve(ctx, domain, type: :txt, stats: false) case dns do {:error, reason} -> log(ctx, :dns, :warn, "txt #{domain} - DNS error #{reason}") {:ok, list} when length(list) > 1 -> log(ctx, :dns, :error, "txt #{domain} - too many explain txt records #{inspect(list)}") {:ok, [explain]} -> log(ctx, :dns, :info, "txt #{domain} -> '#{explain}'") |> Map.put(:explain_string, explain) |> Spf.Parser.explain() end else ctx end end @spec check_limits(Spf.Context.t()) :: Spf.Context.t() defp check_limits(ctx) do # only check for original SPF record, so we donot prematurely stop processing if ctx.nth == 0 do ctx = if ctx.num_dnsm > ctx.max_dnsm do error( ctx, :eval, :too_many_dnsm, "too many DNS mechanisms used (#{ctx.num_dnsm})", :permerror ) else ctx end if ctx.num_dnsv > ctx.max_dnsv do error( ctx, :eval, :too_many_dnsv, "too many VOID DNS queries seen (#{ctx.num_dnsv})", :permerror ) else ctx end else ctx end end @spec match(Spf.Context.t(), tuple, list) :: Spf.Context.t() defp match(%{error: error} = ctx, _term, _tail) when error != nil, # a fatal error was already recorded, so bailout do: ctx defp match(ctx, {_q, _token, range} = _term, tail) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.2 verdict = verdict(ctx) if verdict do # ctx.ip has a match, so set corresponding result and we're done log(ctx, :eval, :note, "#{spf_term(ctx, range)} - matches #{ctx.ip}") |> tick(:num_checks) |> Map.put(:verdict, verdict) |> Map.put(:reason, "#{spf_term(ctx, range)}") else # no match, so continue evaluation log(ctx, :eval, :info, "#{spf_term(ctx, range)} - no match") |> tick(:num_checks) |> evalp(tail) end end @spec validate(binary, Spf.Context.t(), tuple) :: Spf.Context.t() defp validate(name, ctx, {:ptr, [q, domain], range} = _term) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.5 {ctx, dns} = DNS.resolve(ctx, name, type: ctx.atype) case validate?(dns, ctx.ip, name, domain, true) do true -> addip(ctx, [ctx.ip], [32, 128], {q, ctx.nth, spf_term(ctx, range)}) |> log(:eval, :info, "validated: #{name}, #{ctx.ip} for #{domain}") false -> log(ctx, :eval, :info, "not validated: #{name}, #{ctx.ip} for #{domain}") end end @spec verdict(Spf.Context.t()) :: nil | atom defp verdict(ctx) do # used by match/1 to check if we currently have a match # - ipt[prefix] -> [{q, nth, token}] => list of tokens and SPF-id that added the prefix # - the token contains the qualifier that, if matched, says what the result should be # notes: # - prefixes can be contributed multiple times by Nxterms in Mxrecords # - the last {token, nth} to do so, is listed first # - so only check for the first token for the current ctx.nth # - having verdict does not necessarily stop evaluation (e.g. when inside an include) with {_pfx, qlist} <- Iptrie.lookup(ctx.ipt, ctx.ip), {data, _} <- List.keytake(qlist, ctx.nth, 1), q <- elem(data, 0) do qualify(q) else _ -> nil end end @spec qualify(pos_integer()) :: atom defp qualify(qualifier) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.2 case qualifier do ?+ -> :pass ?- -> :fail ?~ -> :softfail ?? -> :neutral end end @spec check_domain(Spf.Context.t()) :: Spf.Context.t() defp check_domain(ctx) do # check domain, if not a legal fqdn -> evaluation result is :none # since there is no domain to actually check if ctx.error do ctx else case Spf.DNS.check_domain(ctx.domain) do {:ok, _domain} -> ctx {:error, reason} -> error(ctx, :eval, :illegal_domain, "domain error (#{reason})", :none) end end end @spec grep_spf(Spf.Context.t()) :: Spf.Context.t() defp grep_spf(ctx) do # either set ctx.spf to an SPF record, or set ctx.error to some atom error {ctx, result} = Spf.DNS.resolve(ctx, ctx.domain, type: :txt, stats: false) case Spf.DNS.filter(result, &spf?/1) do {:ok, []} -> error(ctx, :eval, :no_spf, "no SPF record found", :none) {:ok, [spf]} -> if ascii?(spf), do: Map.put(ctx, :spf, spf), else: error(ctx, :eval, :non_ascii_spf, "SPF contains non-ascii characters", :permerror) {:ok, list} -> error( ctx, :eval, :too_many_spf, "too many SPF records found (#{length(list)})", :permerror ) {:error, :timeout} -> error(ctx, :eval, :timeout, "txt #{ctx.domain} - DNS error (timeout)", :temperror) {:error, :servfail} -> error(ctx, :eval, :servfail, "txt #{ctx.domain} - DNS error (servfail)", :temperror) {:error, :nxdomain} -> error(ctx, :eval, :nxdomain, "txt #{ctx.domain} - DNS error (nxdomain)", :none) {:error, :zero_answers} -> error(ctx, :eval, :zero_answers, "txt #{ctx.domain} - DNS error (zero answers)", :none) {:error, :illegal_name} -> error(ctx, :eval, :illegal_name, "txt #{ctx.domain} - DNS error (illegal name)", :none) {:error, reason} -> Map.put(ctx, :error, reason) end end # Eval Terms @spec eval(Spf.Context.t()) :: Spf.Context.t() defp eval(%{error: error} = ctx) when error != nil, do: ctx defp eval(ctx) do evalp(ctx, ctx.ast) |> explain() |> Map.put(:duration, (DateTime.utc_now() |> DateTime.to_unix()) - ctx.t0) |> check_limits() end @spec evalp(Spf.Context.t(), list) :: Spf.Context.t() defp evalp(ctx, []), # Nomore Terms do: ctx # A defp evalp(ctx, [{:a, [q, domain, dual], range} = term | tail]) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.3 {ctx, dns} = DNS.resolve(ctx, domain, type: ctx.atype) case dns do {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] -> ctx {:error, reason} -> error(ctx, :eval, reason, "DNS error #{domain} - #{reason}", :temperror) {:ok, rrs} -> addip(ctx, rrs, dual, {q, ctx.nth, spf_term(ctx, range)}) end |> match(term, tail) end # All defp evalp(ctx, [{:all, [q], range} = _term | _tail]) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.1 log(ctx, :eval, :note, "#{spf_term(ctx, range)} - matches") |> tick(:num_checks) |> Map.put(:verdict, qualify(q)) |> Map.put(:reason, "#{spf_term(ctx, range)}") end # EXISTS defp evalp(ctx, [{:exists, [q, domain], range} = term | tail]) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.7 {ctx, dns} = DNS.resolve(ctx, domain, type: :a) case dns do {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] -> ctx {:error, reason} -> error(ctx, :eval, reason, "DNS error #{domain} - #{reason}", :temperror) {:ok, rrs} -> log(ctx, :eval, :info, "DNS #{inspect(rrs)}") |> addip(ctx.ip, [32, 128], {q, ctx.nth, spf_term(ctx, range)}) end |> match(term, tail) end # INCLUDE defp evalp(ctx, [{:include, [q, domain], range} = _term | tail]) do term = spf_term(ctx, range) if loop?(ctx, domain) do error( ctx, :eval, :loop, "loop detected: #{ctx.domain} cannot include #{domain}", :permerror ) else ctx = log(ctx, :eval, :note, "#{term} - recurse") |> push(domain) |> evaluate() |> then(&log(&1, :eval, :note, "spf[#{&1.nth}] verdict #{&1.verdict}")) case ctx.verdict do v when v in [:neutral, :fail, :softfail] -> pop(ctx) |> log(:eval, :note, "#{term} - no match") |> evalp(tail) :pass -> pop(ctx) |> Map.put(:verdict, qualify(q)) |> log(:eval, :note, "#{term} - match") |> Map.put(:reason, "#{term} - matched") v when v in [:none, :permerror] -> pop(ctx) |> error(:eval, :include, "#{term} - permanent error", :permerror) :temperror -> pop(ctx) |> log(:eval, :warn, "#{term} - temp error") end end end # IP4/6 defp evalp(ctx, [{ip, [q, pfx], range} = term | tail]) when ip in [:ip4, :ip6] do addip(ctx, [pfx], [32, 128], {q, ctx.nth, spf_term(ctx, range)}) |> match(term, tail) end # MX defp evalp(ctx, [{:mx, [q, domain, dual], range} = term | tail]) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.4 {ctx, dns} = DNS.resolve(ctx, domain, type: :mx) spfterm = spf_term(ctx, range) case dns do {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] -> ctx {:error, reason} -> error(ctx, :eval, reason, "DNS error #{domain} - #{reason}", :temperror) {:ok, [{0, "."}]} -> # https://www.rfc-editor.org/rfc/rfc7505.html#section-3 log(ctx, :eval, :warn, "#{spfterm} - unusable due to null MX for #{domain}") {:ok, rrs} when length(rrs) > 10 -> # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.4 error(ctx, :eval, :too_many_mtas, "too many mta's for #{spfterm}", :permerror) {:ok, rrs} -> Enum.map(rrs, fn {_pref, name} -> name end) |> Enum.take(10) |> Enum.reduce(ctx, fn name, acc -> evalname(acc, name, dual, {q, ctx.nth, spfterm}) end) |> log(:dns, :debug, "MX #{domain} #{inspect({q, ctx.nth, term})} added") end |> match(term, tail) end # PTR defp evalp(ctx, [{:ptr, [_q, domain], _range} = term | tail]) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.5 # https://www.rfc-editor.org/errata/eid4751 domain = Spf.DNS.normalize(domain) {ctx, dns} = DNS.resolve(ctx, Pfx.dns_ptr(ctx.ip), type: :ptr) case dns do {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] -> ctx {:error, reason} -> error(ctx, :eval, reason, "DNS error #{domain} - #{reason}", :temperror) {:ok, rrs} -> # https://www.rfc-editor.org/errata/eid5227 Enum.map(rrs, fn name -> Spf.DNS.normalize(name) end) |> Enum.filter(fn name -> String.ends_with?(name, domain) end) |> Enum.take(10) |> Enum.reduce(ctx, fn name, acc -> validate(name, acc, term) end) end |> match(term, tail) end # REDIRECT defp evalp(ctx, [{:redirect, [:einvalid], range} | _tail]) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-6.1 error(ctx, :eval, :no_redir_domain, "#{spf_term(ctx, range)} - invalid domain", :permerror) end defp evalp(ctx, [{:redirect, [domain], range} | tail]) do # https://www.rfc-editor.org/rfc/rfc7208.html#section-6.1 # - if redirect domain has no SPF -> permerror # - if redirect domain is mailformed -> permerror # - otherwise its result is the result for this SPF if loop?(ctx, domain) do error( ctx, :eval, :loop, "loop detected: #{ctx.domain} cannot redirect to #{domain}", :permerror ) else ctx = test(ctx, :eval, :warn, length(tail) > 0, "terms after #{spf_term(ctx, range)}?") |> log(:eval, :note, "#{spf_term(ctx, range)} - redirecting to #{domain}") |> redirect(domain) |> evaluate() if ctx.error in [:no_spf, :nxdomain] do error(ctx, :eval, :no_redir_spf, "no SPF found for #{domain}", :permerror) else ctx end end end # TERM UNKNOWN -> internal error defp evalp(ctx, [term | tail]) do log(ctx, :eval, :error, "internal error, eval is missing a handler for #{inspect(term)}") |> evalp(tail) end end