From 741359e4762f55bf30420ed0a8c96340dcd6513d Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Mon, 3 Aug 2026 02:31:04 +0300 Subject: [PATCH] Enable pilot abuse limits --- .env.example | 12 ++++++++---- README.md | 11 ++++++++--- compose.yaml | 4 +++- config/config.exs | 19 ++++++++++++++++++- config/runtime.exs | 2 +- config/test.exs | 4 ++++ deploy/helm/who-need-help/values.yaml | 5 +++-- docs/architecture.md | 11 +++++++---- docs/performance.md | 8 ++++---- docs/support-and-content-removal.md | 8 +++++--- docs/trust-safety.md | 12 ++++++++---- lib/who_need_help/trust/rate_limiter.ex | 6 ++++-- scripts/test.sh | 18 +++++++++++++----- 13 files changed, 86 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index bc7cdc9..c34fef5 100644 --- a/.env.example +++ b/.env.example @@ -234,12 +234,16 @@ PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS=86400 PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS=31536000 CODEX_SESSION_ID=copy-the-main-local-codex-session-id -# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved. +# Shared PostgreSQL-backed pilot policies. This explicit value makes an +# operator's effective policy reviewable without inspecting the image. Remove +# the value to use the same compiled pilot default; set exactly {} only to +# disable every counter in an isolated benchmark/test environment. # Shape: {"action_name":{"limit":POSITIVE_INTEGER,"window_seconds":POSITIVE_INTEGER}} # Authentication delivery uses paired email/IP actions: # registration_email + registration_ip, magic_link_email + magic_link_ip, # password_login_email + password_login_ip, email_change_email + email_change_ip. # Public support intake uses support_request (account/email scope) together with -# support_request_ip (trusted client-IP scope). Choose limits from measured traffic; -# the application does not invent a universal threshold. -RATE_LIMIT_POLICIES_JSON={} +# support_request_ip (trusted client-IP scope). IP ceilings are intentionally +# higher than account/email ceilings so shared networks are not treated as one +# person. These are initial pilot product limits, not universal recommendations. +RATE_LIMIT_POLICIES_JSON={"registration_email":{"limit":4,"window_seconds":3600},"registration_ip":{"limit":120,"window_seconds":3600},"magic_link_email":{"limit":4,"window_seconds":3600},"magic_link_ip":{"limit":120,"window_seconds":3600},"password_login_email":{"limit":10,"window_seconds":900},"password_login_ip":{"limit":300,"window_seconds":900},"email_change_email":{"limit":3,"window_seconds":86400},"email_change_ip":{"limit":60,"window_seconds":3600},"support_request":{"limit":5,"window_seconds":86400},"support_request_ip":{"limit":120,"window_seconds":3600},"content_removal_notice":{"limit":20,"window_seconds":86400},"content_removal_notice_ip":{"limit":120,"window_seconds":3600}} diff --git a/README.md b/README.md index c7d3209..444e001 100644 --- a/README.md +++ b/README.md @@ -602,7 +602,7 @@ access or be restricted. The complete role matrix and operator workflow are in ./scripts/bootstrap-admin.sh you@example.com --confirm kind ``` -## Optional shared action limits +## Shared action limits `RATE_LIMIT_POLICIES_JSON` configures atomic PostgreSQL counters shared by every web replica. Its shape is: @@ -612,8 +612,13 @@ web replica. Its shape is: ``` The strings above describe the required types and are not a runnable policy. -Keep `{}` until numeric limits have been approved from policy and observed -traffic. Supported actions are listed in `docs/trust-safety.md`. +The shipped pilot policy covers public authentication and anonymous +support/content-removal intake. Account/email ceilings are lower than IP +ceilings so a shared network is not treated as one person. The complete +effective JSON is kept in `.env`; an absent value uses the compiled pilot +default, while an explicit `{}` disables all counters for isolated load/E2E +runs. These values are an initial product policy, not universal security or +capacity thresholds. Supported actions are listed in `docs/trust-safety.md`. ## Local Kubernetes verification diff --git a/compose.yaml b/compose.yaml index ee5f110..089de88 100644 --- a/compose.yaml +++ b/compose.yaml @@ -29,7 +29,9 @@ x-app-environment: &app-environment EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env} SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-} CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured} - RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}} + # Empty/unset uses the compiled pilot policy. Set exactly {} only for an + # isolated benchmark or test environment that must disable every counter. + RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-} PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS: ${PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS:-86400} PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS: ${PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS:-31536000} MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png} diff --git a/config/config.exs b/config/config.exs index fa56af3..6e90caa 100644 --- a/config/config.exs +++ b/config/config.exs @@ -33,7 +33,24 @@ config :who_need_help, codex_session_id: "not-configured", e2e_routes: false, secure_cookies: false, - rate_limit_policies: %{}, + # Initial pilot policy for public authentication and anonymous intake. These + # values are product policy, not a claim about universal security thresholds + # or database capacity. Runtime JSON can replace the complete map, and an + # explicit `{}` disables every shared counter for isolated load/E2E runs. + rate_limit_policies: %{ + "registration_email" => %{limit: 4, window_seconds: 3_600}, + "registration_ip" => %{limit: 120, window_seconds: 3_600}, + "magic_link_email" => %{limit: 4, window_seconds: 3_600}, + "magic_link_ip" => %{limit: 120, window_seconds: 3_600}, + "password_login_email" => %{limit: 10, window_seconds: 900}, + "password_login_ip" => %{limit: 300, window_seconds: 900}, + "email_change_email" => %{limit: 3, window_seconds: 86_400}, + "email_change_ip" => %{limit: 60, window_seconds: 3_600}, + "support_request" => %{limit: 5, window_seconds: 86_400}, + "support_request_ip" => %{limit: 120, window_seconds: 3_600}, + "content_removal_notice" => %{limit: 20, window_seconds: 86_400}, + "content_removal_notice_ip" => %{limit: 120, window_seconds: 3_600} + }, public_contact_verification_max_age_seconds: 86_400, public_case_access_max_age_seconds: 31_536_000, tracking_presence_cleanup_grace_ms: 5_000, diff --git a/config/runtime.exs b/config/runtime.exs index f3a4b2c..3394d9b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -12,7 +12,7 @@ app_role = rate_limit_policies = case System.get_env("RATE_LIMIT_POLICIES_JSON") do value when value in [nil, ""] -> - %{} + Application.fetch_env!(:who_need_help, :rate_limit_policies) json -> case Jason.decode(json) do diff --git a/config/test.exs b/config/test.exs index c692d17..406a121 100644 --- a/config/test.exs +++ b/config/test.exs @@ -2,6 +2,10 @@ import Config config :who_need_help, :handover_secret, "isolated-test-handover-secret" config :who_need_help, :tracking_presence_cleanup_grace_ms, 100 +# Unit tests opt in to individual policies inside the relevant test. This keeps +# unrelated examples independent from shared counters and mirrors the explicit +# `{}` used by the isolated E2E/load environments. +config :who_need_help, :rate_limit_policies, %{} # Only in tests, remove the complexity from the password hashing algorithm config :bcrypt_elixir, :log_rounds, 1 diff --git a/deploy/helm/who-need-help/values.yaml b/deploy/helm/who-need-help/values.yaml index e856572..c83b57d 100644 --- a/deploy/helm/who-need-help/values.yaml +++ b/deploy/helm/who-need-help/values.yaml @@ -30,8 +30,9 @@ app: erlangPortLimit: 65536 clusterInterface: eth0 codexSessionId: not-configured - # Shared limits are opt-in; set only after product policy thresholds are approved. - rateLimitPoliciesJson: "{}" + # Initial pilot product policy. Use exactly "{}" only for an isolated test + # release that intentionally disables every shared counter. + rateLimitPoliciesJson: '{"registration_email":{"limit":4,"window_seconds":3600},"registration_ip":{"limit":120,"window_seconds":3600},"magic_link_email":{"limit":4,"window_seconds":3600},"magic_link_ip":{"limit":120,"window_seconds":3600},"password_login_email":{"limit":10,"window_seconds":900},"password_login_ip":{"limit":300,"window_seconds":900},"email_change_email":{"limit":3,"window_seconds":86400},"email_change_ip":{"limit":60,"window_seconds":3600},"support_request":{"limit":5,"window_seconds":86400},"support_request_ip":{"limit":120,"window_seconds":3600},"content_removal_notice":{"limit":20,"window_seconds":86400},"content_removal_notice_ip":{"limit":120,"window_seconds":3600}}' publicContactVerificationMaxAgeSeconds: "86400" publicCaseAccessMaxAgeSeconds: "31536000" mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png diff --git a/docs/architecture.md b/docs/architecture.md index b899d4b..f284178 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -204,10 +204,13 @@ backups and a tested recovery procedure. Action buckets live in PostgreSQL and use an atomic upsert keyed by action, hashed scope, and aligned time window. This works across all web replicas -without an in-memory or Redis singleton. Policies are supplied through -`RATE_LIMIT_POLICIES_JSON`; no numeric product policy is compiled into the -application. Authentication checks combine the email and client-IP buckets in -one `INSERT ... ON CONFLICT` statement. Failed transactional-email delivery +without an in-memory or Redis singleton. Policies can be replaced through +`RATE_LIMIT_POLICIES_JSON`. The compiled pilot default covers authentication +and anonymous public intake; an explicit `{}` disables all counters for +isolated test/load runs. Account/email ceilings are lower than IP ceilings so +shared networks are not treated as one person. These values are product policy +rather than a database-capacity claim. Authentication checks combine the email +and client-IP buckets in one `INSERT ... ON CONFLICT` statement. Failed transactional-email delivery removes only the token created for that failed attempt. Expired buckets and tokens are pruned by the maintenance worker. diff --git a/docs/performance.md b/docs/performance.md index f182f4e..98b9034 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -423,13 +423,13 @@ requests or activities during the observation, and `/requests` and joins, chat, tracking, handover, and write throughput therefore remain covered by the isolated profiles rather than this public production probe. -The deployed environment had `RATE_LIMIT_POLICIES_JSON={}`. In that +The environment measured in this historical production observation had +`RATE_LIMIT_POLICIES_JSON={}`. In that configuration `RateLimiter.check/2` returns `:not_configured` without a database query, so this observation neither exercises nor validates authentication throttling. It also provides no evidence that Redis is needed. The current -candidate keeps cross-replica counters in PostgreSQL and requires explicit -email- and IP-scoped policies before public authentication traffic is -protected. +candidate keeps cross-replica counters in PostgreSQL and ships a pilot +email/IP policy; its limiter overhead still requires a separate measured run. Ignored evidence: diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 1d5cae4..0aba95d 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -167,9 +167,11 @@ resource when an app allows account creation: limiter. Public support and content-removal intake each check both their normalized account/email scope and the trusted client-IP scope in one database statement. This prevents a public submitter from evading the network policy by -rotating contact addresses. As with the other actions, no numeric policy is -enabled unless the operator supplies values justified by measured traffic -through `RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL +rotating contact addresses. The initial pilot policy enables both scope types; +operators can replace the complete map through `RATE_LIMIT_POLICIES_JSON`, and +an explicit `{}` is reserved for isolated E2E/load runs. The values are product +policy, not a universal abuse or capacity threshold. CSRF protection, +validation, exact URL limits, contact verification, staff authorization, and audit events apply regardless. diff --git a/docs/trust-safety.md b/docs/trust-safety.md index d9402a1..9d44a63 100644 --- a/docs/trust-safety.md +++ b/docs/trust-safety.md @@ -87,14 +87,18 @@ verification. - Missing optional proximity/movement, repeated pairs, reciprocal direction, and configured action velocity create review signals. They do not automatically punish an account. -- PostgreSQL action buckets enforce only operator-supplied policies across all - replicas. Registration, password login, magic-link delivery, and email +- PostgreSQL action buckets enforce the compiled pilot policy or an explicit + operator replacement across all replicas. Registration, password login, + magic-link delivery, and email changes can each be limited by both normalized email and client IP in one atomic database statement. - No public exact location by default. -No numeric rate policy is enabled by default because no threshold has been -approved for this deployment. Authentication action pairs are +The initial pilot policy enables only public authentication, email change, and +anonymous support/content-removal intake. Its account/email ceilings are lower +than its IP ceilings so a shared network is not treated as one person. The +values are product policy rather than universal security or capacity +thresholds. Authentication action pairs are `registration_email`/`registration_ip`, `magic_link_email`/`magic_link_ip`, `password_login_email`/`password_login_ip`, and diff --git a/lib/who_need_help/trust/rate_limiter.ex b/lib/who_need_help/trust/rate_limiter.ex index 23cd7f7..c633d87 100644 --- a/lib/who_need_help/trust/rate_limiter.ex +++ b/lib/who_need_help/trust/rate_limiter.ex @@ -2,8 +2,10 @@ defmodule WhoNeedHelp.Trust.RateLimiter do @moduledoc """ Shared PostgreSQL-backed action limits. - Policies are opt-in and supplied as a map whose values contain positive - `limit` and `window_seconds` integers. No product thresholds are assumed. + Policies are supplied as a map whose values contain positive `limit` and + `window_seconds` integers. The application ships a pilot product policy, + while runtime configuration can replace the complete map or explicitly use + an empty map for an isolated test environment. """ import Ecto.Query diff --git a/scripts/test.sh b/scripts/test.sh index d34f987..6c90bba 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -25,6 +25,16 @@ fi run_id="$(date -u +%Y%m%d%H%M%S)-$$" image="who-need-help:test-$run_id" container="who-need-help-test-$run_id" +runtime_env="$(mktemp "${TMPDIR:-/tmp}/who-need-help-test-env.XXXXXX")" +chmod 600 "$runtime_env" + +cat >"$runtime_env" </dev/null 2>&1; then docker rm -f "$container" >/dev/null 2>&1 || cleanup_status=$? fi @@ -71,10 +83,6 @@ docker build --target test --tag "$image" . docker run --rm \ --name "$container" \ --network who_need_help_internal \ - --env MIX_ENV=test \ - --env DB_HOST=db \ - --env "DB_USER=$POSTGRES_USER" \ - --env "DB_PASSWORD=$POSTGRES_PASSWORD" \ - --env TEST_POOL_SIZE="${TEST_POOL_SIZE:-10}" \ + --env-file "$runtime_env" \ "$image" \ mix test