Skip to content

Don't store voter emails/IPs on rolls for elections that don't authenticate - #1477

Open
jacksonloper wants to merge 2 commits into
mainfrom
JacksonLoper/rollprivacy
Open

Don't store voter emails/IPs on rolls for elections that don't authenticate#1477
jacksonloper wants to merge 2 commits into
mainfrom
JacksonLoper/rollprivacy

Conversation

@jacksonloper

Copy link
Copy Markdown
Collaborator

The bug

getOrCreateElectionRoll derives two locals from the election's voter_authentication settings, correctly leaving them null when the election doesn't authenticate on them:

const require_ip_hash = (election.settings.voter_authentication.ip_address ? ip_hash : null) ?? null;
const email = election.settings.voter_authentication.email ? req.user?.email : null

The roll it then builds ignored both and copied the raw request values in:

email: req.user?.email ? req.user.email : undefined,
ip_hash: ip_hash,

The guard that decides whether to persist the roll does consult the settings, which is why this looked safe. It isn't. The cast-vote path reaches the "not persisted" else branch and returns the roll anyway, and CastVoteStore.submitBallotEvent inserts whatever roll it is handed:

const { address: _address, registration: _registration, ...rollToInsert } = event.roll;
await trx.insertInto('electionRollDB').values(rollToInsert).execute();

So on the voting path that guard is effectively dead code — it only protects a branch nothing reaches.

Net effect: an open_open election — voter_access: 'open' + voter_authentication: {}, the mode the UI presents as requiring no authentication at all — stores the voter's email address and an IP hash next to their ballot_id. The email lands whenever the voter happens to have a session cookie, which they often do; the IP hash lands always, session or not. Neither is anything the election asked for.

ElectionRoll.ip_hash is already documented in the domain model as "sha256(req.ip); set when voter_authentication.ip_address is enabled." After this change that's actually true.

Scope on prod

Querying electionRollDB joined against head elections with voter_authentication = '{}' and voter_access = 'open':

rows
ballots with an ip_hash stored 937
…of which also have an email 456 (70 elections)

Every one of those rows has both ballot_id set and submitted = true — i.e. all of them came through castVote, none from an admin adding voters. Still happening; most recent was yesterday.

Not proposing a data migration here — happy to do that separately.

How exposed is it, really?

Lower than it first looks, and worth stating plainly so this gets prioritised correctly:

  • GET /Election/:id/rolls refuses outright for voter_access: 'open' elections, owner included — so admins can't list this today.
  • GET /Election/:id/rolls/:voter_id scrubs ballot_id and ip_hash, but not email, and has no expectPermission call. Voter IDs aren't enumerable, and the party most likely to hold one is the voter themselves — but a forwarded ballot-update link (/:election_id/id/:voter_id, which receipt emails contain when ballot_updates is on) would disclose that voter's email. I've left that alone here; it's a separate fix.

So the practical case for this PR is data minimisation rather than an open door: the rows shouldn't exist, they're in every backup, and the only thing standing between them and disclosure is one if in one controller that any future endpoint or CSV export has to remember to reproduce.

What's in here

  • voterRollUtils.ts — use the already-computed email / require_ip_hash instead of the raw request values. Also corrected the comment on the else branch, which claimed nothing reaches it.
  • __mocks__/CastVoteStore.ts — the mock only called rollStore.update(), which no-ops when there's no head row to update. That's exactly the open_open case, so the mock silently dropped every roll the cast-vote path writes and this entire class of bug was invisible to the suite. It now inserts when there's no head row, matching the real store.
  • openElectionRollPrivacy.test.tsopen_open stores neither field; {email: true} still stores the email; {ip_address: true} still stores the hash and nothing else. Verified these fail without the voterRollUtils change.

The test asserts against the roll store rather than GET /rolls, since that endpoint won't list open elections — which is a decent part of why nobody noticed. The row was being written; nothing was reading it back.

Not affected

Receipt emails. castVoteController already falls back through event.roll?.email ?? extractUserFromRequest(req)?.email ?? req.body.receiptEmail, so a signed-in voter on an open_open election still gets their receipt from the second link in that chain.

Full backend suite: 158/158.

🤖 Generated with Claude Code

…ticate

getOrCreateElectionRoll computes `email` and `require_ip_hash` from the
election's voter_authentication settings, correctly leaving them null when
the election doesn't authenticate on them. But the roll it builds ignored
those and copied `req.user.email` and `hashString(req.ip)` in unconditionally.

The guard that decides whether to persist the roll does consult the settings,
so this looked safe. It isn't: the cast-vote path reaches the "not persisted"
branch and hands the roll to CastVoteStore.submitBallotEvent, which inserts
whatever it's given. So an open_open election — voter_access 'open',
voter_authentication {} , i.e. the mode that authenticates on nothing — still
wrote the signed-in voter's email address and an IP hash next to their
ballot_id.

Use the already-computed values instead. ElectionRoll.ip_hash is documented as
"set when voter_authentication.ip_address is enabled"; now it is.

Receipt emails are unaffected: castVoteController falls back to
extractUserFromRequest(req)?.email when the roll has no email.

The mock CastVoteStore only called rollStore.update(), which no-ops when
there's no head row to update — exactly the case here — so the whole class of
bug was invisible to the suite. It now inserts when there's no head row, which
is what the real store does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploy Preview for bettervoting ready!

Name Link
🔨 Latest commit ede5c23
🔍 Latest deploy log https://app.netlify.com/projects/bettervoting/deploys/6a7227fbc9b5fd00082ace99
😎 Deploy Preview https://deploy-preview-1477--bettervoting.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@jacksonloper
jacksonloper requested a review from ArendPeter August 4, 2026 17:53
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 97874593-4cd7-4324-9787-cdf81a472ae5

📥 Commits

Reviewing files that changed from the base of the PR and between 15289d3 and ff1b1e9.

📒 Files selected for processing (3)
  • packages/backend/src/Controllers/Roll/voterRollUtils.ts
  • packages/backend/src/Models/__mocks__/CastVoteStore.ts
  • packages/backend/src/test/openElectionRollPrivacy.test.ts

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved election-roll privacy by storing only the authentication information configured for each election.
    • Open-election ballot submissions now correctly create or update the associated election roll.
    • Prevented unconfigured email addresses and IP hashes from being persisted.
  • Tests

    • Added coverage verifying roll privacy across unauthenticated, email-authenticated, and IP-authenticated elections.

Walkthrough

The change limits new election rolls to configured authentication fields. Cast-vote persistence now updates an existing roll or creates a new roll when needed. Integration tests verify roll contents for unauthenticated, email-authenticated, and IP-authenticated open elections.

Poem

I’m a rabbit with a tidy roll,
No extra fields inside the hole.
Email stays when email’s set,
IP hash joins when required yet.
Ballots persist through every hop,
Clean little rolls are where I stop.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main privacy change for unauthenticated elections.
Description check ✅ Passed The description explains the bug, scope, implementation, tests, production impact, and non-goals; frontend media is not applicable.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch JacksonLoper/rollprivacy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The name read as a boolean predicate, but the value is `string | null` — the
hash to authenticate against, or null when the election doesn't authenticate on
IP. It's passed straight into getElectionRoll's `ip_hash: string | null`
parameter. `email` on the next line already follows the value-or-null
convention; this now matches.

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant