Enable pilot abuse limits
This commit is contained in:
parent
7be5fcf478
commit
741359e476
12
.env.example
12
.env.example
|
|
@ -234,12 +234,16 @@ PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS=86400
|
||||||
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS=31536000
|
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS=31536000
|
||||||
|
|
||||||
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
|
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}}
|
# Shape: {"action_name":{"limit":POSITIVE_INTEGER,"window_seconds":POSITIVE_INTEGER}}
|
||||||
# Authentication delivery uses paired email/IP actions:
|
# Authentication delivery uses paired email/IP actions:
|
||||||
# registration_email + registration_ip, magic_link_email + magic_link_ip,
|
# registration_email + registration_ip, magic_link_email + magic_link_ip,
|
||||||
# password_login_email + password_login_ip, email_change_email + email_change_ip.
|
# password_login_email + password_login_ip, email_change_email + email_change_ip.
|
||||||
# Public support intake uses support_request (account/email scope) together with
|
# Public support intake uses support_request (account/email scope) together with
|
||||||
# support_request_ip (trusted client-IP scope). Choose limits from measured traffic;
|
# support_request_ip (trusted client-IP scope). IP ceilings are intentionally
|
||||||
# the application does not invent a universal threshold.
|
# higher than account/email ceilings so shared networks are not treated as one
|
||||||
RATE_LIMIT_POLICIES_JSON={}
|
# 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}}
|
||||||
|
|
|
||||||
11
README.md
11
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
|
./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
|
`RATE_LIMIT_POLICIES_JSON` configures atomic PostgreSQL counters shared by every
|
||||||
web replica. Its shape is:
|
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.
|
The strings above describe the required types and are not a runnable policy.
|
||||||
Keep `{}` until numeric limits have been approved from policy and observed
|
The shipped pilot policy covers public authentication and anonymous
|
||||||
traffic. Supported actions are listed in `docs/trust-safety.md`.
|
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
|
## Local Kubernetes verification
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ x-app-environment: &app-environment
|
||||||
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env}
|
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env}
|
||||||
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
|
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
|
||||||
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
|
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_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}
|
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}
|
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,24 @@ config :who_need_help,
|
||||||
codex_session_id: "not-configured",
|
codex_session_id: "not-configured",
|
||||||
e2e_routes: false,
|
e2e_routes: false,
|
||||||
secure_cookies: 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_contact_verification_max_age_seconds: 86_400,
|
||||||
public_case_access_max_age_seconds: 31_536_000,
|
public_case_access_max_age_seconds: 31_536_000,
|
||||||
tracking_presence_cleanup_grace_ms: 5_000,
|
tracking_presence_cleanup_grace_ms: 5_000,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ app_role =
|
||||||
rate_limit_policies =
|
rate_limit_policies =
|
||||||
case System.get_env("RATE_LIMIT_POLICIES_JSON") do
|
case System.get_env("RATE_LIMIT_POLICIES_JSON") do
|
||||||
value when value in [nil, ""] ->
|
value when value in [nil, ""] ->
|
||||||
%{}
|
Application.fetch_env!(:who_need_help, :rate_limit_policies)
|
||||||
|
|
||||||
json ->
|
json ->
|
||||||
case Jason.decode(json) do
|
case Jason.decode(json) do
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import Config
|
||||||
|
|
||||||
config :who_need_help, :handover_secret, "isolated-test-handover-secret"
|
config :who_need_help, :handover_secret, "isolated-test-handover-secret"
|
||||||
config :who_need_help, :tracking_presence_cleanup_grace_ms, 100
|
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
|
# Only in tests, remove the complexity from the password hashing algorithm
|
||||||
config :bcrypt_elixir, :log_rounds, 1
|
config :bcrypt_elixir, :log_rounds, 1
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,9 @@ app:
|
||||||
erlangPortLimit: 65536
|
erlangPortLimit: 65536
|
||||||
clusterInterface: eth0
|
clusterInterface: eth0
|
||||||
codexSessionId: not-configured
|
codexSessionId: not-configured
|
||||||
# Shared limits are opt-in; set only after product policy thresholds are approved.
|
# Initial pilot product policy. Use exactly "{}" only for an isolated test
|
||||||
rateLimitPoliciesJson: "{}"
|
# 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"
|
publicContactVerificationMaxAgeSeconds: "86400"
|
||||||
publicCaseAccessMaxAgeSeconds: "31536000"
|
publicCaseAccessMaxAgeSeconds: "31536000"
|
||||||
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
|
|
|
||||||
|
|
@ -204,10 +204,13 @@ backups and a tested recovery procedure.
|
||||||
|
|
||||||
Action buckets live in PostgreSQL and use an atomic upsert keyed by action,
|
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
|
hashed scope, and aligned time window. This works across all web replicas
|
||||||
without an in-memory or Redis singleton. Policies are supplied through
|
without an in-memory or Redis singleton. Policies can be replaced through
|
||||||
`RATE_LIMIT_POLICIES_JSON`; no numeric product policy is compiled into the
|
`RATE_LIMIT_POLICIES_JSON`. The compiled pilot default covers authentication
|
||||||
application. Authentication checks combine the email and client-IP buckets in
|
and anonymous public intake; an explicit `{}` disables all counters for
|
||||||
one `INSERT ... ON CONFLICT` statement. Failed transactional-email delivery
|
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
|
removes only the token created for that failed attempt. Expired buckets and
|
||||||
tokens are pruned by the maintenance worker.
|
tokens are pruned by the maintenance worker.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -423,13 +423,13 @@ requests or activities during the observation, and `/requests` and
|
||||||
joins, chat, tracking, handover, and write throughput therefore remain covered
|
joins, chat, tracking, handover, and write throughput therefore remain covered
|
||||||
by the isolated profiles rather than this public production probe.
|
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
|
configuration `RateLimiter.check/2` returns `:not_configured` without a database
|
||||||
query, so this observation neither exercises nor validates authentication
|
query, so this observation neither exercises nor validates authentication
|
||||||
throttling. It also provides no evidence that Redis is needed. The current
|
throttling. It also provides no evidence that Redis is needed. The current
|
||||||
candidate keeps cross-replica counters in PostgreSQL and requires explicit
|
candidate keeps cross-replica counters in PostgreSQL and ships a pilot
|
||||||
email- and IP-scoped policies before public authentication traffic is
|
email/IP policy; its limiter overhead still requires a separate measured run.
|
||||||
protected.
|
|
||||||
|
|
||||||
Ignored evidence:
|
Ignored evidence:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,11 @@ resource when an app allows account creation:
|
||||||
limiter. Public support and content-removal intake each check both their
|
limiter. Public support and content-removal intake each check both their
|
||||||
normalized account/email scope and the trusted client-IP scope in one database
|
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
|
statement. This prevents a public submitter from evading the network policy by
|
||||||
rotating contact addresses. As with the other actions, no numeric policy is
|
rotating contact addresses. The initial pilot policy enables both scope types;
|
||||||
enabled unless the operator supplies values justified by measured traffic
|
operators can replace the complete map through `RATE_LIMIT_POLICIES_JSON`, and
|
||||||
through `RATE_LIMIT_POLICIES_JSON`. CSRF protection, validation, exact URL
|
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
|
limits, contact verification, staff authorization, and audit events apply
|
||||||
regardless.
|
regardless.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,14 +87,18 @@ verification.
|
||||||
- Missing optional proximity/movement, repeated pairs, reciprocal direction,
|
- Missing optional proximity/movement, repeated pairs, reciprocal direction,
|
||||||
and configured action velocity create review signals. They do not
|
and configured action velocity create review signals. They do not
|
||||||
automatically punish an account.
|
automatically punish an account.
|
||||||
- PostgreSQL action buckets enforce only operator-supplied policies across all
|
- PostgreSQL action buckets enforce the compiled pilot policy or an explicit
|
||||||
replicas. Registration, password login, magic-link delivery, and email
|
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
|
changes can each be limited by both normalized email and client IP in one
|
||||||
atomic database statement.
|
atomic database statement.
|
||||||
- No public exact location by default.
|
- No public exact location by default.
|
||||||
|
|
||||||
No numeric rate policy is enabled by default because no threshold has been
|
The initial pilot policy enables only public authentication, email change, and
|
||||||
approved for this deployment. Authentication action pairs are
|
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`,
|
`registration_email`/`registration_ip`,
|
||||||
`magic_link_email`/`magic_link_ip`,
|
`magic_link_email`/`magic_link_ip`,
|
||||||
`password_login_email`/`password_login_ip`, and
|
`password_login_email`/`password_login_ip`, and
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ defmodule WhoNeedHelp.Trust.RateLimiter do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Shared PostgreSQL-backed action limits.
|
Shared PostgreSQL-backed action limits.
|
||||||
|
|
||||||
Policies are opt-in and supplied as a map whose values contain positive
|
Policies are supplied as a map whose values contain positive `limit` and
|
||||||
`limit` and `window_seconds` integers. No product thresholds are assumed.
|
`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
|
import Ecto.Query
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@ fi
|
||||||
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
||||||
image="who-need-help:test-$run_id"
|
image="who-need-help:test-$run_id"
|
||||||
container="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() {
|
image_tag_is_referenced() {
|
||||||
expected_image=$1
|
expected_image=$1
|
||||||
|
|
@ -44,6 +54,8 @@ cleanup() {
|
||||||
cleanup_status=0
|
cleanup_status=0
|
||||||
trap - EXIT HUP INT TERM
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
rm -f "$runtime_env" || cleanup_status=$?
|
||||||
|
|
||||||
if docker inspect "$container" >/dev/null 2>&1; then
|
if docker inspect "$container" >/dev/null 2>&1; then
|
||||||
docker rm -f "$container" >/dev/null 2>&1 || cleanup_status=$?
|
docker rm -f "$container" >/dev/null 2>&1 || cleanup_status=$?
|
||||||
fi
|
fi
|
||||||
|
|
@ -71,10 +83,6 @@ docker build --target test --tag "$image" .
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--name "$container" \
|
--name "$container" \
|
||||||
--network who_need_help_internal \
|
--network who_need_help_internal \
|
||||||
--env MIX_ENV=test \
|
--env-file "$runtime_env" \
|
||||||
--env DB_HOST=db \
|
|
||||||
--env "DB_USER=$POSTGRES_USER" \
|
|
||||||
--env "DB_PASSWORD=$POSTGRES_PASSWORD" \
|
|
||||||
--env TEST_POOL_SIZE="${TEST_POOL_SIZE:-10}" \
|
|
||||||
"$image" \
|
"$image" \
|
||||||
mix test
|
mix test
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user