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

@ -151,3 +151,4 @@ All plans in [docs/plans/](docs/plans/). Completed plans are kept as architectur
| [notification-overhaul.md](docs/plans/notification-overhaul.md) | Planned |
| [live-site-editor.md](docs/plans/live-site-editor.md) | Design exploration |
| [profit-aware-pricing.md](docs/plans/profit-aware-pricing.md) | Planned |
| [security-hardening.md](docs/plans/security-hardening.md) | Planned |

View File

@ -64,7 +64,9 @@ if config_env() == :prod do
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
secret_key_base: secret_key_base,
# HSTS tells browsers to always use HTTPS for this domain (1 year, include subdomains)
force_ssl: [hsts: true, rewrite_on: [:x_forwarded_proto]]
# ## SSL Support
#

View File

@ -0,0 +1,247 @@
# Security hardening
Status: Planned
## Context
Berrypod is designed as a simpler, safer alternative to WordPress/WooCommerce for print-on-demand sellers. Compared to WordPress, it has significant architectural advantages:
- No plugin ecosystem = no plugin vulnerabilities
- Compiled Elixir binary = no code injection
- Image uploads stored as database BLOBs = no filesystem execution risk
- Ecto changesets = parameterised queries, SQL injection structurally prevented
- Built-in CSRF via Phoenix = consistent protection
- Encrypted secrets in database = no plaintext credentials
However, to make security a genuine selling point, several gaps need addressing. This plan covers three phases of hardening to bring Berrypod to best-in-class security.
## Current state (audit summary)
| Area | Current | Gap |
|------|---------|-----|
| Password hashing | Bcrypt with timing attack prevention | OK |
| Session management | Token-based, 7-day rotation, signed cookies | OK |
| Magic links | SHA256 hashed, 15-min expiry | OK |
| 2FA | None | **Critical gap** |
| Rate limiting | None | **Critical gap** |
| HSTS | Commented out in config | **Should enable** |
| CSP | Basic (`frame-ancestors`, `base-uri` only) | Could be stronger |
| SVG uploads | Stored safely, recolored via validated params | Minor XSS risk |
| Audit logging | Activity log exists but not comprehensive | Enhancement |
| Session encryption | Signed but not encrypted | Enhancement |
## Design
### Phase 1: Essentials
**1.1 Rate limiting**
Add rate limiting using the `hammer` library. Apply to:
- Login attempts: 5 per minute per IP
- Magic link requests: 3 per minute per email
- API endpoints: 60 per minute per IP
- Newsletter signup: 10 per minute per IP
Implementation via a `RateLimitPlug` that checks `Hammer.check_rate/3` and returns 429 on breach.
**1.2 HSTS headers**
Enable `force_ssl` in production config with HSTS headers:
```elixir
config :berrypod, BerrypodWeb.Endpoint,
force_ssl: [hsts: true, rewrite_on: [:x_forwarded_proto]]
```
The `rewrite_on` handles Fly.io's SSL termination at the proxy.
**1.3 Two-factor authentication (TOTP)**
Add TOTP-based 2FA for the admin account:
- New schema: `user_totp` (secret, enabled_at, backup_codes)
- Setup flow: show QR code, verify code, generate backup codes
- Login flow: after password, prompt for TOTP code
- Backup codes: 8 single-use codes, stored hashed
- Recovery: use backup code to disable 2FA and re-setup
Libraries: `nimble_totp` for TOTP generation/verification.
### Phase 2: Hardening
**2.1 Comprehensive CSP**
Extend CSP headers to restrict script/style sources:
```
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' wss:;
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
```
Note: `'unsafe-inline'` for styles is required because we inject theme CSS variables dynamically. Could use nonces but adds complexity.
**2.2 SVG sanitisation**
When uploading SVGs, strip potentially dangerous elements:
- Remove `<script>` tags
- Remove `on*` event handlers (onclick, onload, etc.)
- Remove `<foreignObject>` (can contain HTML)
- Remove `javascript:` URLs in `href`/`xlink:href`
Use a simple regex-based sanitiser (SVGs are simple enough that a full parser is overkill). Run on upload, store sanitised version.
**2.3 Comprehensive audit logging**
Extend activity log to capture all admin actions:
| Event | Data |
|-------|------|
| Admin login | IP, user agent, success/failure |
| Settings changed | Which setting, old/new value (secrets masked) |
| Theme changed | Which property |
| Page edited | Page slug, blocks changed |
| Order status changed | Order number, old/new status |
| Provider credentials updated | Provider name |
| Image uploaded/deleted | Image ID, filename |
Store in existing `activity_log` table. Add retention policy (90 days default).
**2.4 Session encryption**
Add encryption to session cookies:
```elixir
@session_options [
store: :cookie,
key: "_berrypod_key",
signing_salt: "JNwRcD7y",
encryption_salt: "zKp4Q1vE", # Add this
same_site: "Lax",
max_age: 604_800
]
```
Sessions will be both signed (integrity) and encrypted (confidentiality).
### Phase 3: Advanced (future)
Not in this plan but noted for future:
- WebAuthn/passkey support
- Automated security scanning in CI
- Penetration testing
- Database encryption at rest
- Login anomaly detection (geo, device fingerprint)
## Changes
### Phase 1
| File | Change |
|------|--------|
| `mix.exs` | Add `hammer`, `hammer_ets`, `nimble_totp` deps |
| `lib/berrypod/application.ex` | Start Hammer supervisor |
| `lib/berrypod_web/plugs/rate_limit.ex` | New — rate limit plug |
| `lib/berrypod_web/router.ex` | Add rate limit plug to pipelines |
| `config/runtime.exs` | Enable `force_ssl` with HSTS |
| `lib/berrypod/accounts/user_totp.ex` | New — TOTP schema |
| `lib/berrypod/accounts.ex` | Add TOTP functions |
| `lib/berrypod_web/live/auth/totp_setup.ex` | New — 2FA setup LiveView |
| `lib/berrypod_web/live/auth/totp_verify.ex` | New — 2FA challenge LiveView |
| `lib/berrypod_web/user_auth.ex` | Integrate TOTP verification in login flow |
| `lib/berrypod_web/live/admin/settings.ex` | Add 2FA setup section |
| Migration | Add `user_totp` table |
| Tests | Rate limit plug, TOTP setup/verify, login with 2FA |
### Phase 2
| File | Change |
|------|--------|
| `lib/berrypod_web/plugs/content_security_policy.ex` | New — CSP plug |
| `lib/berrypod_web/router.ex` | Add CSP plug to browser pipeline |
| `lib/berrypod/media/svg_sanitiser.ex` | New — SVG sanitisation |
| `lib/berrypod/media.ex` | Call sanitiser on SVG upload |
| `lib/berrypod/activity_log.ex` | Add new event types |
| `lib/berrypod_web/endpoint.ex` | Add encryption_salt to session options |
| Tests | CSP headers, SVG sanitisation, audit events |
## Tasks
### Phase 1: Essentials
| # | Task | Est | Status |
|---|------|-----|--------|
| 1 | Add `hammer` and `hammer_ets` deps, start supervisor | 30m | planned |
| 2 | Create rate limit plug with configurable limits | 1h | planned |
| 3 | Apply rate limiting to login, magic link, API, newsletter | 1h | planned |
| 4 | Enable HSTS in production config | 15m | planned |
| 5 | Add `nimble_totp` dep, create user_totp schema and migration | 45m | planned |
| 6 | Add TOTP functions to Accounts context | 1h | planned |
| 7 | Create TOTP setup LiveView (QR code, verification, backup codes) | 2h | planned |
| 8 | Create TOTP verification LiveView (login challenge) | 1h | planned |
| 9 | Integrate TOTP into login flow | 1.5h | planned |
| 10 | Add 2FA section to admin settings page | 1h | planned |
| 11 | Tests for rate limiting | 1h | planned |
| 12 | Tests for TOTP setup and verification | 1.5h | planned |
### Phase 2: Hardening
| # | Task | Est | Status |
|---|------|-----|--------|
| 13 | Create CSP plug with comprehensive policy | 1h | planned |
| 14 | Create SVG sanitiser module | 1.5h | planned |
| 15 | Integrate SVG sanitiser into upload flow | 30m | planned |
| 16 | Extend activity log with comprehensive admin events | 2h | planned |
| 17 | Add encryption_salt to session config | 15m | planned |
| 18 | Tests for CSP, SVG sanitiser, audit events | 2h | planned |
**Total estimate: ~18 hours**
## Verification
### Phase 1
1. Rate limiting: Rapid login attempts return 429 after limit exceeded
2. HSTS: `curl -I` shows `strict-transport-security` header in production
3. 2FA setup: QR code scans in authenticator app, codes verify
4. 2FA login: After password, prompted for TOTP code
5. Backup codes: Can disable 2FA using backup code
6. `mix precommit` passes
### Phase 2
1. CSP: Browser dev tools show full CSP header on responses
2. SVG sanitisation: Uploaded SVG with `<script>` has it stripped
3. Audit log: Settings change shows in activity log with before/after
4. Session encryption: Cookie is encrypted (not readable without secret)
5. `mix precommit` passes
## Security claims checklist
After completing this plan, Berrypod can legitimately claim:
- [x] No plugin vulnerabilities (architectural)
- [x] No filesystem code execution (architectural)
- [x] SQL injection prevented (Ecto)
- [x] CSRF protection built-in (Phoenix)
- [x] Bcrypt password hashing
- [x] Timing-attack-safe authentication
- [x] Encrypted secrets storage
- [ ] Two-factor authentication (Phase 1)
- [ ] Rate limiting on auth endpoints (Phase 1)
- [ ] HSTS enforced (Phase 1)
- [ ] Comprehensive CSP headers (Phase 2)
- [ ] SVG upload sanitisation (Phase 2)
- [ ] Full admin audit trail (Phase 2)
- [ ] Encrypted session cookies (Phase 2)
This positions Berrypod as genuinely more secure than a typical WordPress/WooCommerce installation, with much lower ongoing maintenance burden.

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

View File

@ -80,7 +80,8 @@ defmodule Berrypod.MixProject do
{:error_tracker, "~> 0.7"},
{:logger_json, "~> 7.0", only: :prod},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:hammer, "~> 7.0"}
]
end

View File

@ -33,6 +33,7 @@
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},