fix(spam-detection): stop truncating IPv4-mapped IPv6 addresses #149

Merged
ellamizuki merged 1 commit from fix/client-ip-ipv6-mapped-truncation into main 2026-09-05 21:22:29 +00:00
ellamizuki commented 2026-09-05 21:18:17 +00:00 (Migrated from github.com)

Root cause

getClientIp()'s port-stripping heuristic in apps/web/src/lib/spam-detection.ts checked only first.includes('.') && first.includes(':') to decide whether a header value was an IPv4 address with a trailing port (e.g. 198.51.100.1:1234).

That shape also matches two other real-world address forms that contain both a . and a : without carrying a port at all:

  • IPv4-mapped IPv6 addresses: ::ffff:192.168.1.1
  • NAT64 addresses: 64:ff9b::203.0.113.5

Reproduction (against origin/main)

'::ffff:192.168.1.1'   -> null   (falls through to 'unknown')
'64:ff9b::203.0.113.5' -> '64'   (garbage fragment: everything before the first colon)

Impact

getClientIp() feeds the per-IP rate limiter (checkRateLimit) used across 10+ routes:

  • apps/web/src/app/api/contact/route.ts
  • apps/web/src/app/api/newsletter/subscribe/route.ts and unsubscribe/route.ts
  • apps/web/src/app/api/ai/chat/route.ts and feedback/route.ts
  • apps/web/src/app/api/ai/content/route.ts
  • apps/web/src/app/api/admin/translate/route.ts
  • apps/web/src/app/api/admin/posts/route.ts (3 call sites)

A request from a client behind either address shape gets normalized to 'unknown' or a garbage key ('64') instead of its real address, so the per-IP rate limit is shared across all such clients (or silently ineffective) instead of applying per-address.

Fix

Only strip a trailing :<port> when the substring before the last colon matches a dotted-quad IPv4 address (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}) and the substring after it is numeric. IPv6 addresses — mapped or not — now pass through unchanged, since they never satisfy the dotted-quad check on the left side.

Tests

3 regression tests added to apps/web/src/tests/lib/spam-detection.test.ts:

  • ::ffff:192.168.1.1 stays intact (previously became 'unknown')
  • 64:ff9b::203.0.113.5 stays intact (previously became '64')
  • a genuine 203.0.113.10:8443 still has its port stripped correctly (no regression on the original behavior)

All three verified to fail against origin/main's spam-detection.ts and pass against this branch.

Validation

  • pnpm vitest run apps/web/src/tests/lib/spam-detection.test.ts — 8/8 passed
  • pnpm test:unit (full suite) — 185/185 passed (182 pre-existing + 3 new)
  • pnpm turbo type-check --filter=@isyuricunha/web — clean
  • pnpm turbo lint --filter=@isyuricunha/web — clean (--max-warnings 0)

Note

The repo's lint-staged/husky pre-commit hook auto-fixed one unrelated line in the same file during commit (checkRateLimit's cleanup loop: Array.from(...).forEach(...) → for...of) — same behavior, style-only, not something I authored intentionally. Flagging it here for transparency since it's outside the diff I wrote by hand.

