berrypod/lib/berrypod_web/plugs/rate_limit.ex
jamey 0c2d4ac406
Some checks failed
deploy / deploy (push) Failing after 8m33s
add rate limiting and HSTS for security hardening
- Add Hammer library for rate limiting with ETS backend
- Rate limit login (5/min), magic link (3/min), newsletter (10/min), API (60/min)
- Add themed 429 error page using bare shop styling
- Enable HSTS in production with rewrite_on for Fly proxy
- Add security hardening plan to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-08 08:58:43 +00:00

92 lines
2.3 KiB
Elixir

defmodule BerrypodWeb.Plugs.RateLimit do
@moduledoc """
Plug for rate limiting requests.
## Usage in router
plug BerrypodWeb.Plugs.RateLimit, type: :login
plug BerrypodWeb.Plugs.RateLimit, type: :api
## Types
- `:login` - 5 requests per minute per IP (auth endpoints)
- `:magic_link` - 3 requests per minute per email
- `:newsletter` - 10 requests per minute per IP
- `:api` - 60 requests per minute per IP
"""
import Plug.Conn
alias Berrypod.RateLimit
def init(opts), do: opts
def call(conn, opts) do
# Skip rate limiting in test environment
if Application.get_env(:berrypod, :env) == :test do
conn
else
do_rate_limit(conn, opts[:type])
end
end
defp do_rate_limit(conn, :login) do
case RateLimit.check_login(conn.remote_ip) do
:ok -> conn
{:error, retry_after} -> rate_limited(conn, retry_after)
end
end
defp do_rate_limit(conn, :magic_link) do
# For magic link, we need to check the email from params
# This is called after parsing, so we can access body params
email = get_email_from_params(conn)
if email do
case RateLimit.check_magic_link(email) do
:ok -> conn
{:error, retry_after} -> rate_limited(conn, retry_after)
end
else
# No email in request, let it through (will fail validation anyway)
conn
end
end
defp do_rate_limit(conn, :newsletter) do
case RateLimit.check_newsletter(conn.remote_ip) do
:ok -> conn
{:error, retry_after} -> rate_limited(conn, retry_after)
end
end
defp do_rate_limit(conn, :api) do
case RateLimit.check_api(conn.remote_ip) do
:ok -> conn
{:error, retry_after} -> rate_limited(conn, retry_after)
end
end
defp do_rate_limit(conn, _unknown) do
conn
end
defp rate_limited(conn, retry_after_ms) do
retry_after_seconds = max(div(retry_after_ms, 1000), 1)
conn
|> put_resp_header("retry-after", Integer.to_string(retry_after_seconds))
|> put_status(:too_many_requests)
|> Phoenix.Controller.put_view(BerrypodWeb.ErrorHTML)
|> Phoenix.Controller.render("429.html")
|> halt()
end
defp get_email_from_params(conn) do
# Try common param structures for email
conn.body_params["user"]["email"] ||
conn.body_params["email"] ||
conn.params["user"]["email"] ||
conn.params["email"]
end
end