# Residual multi-source risk on anonymous endpoints

The repository's architectural decision, rationale and consequences.

# Residual multi-source risk on anonymous endpoints

**Status:** accepted · **Date:** 2026-11-07 · **Related:** [ADR-0017](0017-eu-hosting.md), [ADR-0019](0019-barcode-adapter.md), [ADR-0020](0020-account-optional-household-app.md), [ADR-0021](0021-family-sync-with-anonymous-device-keys.md)

**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 [#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 [#decision]

1. **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 by `anonymousRateLimit` (`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`), window `NEW_OPT_IN_WINDOW_SEC` (`rate-limit.ts:81`), enforced in `optIn` (`municipality.service.ts:140-161`). The source key contains the UTC date, so the count restarts at midnight UTC.
2. **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 at `municipality.procedures.ts:70-72`). The counter stores the hash, never the address. Rotating `BETTER_AUTH_SECRET` changes every key and so restarts the counters.
3. **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 its `next` (`municipality.service.ts:141-147`). Withdrawals (`optOut`, `municipality.procedures.ts:80-85`) are not rate-limited.
4. **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`).
5. **Accepted for v1.** No device attestation (App Attest / Play Integrity). Revisit together with the open question in [ADR-0017](0017-eu-hosting.md): whether a municipality needs more than these caps.

## Consequences [#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 the `rate_limit` table until `packages/api/src/platform/jobs/auth-cleanup.ts` deletes rows idle for 24 h (`AUTH_CLEANUP_GRACE_MS`, line 14). The daily source key holds no address. [ADR-0017](0017-eu-hosting.md) 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.create` 5 and `family.push` 120 per address per hour (`rate-limit.ts:67-68`; `packages/api/src/features/family/family.procedures.ts:39`, `:74`), and `barcodes.learn` 30 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](0021-family-sync-with-anonymous-device-keys.md)) and learned barcodes carry no household ([ADR-0019](0019-barcode-adapter.md)).