## Root cause `getClientIp()`'s port-stripping heuristic in `apps/web/src/lib/spam-detection.ts` checked only `first.includes('.') && first.includes(':')` to decide whether a header value was an IPv4 address with a trailing port (e.g. `198.51.100.1:1234`). That shape also matches two other real-world address forms that contain both a `.` and a `:` without carrying a port at all: - IPv4-mapped IPv6 addresses: `::ffff:192.168.1.1` - NAT64 addresses: `64:ff9b::203.0.113.5` ## Reproduction (against `origin/main`) ``` '::ffff:192.168.1.1' -> null (falls through to 'unknown') '64:ff9b::203.0.113.5' -> '64' (garbage fragment: everything before the first colon) ``` ## Impact `getClientIp()` feeds the per-IP rate limiter (`checkRateLimit`) used across 10+ routes: - `apps/web/src/app/api/contact/route.ts` - `apps/web/src/app/api/newsletter/subscribe/route.ts` and `unsubscribe/route.ts` - `apps/web/src/app/api/ai/chat/route.ts` and `feedback/route.ts` - `apps/web/src/app/api/ai/content/route.ts` - `apps/web/src/app/api/admin/translate/route.ts` - `apps/web/src/app/api/admin/posts/route.ts` (3 call sites) A request from a client behind either address shape gets normalized to `'unknown'` or a garbage key (`'64'`) instead of its real address, so the per-IP rate limit is shared across all such clients (or silently ineffective) instead of applying per-address. ## Fix Only strip a trailing `:<port>` when the substring before the last colon matches a dotted-quad IPv4 address (`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`) and the substring after it is numeric. IPv6 addresses — mapped or not — now pass through unchanged, since they never satisfy the dotted-quad check on the left side. ## Tests 3 regression tests added to `apps/web/src/tests/lib/spam-detection.test.ts`: - `::ffff:192.168.1.1` stays intact (previously became `'unknown'`) - `64:ff9b::203.0.113.5` stays intact (previously became `'64'`) - a genuine `203.0.113.10:8443` still has its port stripped correctly (no regression on the original behavior) All three verified to fail against `origin/main`'s `spam-detection.ts` and pass against this branch. ## Validation - `pnpm vitest run apps/web/src/tests/lib/spam-detection.test.ts` — 8/8 passed - `pnpm test:unit` (full suite) — 185/185 passed (182 pre-existing + 3 new) - `pnpm turbo type-check --filter=@isyuricunha/web` — clean - `pnpm turbo lint --filter=@isyuricunha/web` — clean (`--max-warnings 0`) ## Note The repo's `lint-staged`/husky pre-commit hook auto-fixed one unrelated line in the same file during commit (`checkRateLimit`'s cleanup loop: `Array.from(...).forEach(...)` → `for...of`) — same behavior, style-only, not something I authored intentionally. Flagging it here for transparency since it's outside the diff I wrote by hand.
isyuricunha (Migrated from github.com) reviewed 2026-09-05 21:18:17 +00:00
vercel[bot] commented 2026-09-05 21:18:22 +00:00 (Migrated from github.com)

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
website-web Ready Ready Preview Sep 5, 2026 9:18pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
website-docs Ignored Ignored Sep 5, 2026 9:18pm UTC
[vc]: #oGPRBegzfVeMIHLkhr7xHWZoHLGoCnOB35KRLt0p/js=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3ZWJzaXRlLXdlYiIsInByb2plY3RJZCI6InByal9iNnJGaEFBUEhJWFI3M2Nlb2RFMHl2bEJ3MzgyIiwicm9vdERpcmVjdG9yeSI6ImFwcHMvd2ViIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IndlYnNpdGUtd2ViLWdpdC1maXgtY2xpZW50LWlwLWlwdjYtZDI0ODQ0LXl1cmktY3VuaGFzLXByb2plY3RzLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3l1cmktY3VuaGFzLXByb2plY3RzL3dlYnNpdGUtd2ViL0U3U0wyMjZWbmE3cXpvZlFVUnFYU21ONFZTa3AiLCJwcmV2aWV3VXJsIjoid2Vic2l0ZS13ZWItZ2l0LWZpeC1jbGllbnQtaXAtaXB2Ni1kMjQ4NDQteXVyaS1jdW5oYXMtcHJvamVjdHMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6IndlYnNpdGUtZG9jcyIsInByb2plY3RJZCI6InByal8yZVUyc1RNMGlkWTcwclRic2NDQ3RFdDZNRk93Iiwicm9vdERpcmVjdG9yeSI6ImFwcHMvZG9jcyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3l1cmktY3VuaGFzLXByb2plY3RzL3dlYnNpdGUtZG9jcy81S3Z5aXBWREtHZzdDUzJvOUs4bkZ1anB0SjU3IiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQifV19 The latest updates on your projects. Learn more about [Vercel for GitHub](https://vercel.link/github-learn-more). | Project | Deployment | Actions | Updated | | :--- | :----- | :------ | :------ | | <a href="https://vercel.com/yuri-cunhas-projects/website-web"><sup><img src="https://vercel.com/api/www/avatar?projectId=prj_b6rFhAAPHIXR73ceodE0yvlBw382&teamId=team_g087rqPZJa9s4DWPeZtQhxa9&s=32" width="16" height="16" align="middle" alt="" /></sup></a> [website-web](https://vercel.com/yuri-cunhas-projects/website-web) | ![Ready](https://vercel.com/static/status/ready.svg) [Ready](https://vercel.com/yuri-cunhas-projects/website-web/E7SL226Vna7qzofQURqXSmN4VSkp) | [Preview](https://website-web-git-fix-client-ip-ipv6-d24844-yuri-cunhas-projects.vercel.app) | <relative-time datetime="2026-09-05T21:18:22.271Z">Sep 5, 2026 9:18pm UTC</relative-time> | <details><summary>1 Skipped Deployment</summary> | Project | Deployment | Actions | Updated | | :--- | :----- | :------ | :------ | | <a href="https://vercel.com/yuri-cunhas-projects/website-docs"><sup><img src="https://vercel.com/api/www/avatar?projectId=prj_2eU2sTM0idY70rTbscCCtEt6MFOw&teamId=team_g087rqPZJa9s4DWPeZtQhxa9&s=32" width="16" height="16" align="middle" alt="" /></sup></a> [website-docs](https://vercel.com/yuri-cunhas-projects/website-docs) | ![Ignored](https://vercel.com/static/status/canceled.svg) [Ignored](https://vercel.com/yuri-cunhas-projects/website-docs/5KvyipVDKGg7CS2o9K8nFujptJ57) | | <relative-time datetime="2026-09-05T21:18:22.271Z">Sep 5, 2026 9:18pm UTC</relative-time> | </details>
Sign in to join this conversation.
No description provided.