はじめに
個人で匿名投票サイト(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リセット)を潰しつつ、プライバシー方針(保存ゼロ)を崩さないことを優先しました。同じような要件のサービスの参考になれば幸いです。