Where a personal SMTP connection may make the server connect to.
An SMTP connection names a host and a port, and both "Test Connection" and
every send open a socket to them from the server. For a website-wide
connection that is an admin's choice. A personal connection (one owned by a
user, {:user, uuid} or any other typed owner) is set by whoever was allowed
to add one — and, left unchecked, lets that user make the server connect to
internal hosts: the database, a connection pooler, a cache, cloud metadata.
The SMTP banner or the error that comes back is enough to probe the internal
network.
Turning it on
Off by default — personal connections then connect exactly as they always did. A host enables it in its config:
config :phoenix_kit, :personal_smtp_policy, :restrictednil, false and :open mean off; any other value means on, so a typo
fails closed rather than silently leaving personal connections unchecked.
That includes strings: a value read with System.get_env/1 is a string, and
"false" turns the policy on — map an environment variable to an atom in
config/runtime.exs yourself. Website-wide (:system) connections are never
affected.
What is refused
For a personal connection, with the policy on:
- a port other than 25, 465, 587 or 2525 (
:blocked_port); - a host the server must not connect to (
:blocked_host):- a name that only means something inside a network — a bare name with
no dot (
localhost,pgbouncer), or one underlocalhost,local,internalorhome.arpa— refused without a lookup; - a host that is, or resolves to, anything but a public unicast
address, as
PhoenixKit.Utils.PublicAddress.public?/1decides — the core's rule for every server-side connection to a user-supplied host: loopback, private, carrier-grade NAT, link-local, unique-local and site-local, multicast, unspecified, reserved, documentation and benchmarking ranges, IPv6 outside2000::/3, and every IPv6 form that carries an IPv4 address (mapped, compatible, NAT64, 6to4, Teredo), plus any range the host lists inconfig :phoenix_kit, :blocked_ip_ranges. A name is resolved for both IPv4 and IPv6, and every address it yields must be public; - a name that does not resolve, or not within the connection's timeout — there is no checked address to connect to, and handing the name to the SMTP client would let it resolve the name again unchecked.
- a name that only means something inside a network — a bare name with
no dot (
The host cases share one reason and one message on purpose: telling "does not resolve" apart from "resolves to an internal address" would let a user find out which names exist in the server's internal DNS.
Refusals come back as {:error, {:smtp_endpoint_refused, reason}};
message/1 turns the reason into the sentence to show.
Connect-time pinning
The check runs right before each socket is opened — in
PhoenixKit.Integrations.Validators.smtp/2 (Test Connection, saved or not)
and in PhoenixKit.Mailer.swoosh_config_for/1 (every send through
deliver_via_integration/3) — and the options it returns carry the checked
address as the relay, so the SMTP client connects to exactly the address that
was checked and never resolves the name itself. A name that answers with a
public address at check time and an internal one at connect time (DNS
rebinding) has no second lookup to exploit. The TLS handshake still names the
host the operator typed: the certificate is verified against it, and it is
sent as the server-name indication even when certificates are not verified.
The lookup is bounded by the connection's timeout (5 s when none is set);
IPv4 and IPv6 are asked at the same time, and a family that does not answer
in time (or whose lookup fails) counts as no addresses. A failed lookup
therefore also comes back as :blocked_host; on the send path a caller
should treat that refusal as possibly transient.
On an IPv6-only host behind DNS64, every name of an IPv4-only relay also
answers with a synthesized 64:ff9b::/96 AAAA record. PublicAddress
refuses that range, and every address must pass, so such relays are refused
there. Such a network either keeps the policy off, or gives the server
IPv4 egress and a resolver without DNS64.
Residual risk: the first address of the checked set is used (IPv4 preferred), with no fallback to the others — the same single attempt the client made with a name. A public address that forwards to an internal one (a relay the user controls) is outside what any address check can see.
Summary
Functions
Whether the host has turned the policy on (see the module doc).
Checks the built SMTP options (PhoenixKit.Mailer.SmtpTransport.config/1)
under policy, returning them with the relay pinned to the checked address.
The sentence to show a person for a refusal reason — for a caller of
PhoenixKit.Mailer.deliver_via_integration/3 that got
{:error, {:smtp_endpoint_refused, reason}}, and for Test Connection.
The policy for a connection, from its credentials and the owner the caller acted as.
Types
Functions
@spec enabled?() :: boolean()
Whether the host has turned the policy on (see the module doc).
@spec enforce(keyword(), policy(), keyword()) :: {:ok, keyword()} | {:error, {:smtp_endpoint_refused, reason()}}
Checks the built SMTP options (PhoenixKit.Mailer.SmtpTransport.config/1)
under policy, returning them with the relay pinned to the checked address.
Returns the options untouched for a :system connection, when the policy is
off, and when no host is set (there is nothing to connect to). Options:
:enabled— overridesenabled?/0(tests).:resolver—(charlist, :inet | :inet6 -> {:ok, [ip]} | {:error, _}), defaults to:inet.getaddrs/3with the lookup timeout. Whatever the resolver, an answer that does not come within the timeout is not waited for.
The sentence to show a person for a refusal reason — for a caller of
PhoenixKit.Mailer.deliver_via_integration/3 that got
{:error, {:smtp_endpoint_refused, reason}}, and for Test Connection.
The policy for a connection, from its credentials and the owner the caller acted as.
A connection whose credentials carry an owner_uuid is personal whatever the
caller says. One without is :system only when the caller says :system;
any other owner — a user, :any, none at all — gets the strict policy.