Enable pilot abuse limits

This commit is contained in:
SimpleTest 2026-08-03 02:31:04 +03:00
parent 7be5fcf478
commit 741359e476
13 changed files with 86 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" <<EOF
MIX_ENV=test
DB_HOST=db
DB_USER=$POSTGRES_USER
DB_PASSWORD=$POSTGRES_PASSWORD
TEST_POOL_SIZE=${TEST_POOL_SIZE:-10}
EOF
image_tag_is_referenced() {
expected_image=$1
@ -44,6 +54,8 @@ cleanup() {
cleanup_status=0
trap - EXIT HUP INT TERM
rm -f "$runtime_env" || cleanup_status=$?
if docker inspect "$container" >/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