Skip to content

feat(ads): share the visitor id with /ad.js, salt + rotate IP hashes - #191

Merged
ralyodio merged 2 commits into
masterfrom
worktree-ads-visitor-id-and-ip-salt
Aug 10, 2026
Merged

ralyodio merged 2 commits into
masterfrom
worktree-ads-visitor-id-and-ip-salt

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to the investigation into the periodic ad-impression spikes on /ads?range=1h.

1. /ad.js now mints the visitor id

/ad.js only ever read localStorage['crawlproof.visitor'] — nothing but /stats.js wrote it. A publisher running the ad tag without the analytics tag therefore sent an empty visitor on every impression. In production that was ~69% of impressions with no visitor id (5,979 of 19,171 over 7 days), which left ip_hash as the only signal available for click dedupe and frequency capping.

The minting logic moves to lib/tracker/visitorSnippet.ts and is inlined verbatim by both routes. Still localStorage, still no cookie.

Scope note, deliberately documented in the module: the tag runs in the publisher's page, so this storage is partitioned to the publisher's origin. It is a stable per-site visitor id and cannot follow anyone across publishers — that would need third-party storage, which browsers no longer grant. Per-site frequency capping and returning-visitor counts work; cross-publisher reach does not, by platform design.

2. Salted, daily-rotating IP hashes

lib/ads/serve.ts hashed IPs as bare sha256(ip) truncated to 32 hex chars. IPv4 is 2^32 addresses, so that is a reversible encoding of the IP rather than a pseudonym — and ip_hash is the only identifier the ad network has for terminal traffic, which has no localStorage at all. lib/rateLimit.ts used a hardcoded constant prefix, which is a pepper in name only since it lives in the source.

Both collapse into lib/ipHash.ts. It exports two variants over one implementation, because the callers need opposite properties:

salt why
hashIp() stable checkAnonymousLimit looks back 24h+; a rotating salt would refill every anonymous quota at the boundary — a quota bypass, not a privacy win
hashIpRotating() rotates daily ad metering only needs to recognise an IP for hours (6h click dedupe, frequency capping); past that, being able to re-identify is a liability

rotatingIpHashCandidates() returns every salt window inside a lookback, so click dedupe doesn't silently miss for the first hours after each rotation.

Deploying this changes nothing until IP_HASH_SALT is set — unset reproduces the legacy digest byte for byte, so no stored hash is invalidated and no rate-limited visitor gets a free quota. Setting it in prod resets abuse counters once and breaks in-flight click dedupe for up to 6h; both self-heal.

3. ?v= documented for terminal publishers

A terminal has no cookies and no localStorage, so unlike the web tag we cannot mint an id for the caller — without it every fetch looks like a new person, which is exactly why a scheduled curl loop reads as a spike of unique visitors. The slot manager now ships a snippet that generates one opaque random id per machine at install time, with an explicit warning not to use a hostname, username, or IP.

Incidental: lib/rateLimit.ts was binary

Its CONTROL_OR_WS regex embedded raw control bytes (including a NUL) directly in the literal instead of escapes. That made the file read as binary to grep — searches over it silently returned nothing, with no error — and git diffed it as Bin. Now spelled with \u escapes; behaviour is identical and pinned by new tests. This was found by accident when greps for hashIp in that file returned nothing.

Testing

  • npm run typecheck — clean
  • npm test — 1,417 passed, 1 failed
  • The one failure is tests/contract/tracker-geo.test.ts, which needs the GeoLite2 mmdb. Pre-existing and environmental — verified by stashing this branch's changes and re-running it on the baseline, where it fails identically. CI installs the dependency via npm ci, so it passes there.
  • 28 new tests across ip-hash (12), ad-visitor-id (6), and url-control-chars (10). The ad-visitor-id set includes a parse check on both generated tags, since containment assertions can't catch a broken interpolation.

Not included

Impression-side dedupe (mirroring the 6h click window) is the change that would actually flatten the spikes. It is a clean follow-up now that the hash is properly salted, but it is a metering behaviour change and deserves its own PR. The immediate mitigation is a one-line change to /usr/local/bin/profullstack-sponsor-ad on the dev box, outside this repo.

🤖 Generated with Claude Code

ralyodio and others added 2 commits August 10, 2026 10:26
Three changes to make ad metering identify visitors it previously couldn't,
and to stop the fallback identifier from being reversible.

1. Shared visitor id. /ad.js only ever *read* localStorage['crawlproof.visitor'];
   nothing but /stats.js wrote it, so a publisher running the ad tag without the
   analytics tag sent an empty visitor on every impression -- ~69% of production
   impressions carried no visitor id. The minting logic moves to
   lib/tracker/visitorSnippet.ts and is now inlined verbatim by both routes, so
   the ad tag mints the id when it is the first CrawlProof script on the page.
   Still localStorage, still no cookie.

2. Salted, rotating IP hashes. lib/ads/serve.ts hashed IPs as bare sha256(ip)
   truncated to 32 hex chars. IPv4 is 2^32 addresses, so that is a reversible
   encoding of the IP, not a pseudonym -- and ip_hash is the only identifier the
   ad network has for terminal traffic, which has no localStorage at all. Both
   implementations (here and lib/rateLimit.ts, which used a hardcoded constant
   prefix) collapse into lib/ipHash.ts.

   Two exported variants over one implementation, because the callers need
   opposite properties: abuse caps look back 24h+ and must not reset at the
   rotation boundary, so they keep a stable salt; ad metering only needs to
   recognise an IP for hours, so it rotates daily and the ability to correlate
   expires on its own. Click dedupe queries every salt window inside its 6h
   lookback, so the check doesn't silently miss after each rotation.

   IP_HASH_SALT unset reproduces the legacy digest byte for byte, so deploying
   this without the env var set changes nothing.

3. Documented ?v= for terminal publishers. A terminal has no cookies and no
   localStorage, so unlike the web tag we cannot mint an id for the caller --
   without it every fetch looks like a new person, which is why a scheduled
   curl loop reads as a spike of unique visitors. The slot manager now ships a
   snippet that generates one opaque id per machine at install time.

Also replaces the raw control bytes embedded in lib/rateLimit.ts's
CONTROL_OR_WS regex with \u escapes. They made the file read as binary to grep
and to git (which diffed it as Bin), so searches over it silently returned
nothing. Behaviour is identical and now covered by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both tags are string-templated into third-party pages, so a stray brace or a
bad interpolation would ship a script that throws on every publisher site.
Containment assertions cannot catch that; parsing can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

ThreatCrush Security Scan

52 finding(s)

HIGH/CRITICAL: 4 | MEDIUM: 45 | LOW: 3

Severity Rule Location
HIGH secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH secret-generic-credential lib/sp/platforms/linkedin.ts:25
HIGH manifest-typosquat package.json:59
MEDIUM js-unescaped-html-sink app/(app)/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/actions.tsx:96
MEDIUM js-unescaped-html-sink app/(app)/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/setup/form.tsx:504
MEDIUM sql-template-interpolation app/(app)/projects/[id]/uptime/monitor-actions.tsx:28
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM sql-template-interpolation app/actions/admin.ts:114
MEDIUM sql-template-interpolation app/actions/orgs.ts:328
MEDIUM sql-template-interpolation app/api/lx/keywords/regenerate/route.ts:59
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM sql-template-interpolation lib/audit/checks/security.ts:48
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:93
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:367
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:379
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:380
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1340
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1362
MEDIUM sql-template-interpolation lib/lx/guestPostGen.ts:109
MEDIUM tls-verification-disabled lib/onion.ts:47
MEDIUM sql-template-interpolation lib/sp/platforms/linkedin.ts:177
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:97
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:102
MEDIUM sql-template-interpolation scripts/lx-republish-todays-articles.mjs:102
MEDIUM js-dynamic-code-execution tests/careers-page-templates.test.ts:21
MEDIUM js-dynamic-code-execution tests/careers-widget-script.test.ts:69
MEDIUM js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
MEDIUM js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/coinpay.test.ts:4

…and 2 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 10, 2026 10:31
@ralyodio
ralyodio merged commit 9af5d97 into master Aug 10, 2026
8 checks passed
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