Status: accepted · Date: 2026-11-07 · Related: ADR-0017, ADR-0019, ADR-0020, ADR-0021
Enforcement: packages/api/src/features/municipality/municipality.procedures.test.ts (a 4th new opt-in from one source in a day is refused, line 529; updates and the retry of a lost first answer are not new, line 541; the 21st call from one address in an hour is refused, a withdrawal never, line 567); packages/api/src/platform/rpc/rate-limit.test.ts (the daily source key rotates by UTC day and separates addresses and secrets).
Context
The municipality opt-in is public and anonymous (ADR-0020). The only handle on a caller is the address the edge vouches for, context.ip (packages/api/src/platform/rpc/context.ts:122; cf-connecting-ip on Workers, packages/api/src/platform/rpc/rate-limit.ts:57-63). A district is published only from 20 homes up (MIN_HOMES_PER_DISTRICT = 20, packages/preparedness/src/aggregates.ts:9, applied at packages/api/src/features/municipality/municipality.service.ts:251). The attack is therefore fabricated homes: 19 of them put one real home into a published district.
The review (docs/varu/evidence/review-recheck-2.md, standards#3) marked the fix PARTLY: the per-source cap blocks a single-source burst, but seven sources can still fabricate 19 homes.
Decision
- Keep the caps as coded; no code change.
- 20 calls per address per hour for
municipality.opt-in(ANONYMOUS_LIMITS,packages/api/src/platform/rpc/rate-limit.ts:65), applied byanonymousRateLimit(packages/api/src/features/municipality/municipality.procedures.ts:65). - 3 new opt-ins per source per day:
NEW_OPT_INS_PER_SOURCE_PER_DAY = 3(rate-limit.ts:80), windowNEW_OPT_IN_WINDOW_SEC(rate-limit.ts:81), enforced inoptIn(municipality.service.ts:140-161). The source key contains the UTC date, so the count restarts at midnight UTC.
- 20 calls per address per hour for
- Source key:
dailySourceKey(context.ip, BETTER_AUTH_SECRET, now)is the SHA-256 of the server secret, the UTC date and the address (rate-limit.ts:90-93, called atmunicipality.procedures.ts:70-72). The counter stores the hash, never the address. RotatingBETTER_AUTH_SECRETchanges every key and so restarts the counters. - Only new opt-ins count. A call with a
token(an update) returns before the source check (municipality.service.ts:123-139). A retry of a lost first answer is matched by itsnext(municipality.service.ts:141-147). Withdrawals (optOut,municipality.procedures.ts:80-85) are not rate-limited. - The server is the only enforcer. The app does not mirror the caps; it refreshes its own count at most once a day (
COUNT_ME_REFRESH_MS,apps/mobile/src/features/settings/settings.count-me.ts:25). - Accepted for v1. No device attestation (App Attest / Play Integrity). Revisit together with the open question in ADR-0017: whether a municipality needs more than these caps.
Consequences
- A source adds at most 3 new homes a day. 19 fabricated homes need 7 sources in one day, or 7 days from one source (3 × 6 = 18 < 19).
- Many addresses still get past it, since each address is its own source (for example IPv6 ranges, botnets or rotating mobile carriers).
- Updates meet only the 20-calls-per-address-per-hour cap.
- The hourly key keeps the raw address (
anon:<limit>:<ip>,rate-limit.ts:105) in therate_limittable untilpackages/api/src/platform/jobs/auth-cleanup.tsdeletes rows idle for 24 h (AUTH_CLEANUP_GRACE_MS, line 14). The daily source key holds no address. ADR-0017 says the table never stores an address; that holds only for the source key. - A request without an address is limited neither hourly nor daily (
rate-limit.ts:102,municipality.procedures.ts:70). The code states that production on Workers always has one (rate-limit.ts:95-99). - Family sync and learned barcodes have per-address caps only, with no per-source daily cap:
family.create5 andfamily.push120 per address per hour (rate-limit.ts:67-68;packages/api/src/features/family/family.procedures.ts:39,:74), andbarcodes.learn30 per address per hour (rate-limit.ts:66;packages/api/src/features/barcodes/barcodes.procedures.ts:45). Many addresses are bounded only by these numbers. The exposure there is volume (families, storage, votes), not disclosure: family data is ciphertext (ADR-0021) and learned barcodes carry no household (ADR-0019).