Skip to content

Dev installs can route auth mail into an unauthenticated /dev/mailbox — detect it and offer the integration send path instead #687

Description

@timujinne

Raised from a security review of a real PhoenixKit deployment on 2026-08-07. Nothing here is exploitable in that particular install today — the operator had already moved sending to a configured integration, which is exactly the mitigation this issue proposes making a first-class option. It is filed because the default shape is unsafe and the framework already has most of the pieces to say so.

The shape

mix phx.new generates, in dev.exs, a mailer on Swoosh.Adapters.Local, and in the router:

if Application.compile_env(:my_app, :dev_routes) do
  scope "/dev" do
    pipe_through :browser
    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end
end

No authentication, by design — it is a developer convenience. PhoenixKit then delivers password-reset, magic-link, email-confirmation, email-change and invitation mail through that mailer. Those messages carry single-use tokens that exist nowhere else: core stores only a SHA-256 hash, so the raw token lives exclusively in the message.

The consequence is a three-request account takeover for anyone who can reach the dev server:

  1. POST /users/reset-password with any address — the page is public and rate-limited per address, which does not impede one targeted reset.
  2. The mail is stored in the in-memory mailbox instead of being sent.
  3. GET /dev/mailbox — anonymous — read the link, follow it, set a password.

Picking the Owner yields the whole application. POST /dev/mailbox/clear then removes the evidence.

"It's only dev" is doing a lot of work in that sentence. Dev servers routinely bind 0.0.0.0, sit on a shared container network, and answer on a real hostname behind a proxy — and they hold real data far more often than anyone plans.

Two gaps in what PhoenixKit already has

1. PhoenixKit.Config.mailer_local?/0 misses the common wiring.

def mailer_local? do
  case get(PhoenixKit.Mailer, nil)[:adapter] do
    Swoosh.Adapters.Local -> true
    _ -> false
  end
end

It inspects config :phoenix_kit, PhoenixKit.Mailer only. In delegation modeconfig :phoenix_kit, mailer: MyApp.Mailer, which PhoenixKit.Mailer's own moduledoc documents and which host apps generally use — the host's adapter is invisible to it. So on a host that delegates to a Swoosh.Adapters.Local mailer, mailer_local?/0 returns false while every message really does land in the local mailbox. I confirmed this on a live install: Application.get_env(:phoenix_kit, PhoenixKit.Mailer) is nil, the host mailer's adapter is Swoosh.Adapters.Local, and the helper answers false.

It also does not account for default_email_integration_uuid, which takes precedence over both mailers in deliver_email/2.

2. When it does fire, the response points people at the mailbox.

PhoenixKitWeb.Components.Core.DevNotice renders, on the public login / registration / forgot-password / magic-link pages:

Development mode: Check mailbox for reset emails

That is a helpful signpost for a developer and a map for everyone else — rendered to anonymous visitors, on the exact pages used to trigger the mail. What is missing is any signal aimed at the operator that the inbox has no authentication.

Proposal

  1. Make mailer_local?/0 reflect what will actually send. Resolve it the way deliver_email/2 does — default send integration, then the delegated host mailer, then the built-in one — so delegation and the integration override are both accounted for. This is a small fix and everything below depends on it.

  2. Warn at install and doctor time. mix phoenix_kit.install and mix phoenix_kit.doctor can see the resolved adapter, and can scan the host router's routes for Plug.Swoosh.MailboxPreview (Phoenix.Router.routes/1) and check whether its pipeline carries any authentication. When mail is local and the preview is unauthenticated, say so in one sentence with the consequence spelled out — "password-reset links for every account are readable at /dev/mailbox by anyone who can reach this server" — rather than a generic note.

  3. Offer the integration send path as an install option. PhoenixKit already ships the Integrations system with SMTP / SES / Brevo providers and default_email_integration_uuid; a dev install pointed at a local catch-all SMTP (Mailpit, Mailhog, smtp4dev) gets the same convenience with none of the tokens-in-a-public-web-page property. Offering this during phoenix_kit.install — "how should development mail be delivered?" — puts the decision in front of the operator once, at the moment they can act on it.

  4. Reconsider the DevNotice link. Keeping the notice while dropping the anchor, or rendering the link only when the preview route is behind a pipeline, would stop the framework advertising an unauthenticated inbox on its own public pages.

Happy to open a PR for (1) and (2) if the direction is agreeable — (3) is a larger design question about what phoenix_kit.install should ask.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions