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

@@ -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