add rate limiting and HSTS for security hardening
Some checks failed
deploy / deploy (push) Failing after 8m33s
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:
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
91
lib/berrypod_web/plugs/rate_limit.ex
Normal file
91
lib/berrypod_web/plugs/rate_limit.ex
Normal 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
|
||||
Reference in New Issue
Block a user