Skip to content

Enforce one-vote-per-visitor on the server (vote integrity) #18

Description

@mukto90

Problem

Vote de-duplication exists only in the browser. assets/public/js/script.js stores voted item IDs in localStorage (niroroadmap_votes) and disables the buttons client-side. API\Task::vote() (app/API/Task.php) has no server-side check at all: every POST /tasks/{id}/vote increments the upvote/downvote post meta.

Consequences:

  • Clearing site data, using a private window, or a different browser lets one person vote unlimited times.
  • Anyone can script POST /tasks/{id}/vote in a loop and inflate any item to any number.
  • Vote counts are the core signal of the plugin. If they can't be trusted, the board is worthless for prioritisation and we can't safely build features on top of them (analytics, sorting by votes, weighted votes).
  • The Limiter trait is keyed on user_id (user meta), so it can't protect anonymous visitors.

Proposal

Enforce "one vote per visitor per item" on the server, without storing personal data in the clear.

Voter identification

  1. Issue a random voter token in a first-party cookie (niroroadmap_voter, 1 year, SameSite=Lax, Secure on HTTPS, HttpOnly where possible).
  2. As a second layer, compute hash_hmac( 'sha256', ip . '|' . user_agent, wp_salt() ). Never store the raw IP.
  3. Logged-in users are identified by user ID (survives cookie loss).

Storage

  • Record item_id + voter_hash + type + timestamp. Start with a dedicated table ({prefix}niroroadmap_votes, unique index on item_id, voter_hash) created via Installer, because post meta does not scale for per-voter rows.
  • Keep upvote / downvote post meta as the cached counters (so existing queries and templates keep working).
  • Provide a one-time migration that leaves existing counters untouched.

Behaviour

  • Second vote on the same item by the same voter returns 409 with a clear message; the UI shows the voter's existing vote.
  • Allow changing a vote (up -> down) if a setting enables it (default: off, i.e. vote is final). Changing adjusts both counters atomically.
  • Add per-IP-hash rate limiting for the vote endpoint (e.g. 30 votes / 10 min) using transients.
  • Return the voter's own vote state in GET /tasks/{id} so the UI no longer depends only on localStorage (keep localStorage as a UX cache).
  • Votes on non-published or password-protected items stay rejected (already handled by get_public_task()).
  • Increment counters atomically (single SQL UPDATE ... SET meta_value = meta_value + 1 or insert-then-count) to avoid read-modify-write races in vote().

Acceptance criteria

  • Voting twice on the same item from the same browser is rejected by the server even with localStorage cleared.
  • Voting after deleting cookies from the same IP + UA is rejected.
  • Looping the REST endpoint cannot raise a count by more than one per voter and is rate limited.
  • Logged-in user votes are tied to the user ID across devices.
  • Counters on cards and in the popup match the number of vote rows.
  • Existing counters survive the upgrade; no data loss.
  • Concurrent requests do not lose or double-count votes.
  • Unit/integration tests cover: first vote, duplicate vote, changing vote (setting on/off), rate limit, unpublished item, concurrent votes.
  • readme.txt Privacy section updated: describe the cookie and the salted hash, and that no raw IP is stored. (Currently it states votes are stored only as plain counts and nothing is sent to the server.)

Technical notes

  • Files: app/API/Task.php (vote(), get()), app/Controller/Common/API.php (route args and permission callback), app/Bootstrap/Installer.php (table), app/Bootstrap/Uninstaller.php/uninstall.php (cleanup), assets/public/js/script.js.
  • Cache-friendly sites: the vote endpoint must not depend on a page-embedded nonce that full-page caches would serve stale. Cookie + server hash avoids that.
  • GDPR: a salted hash of IP + UA is still pseudonymous. Document it, and consider making the IP layer optional via a filter (niroroadmap_vote_fingerprint).

Out of scope

Captcha / bot scoring, weighted votes (separate issue), email-verified votes.

Priority rationale

Severe. This is an integrity hole in the main feature and a prerequisite for sorting by votes, analytics and weighted votes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions