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:
@@ -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,
|
||||
|
||||
81
lib/berrypod/rate_limit.ex
Normal file
81
lib/berrypod/rate_limit.ex
Normal 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
|
||||
Reference in New Issue
Block a user