Security Hardening Sprint — April 2026
Window: 2026-04-11 → 2026-04-20 Outcome: client-side surface area reduced to authenticated reads + App-Check-enforced callables. Governance writes validated with Zod schemas at both client and server. Identity proofs locked to server-only writes. Abuse registry + manual-review escalation shipped.
This doc consolidates the security posture changes after the Rocket retirement, so the threat model is one hop away from the root README rather than scattered across commit messages.
Why this sprint happened
Three concurrent pressures:
- TRUST is existential. Foundation's value proposition is "verified humans vote" — if the verification story has cracks, there's nothing to ship.
- The Rocket retirement exposed soft-bellies. Collapsing the Rust gateway
meant every write that used to run behind a Rust service now ran as a Cloud
Function reachable from any Firebase client. Without hardening, any
authenticated user could call
createProposalorcastVotewith a crafted payload. - External review pre-YC. A dedicated security-review pass on the shared
@plantagoai/*packages surfaced cross-cutting gaps — seedocs/security-review_shared-packages-2026-04-20.md.
What shipped
1. App Check on every callable — commit b997a66c (2026-04-20)
All 25 callables now route through functions/lib/app-check.js::callable().
When ENFORCE_APP_CHECK=true, requests without a valid App Check token (tied
to reCAPTCHA Enterprise for web, Play Integrity for Android) are rejected
before auth/handler logic runs. The helper also centralizes region, CORS,
timeout, and memory settings so they can't drift per-callable.
// functions/lib/app-check.js (summary)
export const ENFORCE_APP_CHECK = process.env.ENFORCE_APP_CHECK === "true";
export function callable(opts, handler) {
return onCall({
region: "us-central1",
cors: true,
enforceAppCheck: ENFORCE_APP_CHECK,
...opts,
}, handler);
}
Every callable in functions/index.js, functions/account-deletion.js, and
functions/legal.js uses this wrapper. functions/admin-status.js uses
onRequest (public health check).
2. Governance writes: schemas + auth — commits 9d8e23ca, 5e4792ab
Phase 0 hotfix (9d8e23ca) added requireAuth() to every write path in
functions/index.js. Before this, the callable guard was present but the
internal handlers still admitted unauthenticated shapes under some branches.
Phase 2.A (5e4792ab) codified every inbound write as a Zod schema in
@plantagoai/db and enforced it from both client and server. Client-side
validation is UX / fast-fail; server-side is the trust boundary. Schemas cover:
createProposal, castVote, submitSupport, activateProposal,
flagUserAbuse, unflagUserAbuse, approveAccessRequest, rejectAccessRequest,
inviteDirect, setAllowSelfRequest, requestSelfAccess.
3. CSP posture — reCAPTCHA Enterprise allowances — commits 52ee1cb4, a726374f
App Check on the web uses reCAPTCHA Enterprise, which posts telemetry back to
Google. The CSP in firebase.json was hardening-first and was rejecting the
telemetry. Two commits widened the policy narrowly:
52ee1cb4(2026-04-20) —script-srcandframe-srcallowhttps://www.google.com/recaptcha/andhttps://www.gstatic.com/recaptcha/a726374f(2026-04-20) —connect-srcallowshttps://www.google.com/recaptcha/for attestation posts
No broader unsafe-inline or unsafe-eval changes — only the specific origins
reCAPTCHA Enterprise needs.
4. identity_proofs → server-only writes — commit 6a641d82 (2026-04-11)
identity_proofs/{uid} previously allowed the authenticated user to write
their own document. That let a user overwrite their proof type / tier
client-side. After this commit, Firestore rules reject all client writes to
identity_proofs; the only writer is the verifyPassportProof Cloud Function.
The doc-shape is now one-way from server → client: PoH tier is computed from the attestation, the client only reads.
See firestore.rules and functions/lib/tier.js::proofTypeForAttestation,
trustTierFor.
5. Manual-review photos → Cloud Storage — commit ed6381bf (2026-04-20)
The manual-review fallback (when Self Protocol verification fails — e.g., for documents without MRZ like Portuguese Cartão de Cidadão or Israeli Teudat Zehut) previously stored the ID photo inline in Firestore. That put PII in a collection with looser access patterns and no automatic TTL.
After this commit, photos land in a Cloud Storage bucket with:
- Server-only write access
- Reviewer-only read access (ring-gated via
@plantagoai/auth) - 90-day retention policy matching the deletion-grace-period default
Firestore records only a storage path + metadata. See storage.rules.
6. Abuse registry + threat model — commit 2f5ccd5b (2026-04-20)
Per Proof-of-Humanity Phase 4, the user_abuse_flags collection now records
flags with reason codes, reviewer ID, and timestamp. The
flagUserAbuse/unflagUserAbuse callables are ring-gated (reviewers only).
Downstream callables (voting, proposal creation) check the registry before
admitting writes.
docs/poh-threat-model.md documents the full threat surface: Sybil attacks
against one-person-one-vote, document spoofing, Semaphore group
desynchronization, custody key exfiltration, rate-limit gaps. Mitigations map
1:1 against the codebase locations.
7. WebAuthn / Rocket-era dead code sweep — commit 6b2c0207 (2026-04-19)
Retired code paths that existed only because they were reachable from the Rust gateway. In particular, the older WebAuthn attestation flow (superseded by Firebase Auth + email-link invites) was removed — it was no longer referenced but left live import surface that could be abused.
Remaining work
Tracked in docs/security-fix-plan-2026-04-20.md and
docs/security-fix-plan-phase-2b-decisions-2026-04-20.md. Highlights:
- Anchor program redeploy from repo source. The currently deployed program
ID doesn't match the repo's Rust source (binary-source drift discovered
during the SBF toolchain audit — see
project_anchor_extension_pendingin auto-memory). A clean redeploy to a fresh program ID is pending. - Rate limiting at the callable layer. App Check raises the cost floor but
doesn't bound per-user request rate. A Firestore-backed sliding window keyed
by
context.auth.uidis planned. - Semaphore group audit cadence. Groups rebuild on every write via a trigger; we want a periodic re-verification job that catches desync before a vote starts rather than at vote time.
- Secret rotation runbook. Resend + Anthropic + Gmail SMTP + Solana devnet keypair — document the rotation steps and owners.
Commit reference
| SHA | Date | Scope |
|---|---|---|
4498a330 |
2026-04-20 | identity_proofs lockdown + App Check opt-in scaffolding |
b997a66c |
2026-04-20 | route all callables through App Check helper |
52ee1cb4 |
2026-04-20 | CSP: reCAPTCHA Enterprise script-src + frame-src |
a726374f |
2026-04-20 | CSP: reCAPTCHA Enterprise telemetry in connect-src |
9d8e23ca |
2026-04-20 | Phase 0 hotfix — require auth on governance writes |
5e4792ab |
2026-04-20 | Phase 2.A — governance schemas + client-write validation |
ed6381bf |
2026-04-20 | Phase 9 — manual-review photos to Cloud Storage |
2f5ccd5b |
2026-04-20 | PoH Phase 4 — abuse registry + threat model + E2E |
6a641d82 |
2026-04-11 | Phase 1.5 — lock identity_proofs to server-only writes |
6b2c0207 |
2026-04-19 | Phase 11 sweep — delete WebAuthn/Rocket-era dead code paths |
Related docs:
docs/poh-threat-model.mddocs/security-review_shared-packages-2026-04-20.mddocs/security-fix-plan-2026-04-20.mddocs/security-fix-plan-phase-2b-decisions-2026-04-20.md