Skip to content

Throttle rapid ad clicks across campaigns and publishers - #256

Merged
ralyodio merged 1 commit into
masterfrom
fix/ad-click-throttling
Sep 13, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/ad-click-throttling

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Rapid clicks on different campaigns could bypass campaign-only deduplication and inflate paper spend. A shared Redis admission now permits one accepted click per visitor or IP every five seconds across campaigns and publishers, including concurrent requests. Rejected clicks retain their redirect while charging neither cash nor paper spend. Free delivery also participates in the existing six-hour campaign deduplication, and failed validation withholds charges.

Railway click accounting uses the edge-provided client IP. Deployment requires the existing REDIS_URL and IP_HASH_SALT; both are configured and the production Redis connection passed a read-only health check. There is no database migration. Shared-IP visitors share the short cooldown; during Redis outages billing is withheld while redirects continue.

Validation: production build and TypeScript pass; full suite 2,327 passed, seven pre-existing skips. Real disposable Redis tests admit exactly one of 32 simultaneous requests across eight connections and cover expiry, changed identities and non-extending rejections. Accounting tests cover paid, promo and free tiers.

@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

45 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 28 | LOW: 15

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:48
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
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 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 js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:99
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:20
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:24
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:25
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:26
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:31
LOW js-dynamic-code-execution tests/contract/ads-click-cooldown-redis.test.ts:35
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

it("admits exactly one of 32 concurrent cross-campaign clicks across connections", async () => {
const ip = key("ip"), visitor = key("visitor");
const accepted = await Promise.all(Array.from({ length: 32 }, (_, i) =>
clients[i % clients.length].eval(CLAIM_CLICK_LUA, 2, ip, visitor, CLICK_COOLDOWN_MS)));
expect(accepted.filter((n) => n === 1)).toHaveLength(1);
expect(await clients[0].pttl(ip)).toBeGreaterThan(4_000);
expect(await clients[0].pttl(ip)).toBeLessThanOrEqual(5_000);
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, key("rotated-visitor"), 5_000)).toBe(0);
expect(await clients[0].pttl(ip)).toBeGreaterThan(4_000);
expect(await clients[0].pttl(ip)).toBeLessThanOrEqual(5_000);
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, key("rotated-visitor"), 5_000)).toBe(0);
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, key("rotated-ip"), visitor, 5_000)).toBe(0);
expect(await clients[0].pttl(ip)).toBeLessThanOrEqual(5_000);
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, key("rotated-visitor"), 5_000)).toBe(0);
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, key("rotated-ip"), visitor, 5_000)).toBe(0);
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, key("different-ip"), key("different-visitor"), 5_000)).toBe(1);
it("does not claim the other identity or extend expiry on rejection", async () => {
const ip = key("busy-ip"), visitor = key("new-visitor");
await clients[0].set(ip, "1", "PX", 100);
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, visitor, 5_000)).toBe(0);
expect(await clients[0].exists(visitor)).toBe(0);
expect(await clients[0].pttl(ip)).toBeLessThanOrEqual(100);
await new Promise((r) => setTimeout(r, 120));
expect(await clients[1].eval(CLAIM_CLICK_LUA, 2, ip, visitor, 5_000)).toBe(1);
@ralyodio
ralyodio merged commit 4bc6ad3 into master Sep 13, 2026
10 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.

2 participants