1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

IPアドレスを保存せずに多重投票を防ぐ ― 端末フィンガープリント実装の実測記録

1
Last updated at Posted at 2026-10-04

はじめに

個人で匿名投票サイト(Xユーザーの好き嫌い投票サイト「ツイッタラー世論調査」)を運営しています。このサイトの要件が少し特殊です。

  • アカウント登録なし。誰でも匿名で投票できる
  • IPアドレスを保存しない(プライバシー最優先。開示請求が来ても「保有していません」と言える設計)
  • でも、多重投票・票の水増しは防ぎたい

「IPもアカウントも持たない」状態で、どうやって「同一人物の2回目」を判定するか。この記事はその実装と実測の記録です。

前提: 投票の識別はcookieトークン

このサイトでは、初回アクセス時にクライアント側で64桁の匿名トークンを生成し、cookieに保存しています。投票は「対象 × トークン」の組で1日1回まで(DB側で判定)。

つまり、cookieを消せば新しいトークンになれる構造でした。

攻撃: Safariの標準機能で多重投票

Safariの「履歴とWebサイトデータを消去」→ サイト再訪 → 規約に同意 → 新しいトークンが発行される → 何度でも投票できる。

実際にユーザーから「まだ不正投票できたゾ」と報告が来ました(ありがたい)。

対策1: IP単位の「新規トークン枠」(メモリ内レート制限)

まず、同一IPから「初めて見るトークン」で投票できる数を1日100個までに制限しました。

  • 実装: Map<ip, Set<token>> をJST日単位で管理。既知トークンは制限なし
  • 効果: 連投は止まる
  • 弱点: 「リセット → 1〜2回だけ再投票」は通ってしまう。デモ攻撃には弱い

※IPはこのレート制限のメモリ内でしか使わず、保存はしません。

対策2(本丸): 端末フィンガープリント

cookieを消しても変わらない信号で「同一端末」を判定します。

取得する信号:

  • UserAgent / 言語 / 画面サイズ・DPR / タイムゾーン / CPU論理コア数 / maxTouchPoints
  • canvas描画のピクセル差異(フォントラスタライザ・GPU差が出る)
  • WebGLレンダラ名

ハッシュ化: cyrb53 を3シードで回して33桁hexに。暗号強度は不要で、重複検出が目的です。

クライアント側: ページ内で1回だけ計算してキャッシュし、投票/評価リクエストのボディに fp として同梱します。

サーバー側: Map<"fp:kind:targetId", JST日> をメモリ内で保持。「同じ端末 × 同じ対象 × 同じ日」なら429を返します。

設計上の判断:

  • フェイルオープン: fpが空・不正形式なら従来のレート制限のみ適用。旧クライアントや特殊環境を壊さない
  • 永続化しない: サーバー再起動でリセットされる。攻撃者は再起動のタイミングで1回通せるが、実害は軽微。「保存ゼロ」の方針と一貫させるため、あえて永続化しない
  • 記録は投票成功時のみ: 失敗リトライで枠を無駄に消費しない

おまけ: バースト制限

新規トークンの使用を10分あたり12個までに制限。cookieリセット連打を即座に止めます。

実測(攻撃の完全再現テスト)

PlaywrightでSafari相当のクライアントを再現し、dev環境と本番の両方で確認しました。

# 手順 結果
1 初回投票 200 成功
2 cookie全消し → 同一対象へ再投票 429 遮断(fpはリセット前後で完全一致)
3 別の対象への投票 200 成功(過剰ブロックなし)
4 評価 → cookie全消し → 再評価 409 遮断
5 リセット連打 ×12 9回目から429(バースト制限)
6 別fp(別端末相当) 200 成功
7 fpなし(旧クライアント相当) 200 成功(フェイルオープン動作)

「cookieを消して再投票」という手口は、同じ端末では通らなくなりました。

限界

  • 同一端末でも別ブラウザを使うとfpは変わります(canvas・UAが違うため)。ただしその場合もバースト制限・日次上限は効きます
  • サーバー再起動でfp記録は消えます
  • 完璧ではありません。「Safariでポチポチ」レベルの攻撃が消えた、というのが正確なところです

スタック(おまけ)

  • Next.js 15 (App Router) + PostgreSQL + Cloudflare Tunnel + Coolify
  • パッケージマネージャは Bun(Docker内installが8秒台に短縮)
  • 2ch専ブラ互換API(dat / SETTING.TXT / read.cgi)も自前実装
  • SEO: 動的OGP(1200×630を投票数から生成)/ JSON-LD / IndexNow

まとめ

「匿名性を守りながら不正を防ぐ」は、完璧な解が存在しない領域です。今回の実装は、最も手軽な攻撃(cookieリセット)を潰しつつ、プライバシー方針(保存ゼロ)を崩さないことを優先しました。同じような要件のサービスの参考になれば幸いです。

サイト: https://tsuittara-yoron.hikamers.app/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?