add rate limiting and HSTS for security hardening
Some checks failed
deploy / deploy (push) Failing after 8m33s

- 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>
This commit is contained in:
jamey
2026-03-08 08:58:43 +00:00
parent 48eb7a9d9c
commit 0c2d4ac406
14 changed files with 507 additions and 4 deletions

View File

@@ -14,6 +14,8 @@ defmodule Berrypod.Application do
Berrypod.ActivityLog.ObanTelemetryHandler.attach()
children = [
# Rate limiting (ETS-backed, must start early)
Berrypod.RateLimit,
BerrypodWeb.Telemetry,
Berrypod.Repo,
{Ecto.Migrator,

View File

@@ -0,0 +1,81 @@
defmodule Berrypod.RateLimit do
@moduledoc """
Rate limiting using Hammer with ETS backend.
Provides protection against brute force attacks on auth endpoints
and DoS attacks on API endpoints.
## Limits
- Login attempts: 5 per minute per IP
- Magic link requests: 3 per minute per email
- Newsletter signup: 10 per minute per IP
- API requests: 60 per minute per IP
## Usage
case Berrypod.RateLimit.check_login(ip_address) do
:ok -> proceed_with_login()
{:error, retry_after_ms} -> return_429(retry_after_ms)
end
"""
use Hammer, backend: :ets
# Limits (scale in milliseconds, limit is max hits per scale)
@login_scale :timer.minutes(1)
@login_limit 5
@magic_link_scale :timer.minutes(1)
@magic_link_limit 3
@newsletter_scale :timer.minutes(1)
@newsletter_limit 10
@api_scale :timer.minutes(1)
@api_limit 60
@doc """
Check rate limit for login attempts.
Returns :ok or {:error, retry_after_ms}
"""
def check_login(ip) do
key = "login:#{format_ip(ip)}"
check(key, @login_scale, @login_limit)
end
@doc """
Check rate limit for magic link requests.
Uses email as key to prevent enumeration attacks.
"""
def check_magic_link(email) do
key = "magic_link:#{email}"
check(key, @magic_link_scale, @magic_link_limit)
end
@doc """
Check rate limit for newsletter signups.
"""
def check_newsletter(ip) do
key = "newsletter:#{format_ip(ip)}"
check(key, @newsletter_scale, @newsletter_limit)
end
@doc """
Check rate limit for API requests.
"""
def check_api(ip) do
key = "api:#{format_ip(ip)}"
check(key, @api_scale, @api_limit)
end
defp check(key, scale, limit) do
case hit(key, scale, limit) do
{:allow, _count} -> :ok
{:deny, retry_after} -> {:error, retry_after}
end
end
defp format_ip(ip) when is_tuple(ip), do: :inet.ntoa(ip) |> to_string()
defp format_ip(ip) when is_binary(ip), do: ip
end

View File

@@ -1165,7 +1165,15 @@ defmodule BerrypodWeb.ShopComponents.Content do
)
}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<svg
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
@@ -1207,7 +1215,15 @@ defmodule BerrypodWeb.ShopComponents.Content do
)
}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<svg
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>

View File

@@ -11,6 +11,8 @@ defmodule BerrypodWeb.CartController do
alias Berrypod.Cart
plug BerrypodWeb.Plugs.RateLimit, [type: :api] when action == :update
@doc """
Updates the cart in session (JSON API for JS hook).
"""

View File

@@ -3,6 +3,8 @@ defmodule BerrypodWeb.ContactController do
alias Berrypod.ContactNotifier
plug BerrypodWeb.Plugs.RateLimit, [type: :api] when action == :create
@doc """
Handles contact form submission (no-JS fallback).
"""

View File

@@ -32,6 +32,11 @@ defmodule BerrypodWeb.ErrorHTML do
)
end
def render("429.html", assigns) do
# 429 gets a bare themed page - just the message, no nav/footer/hero
render_bare_error(assigns, "429", "Too many requests", "Please wait a moment and try again.")
end
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
@@ -53,6 +58,54 @@ defmodule BerrypodWeb.ErrorHTML do
end
end
defp render_bare_error(assigns, error_code, error_title, error_description) do
{theme_settings, generated_css} = load_theme_data()
assigns =
assigns
|> Map.put(:error_code, error_code)
|> Map.put(:error_title, error_title)
|> Map.put(:error_description, error_description)
|> Map.put(:theme_settings, theme_settings)
|> Map.put(:generated_css, generated_css)
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@error_code} - {@error_title}</title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} />
<style id="theme-css">
<%= Phoenix.HTML.raw(@generated_css) %>
</style>
</head>
<body>
<div
class="shop-root themed"
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
>
<main style="min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 2rem;">
<p style="font-size: 4rem; font-weight: 700; margin: 0; color: var(--color-text);">
{@error_code}
</p>
<h1 style="font-size: 1.5rem; font-weight: 600; margin: 0.5rem 0; color: var(--color-text);">
{@error_title}
</h1>
<p style="font-size: 1rem; opacity: 0.6; color: var(--color-text);">
{@error_description}
</p>
</main>
</div>
</body>
</html>
"""
end
defp render_minimal_error(assigns) do
~H"""
<!DOCTYPE html>

View File

@@ -3,6 +3,8 @@ defmodule BerrypodWeb.NewsletterController do
alias Berrypod.Newsletter
plug BerrypodWeb.Plugs.RateLimit, [type: :newsletter] when action == :subscribe
@doc "No-JS fallback for newsletter signup form."
def subscribe(conn, %{"email" => email}) do
ip_hash = hash_ip(conn)

View File

@@ -4,6 +4,8 @@ defmodule BerrypodWeb.UserSessionController do
alias Berrypod.Accounts
alias BerrypodWeb.UserAuth
plug BerrypodWeb.Plugs.RateLimit, [type: :login] when action == :create
def create(conn, %{"_action" => "confirmed"} = params) do
create(conn, params, "User confirmed successfully.")
end

View File

@@ -0,0 +1,91 @@
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