diff options
author | 2025-05-28 22:05:05 +0100 | |
---|---|---|
committer | 2025-05-28 22:05:05 +0100 | |
commit | 843f7c01c9a045c42da7bea75ce0f3365ec4e6a5 (patch) | |
tree | 1fa73c17e31b3f54b221e34a8777c2bbf80ba74e | |
parent | Add leex and yecc files & Elixir interface for DMARC parse (diff) |
Add DMARC struct storage and validation
-rw-r--r-- | lib/dmarc/policy.ex | 311 |
1 files changed, 311 insertions, 0 deletions
diff --git a/lib/dmarc/policy.ex b/lib/dmarc/policy.ex new file mode 100644 index 0000000..2d2f581 --- /dev/null +++ b/lib/dmarc/policy.ex @@ -0,0 +1,311 @@ +defmodule Lithium.DMARC.Policy do + @moduledoc """ + Represents a DMARC policy record as per RFC 7489 section 6. + + Default values are filled in the validation methods if not provided. + + Values are translated from raw DMARC options to human-readable atoms at this point. + + Most errors in policy formatting soft-fail to the default values as specified + in the RFC document. Processing only halts under certain unrecoverable conditions + (e.g. invalid version, invalid policy with no RUA, etc.). + """ + defstruct [ + :v, + :p, + :adkim, + :aspf, + :fo, + :pct, + :rf, + :ri, + :rua, + :ruf, + :sp + ] + + @alignment_default :relaxed + @fo_default [:all_failure] + @pct_default 100 + @ri_default 86400 + @rf_default :afrf + + require Logger + + @typedoc """ + The DMARC version (currently always "DMARC1"). + """ + @type v :: :dmarc1 + + @typedoc """ + The DMARC requested mail received policy. + """ + @type p :: :none | :quarantine | :reject + + @typedoc """ + DKIM identity alignment mode. + """ + @type adkim :: :relaxed | :strict + + @typedoc """ + SPF identity alignment mode. + """ + @type aspf :: :relaxed | :strict + + @typedoc """ + All possible DMARC failure reporting options. + """ + @type fo_modes :: :all_failure | :any_failure | :dkim_failure | :spf_failure + + @typedoc """ + The selected DMARC failure reporting options. + """ + @type fo :: [fo_modes()] + + @typedoc """ + The percentage of messages subjected to the DMARC policy. + """ + @type pct :: integer() + + @typedoc """ + The supported reporting formats for aggregate reports. + """ + @type rf_formats :: :afrf + + @typedoc """ + Selected reporting formats for aggregate reports. + """ + @type rf :: [rf_formats()] + + @typedoc """ + The interval in seconds between DMARC aggregate reports. + """ + @type ri :: integer() + + @typedoc """ + The mailto URI for aggregate reports. + """ + @type rua :: String.t() + + @typedoc """ + The mailto URI for forensic reports. + """ + @type ruf :: String.t() + + @typedoc """ + The mailto URI for the subdomain policy. + """ + @type sp :: p() + + @typedoc """ + The DMARC policy record. + """ + @type t :: %__MODULE__{ + v: v(), + p: p(), + adkim: adkim(), + aspf: aspf(), + fo: fo(), + pct: pct(), + rf: rf(), + ri: ri(), + rua: [rua()], + ruf: [ruf()], + sp: sp() + } + + @spec parse_version(version :: String.t()) :: {:ok, v()} | {:error, :invalid_version} + defp parse_version(version) do + case String.downcase(version) do + "dmarc1" -> + {:ok, :DMARC1} + + _ -> + Logger.error("Invalid DMARC version: #{inspect(version)}") + {:error, :invalid_version} + end + end + + # As per RFC 7489, when we parse p= strictness if we find an + # invalid value but have a valid RUA, we continue as if we have + # found a :none policy. + @spec parse_policy( + location :: :p | :sp, + strictness :: String.t(), + has_rua :: boolean() + ) :: {:ok, p() | nil} | {:error, :invalid_policy} + defp parse_policy(location, policy, has_rua) do + case policy do + "none" -> + {:ok, :none} + + "quarantine" -> + {:ok, :quarantine} + + "reject" -> + {:ok, :reject} + + _ -> + cond do + location == :p and has_rua -> + {:ok, :none} + + location == :sp -> + {:ok, nil} + + true -> + Logger.error("Invalid DMARC policy: #{inspect(policy)}") + {:error, :invalid_policy} + end + end + end + + @spec parse_alignment_strictness(strictness :: String.t() | nil) :: + adkim() | aspf() + defp parse_alignment_strictness(strictness) do + case strictness do + "r" -> + :relaxed + + "s" -> + :strict + + _ -> + # Default to relaxed if not specified + @alignment_default + end + end + + @spec parse_fo(fo :: [String.t()] | nil) :: fo() + defp parse_fo(nil), do: @fo_default + + defp parse_fo(fo) do + parsed_modes = + Enum.map(fo, fn mode -> + case mode do + "0" -> :all_failure + "1" -> :any_failure + "d" -> :dkim_failure + "s" -> :spf_failure + _ -> nil + end + end) + + parsed_modes = Enum.reject(parsed_modes, &is_nil/1) |> Enum.sort() |> Enum.uniq() + + if parsed_modes == [] do + @fo_default + else + parsed_modes + end + end + + @spec parse_pct(pct :: String.t() | nil) :: pct() + defp parse_pct(nil), do: @pct_default + + defp parse_pct(pct) do + case Integer.parse(pct) do + {pct, ""} when pct >= 0 and pct <= 100 -> + pct + + _ -> + @pct_default + end + end + + @spec parse_rf(rf :: [String.t()]) :: rf() + defp parse_rf(nil), do: [@rf_default] + defp parse_rf([]), do: [@rf_default] + + defp parse_rf(rf) do + parsed_formats = + Enum.map(rf, fn format -> + case format do + "afrf" -> :afrf + _ -> nil + end + end) + + parsed_formats = Enum.reject(parsed_formats, &is_nil/1) |> Enum.sort() |> Enum.uniq() + + if parsed_formats == [] do + [@rf_default] + else + parsed_formats + end + end + + @spec parse_ri(ri :: String.t() | nil) :: ri() + defp parse_ri(nil), do: @ri_default + + defp parse_ri(ri) do + case Integer.parse(ri) do + {ri, ""} when ri >= 3600 and ri <= 86400 -> + # We have to handle daily interval reports, we should handle hourly interval. + # We do not *have* to handle anything else. + ri + + _ -> + @ri_default + end + end + + @spec parse_addresses(addresses :: String.t() | nil) :: + [rua() | ruf()] + defp parse_addresses(nil), do: [] + + defp parse_addresses(addresses) do + addresses + |> Enum.map(&parse_address/1) + |> Enum.reject(&is_nil/1) + end + + @spec parse_address(address :: String.t()) :: + rua() | ruf() + defp parse_address(address) do + uri = URI.parse(address) + + if uri.scheme == "mailto" do + uri.path + else + nil + end + end + + def from_raw_map(raw_map) do + with {:ok, version} <- parse_version(raw_map[:v]), + rua <- parse_addresses(raw_map[:rua]), + ruf <- parse_addresses(raw_map[:ruf]), + {:ok, p} <- parse_policy(:p, raw_map[:p], length(rua) > 0) do + adkim = parse_alignment_strictness(raw_map[:adkim]) + aspf = parse_alignment_strictness(raw_map[:aspf]) + fo = parse_fo(raw_map[:fo]) + pct = parse_pct(raw_map[:pct]) + rf = parse_rf(raw_map[:rf]) + ri = parse_ri(raw_map[:ri]) + + sp = + case parse_policy(:sp, raw_map[:sp], length(rua) > 0) |> elem(1) do + nil -> p + sp -> sp + end + + {:ok, + %__MODULE__{ + p: p, + v: version, + adkim: adkim, + aspf: aspf, + fo: fo, + pct: pct, + rf: rf, + ri: ri, + rua: rua, + ruf: ruf, + sp: sp + }} + else + error -> error + end + end +end |