Skip to content

Commit a46ad16

Browse files
waleedlatif1claude
andauthored
fix(consent): stop asking for consent from a policy we failed to fetch (#7766)
* fix(consent): stop asking for consent from a policy we failed to fetch The consent banner reappears for people who have already answered it. The policy lookup is a cross-origin call to the consent instance, and that origin intermittently answers a bot challenge instead: HTTP 403, an HTML body, and `x-vercel-mitigated: challenge`. A browser fetch cannot solve a JS challenge, so the runtime substitutes a generic opt-in policy and carries on as though the lookup succeeded. Because consent records the fingerprint of the policy they were given under, the substituted policy never matches, the runtime treats it as a material policy change, clears the stored consent, and asks again — with a question the visitor's jurisdiction may not even require. The banner now renders only when the policy was actually resolved. A load that fell back asks nothing; the next load that reaches the real policy asks if it still needs to. The root cause belongs to the consent instance, which should not challenge an endpoint browsers call over XHR. Reproduced against production: consent given, cookie present, then one challenged lookup and the banner returns with the cookie deleted. Also records why the backend URL must stay cross-origin. The documented same-origin rewrite would dodge the challenge, but the instance resolves jurisdiction from the address the request arrives from, and ignores every forwarding header we could send (measured: x-forwarded-for, x-real-ip, true-client-ip, cf-connecting-ip, x-vercel-ip-country). Sim has no edge that supplies a country header, so proxying would resolve every visitor to our own region and stop asking the EU for consent entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tTS2kSemtRPmTq9ezGRCc * fix(consent): keep the explicitly opened dialog working during a fallback The guard suppressed the whole card, including a dialog the visitor opened from the Cookie Policy or the footer control. That leaves a published promise — change your choice at any time — wired to a button that does nothing. Guard the unsolicited banner only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tTS2kSemtRPmTq9ezGRCc --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0f9ecde commit a46ad16

3 files changed

Lines changed: 119 additions & 2 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockUseConsentManager, mockUseHeadlessConsentUI } = vi.hoisted(() => ({
9+
mockUseConsentManager: vi.fn(),
10+
mockUseHeadlessConsentUI: vi.fn(),
11+
}))
12+
13+
vi.mock('@c15t/nextjs/headless', () => ({
14+
useConsentManager: mockUseConsentManager,
15+
useHeadlessConsentUI: mockUseHeadlessConsentUI,
16+
}))
17+
18+
vi.mock('@/app/_shell/consent/consent-preferences', () => ({
19+
CONSENT_LINK_CLASS: 'link',
20+
ConsentPreferences: () => <span data-testid='preferences' />,
21+
}))
22+
23+
import { ConsentBanner } from '@/app/_shell/consent/consent-banner'
24+
25+
let root: Root | null = null
26+
27+
function render(): HTMLDivElement {
28+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
29+
const container = document.createElement('div')
30+
document.body.appendChild(container)
31+
root = createRoot(container)
32+
act(() => root?.render(<ConsentBanner />))
33+
return container
34+
}
35+
36+
function isBannerShown(container: HTMLDivElement): boolean {
37+
return container.querySelector('section[aria-label="Cookie preferences"]') !== null
38+
}
39+
40+
beforeEach(() => {
41+
mockUseHeadlessConsentUI.mockReturnValue({
42+
banner: { isVisible: true, allowedActions: ['accept', 'reject', 'customize'] },
43+
dialog: { isVisible: false, allowedActions: [] },
44+
openDialog: vi.fn(),
45+
performAction: vi.fn(),
46+
saveCustomPreferences: vi.fn(),
47+
})
48+
})
49+
50+
afterEach(() => {
51+
act(() => root?.unmount())
52+
root = null
53+
vi.clearAllMocks()
54+
})
55+
56+
describe('ConsentBanner', () => {
57+
it.each(['backend', 'backend-cache-hit', 'ssr'])(
58+
'asks for consent when the policy came from %s',
59+
(initDataSource) => {
60+
mockUseConsentManager.mockReturnValue({ initDataSource })
61+
62+
expect(isBannerShown(render())).toBe(true)
63+
}
64+
)
65+
66+
it('still renders a dialog the visitor opened, so the published control works', () => {
67+
mockUseHeadlessConsentUI.mockReturnValue({
68+
banner: { isVisible: false, allowedActions: [] },
69+
dialog: { isVisible: true, allowedActions: ['accept', 'reject', 'customize'] },
70+
openDialog: vi.fn(),
71+
performAction: vi.fn(),
72+
saveCustomPreferences: vi.fn(),
73+
})
74+
mockUseConsentManager.mockReturnValue({ initDataSource: 'offline-fallback' })
75+
76+
expect(isBannerShown(render())).toBe(true)
77+
})
78+
79+
it('asks nothing when the policy lookup fell back', () => {
80+
// A bot challenge on the third-party `/init` makes the runtime substitute a
81+
// generic opt-in policy, which would otherwise re-prompt visitors who had
82+
// already consented under the real one.
83+
mockUseConsentManager.mockReturnValue({ initDataSource: 'offline-fallback' })
84+
85+
expect(isBannerShown(render())).toBe(false)
86+
})
87+
})

‎apps/sim/app/_shell/consent/consent-banner.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useHeadlessConsentUI } from '@c15t/nextjs/headless'
3+
import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless'
44
import { Chip } from '@sim/emcn'
55
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
66
import Link from 'next/link'
@@ -29,20 +29,37 @@ const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const
2929
* the light layer on `<html>` through `ThemeProvider`'s forced theme, or is a
3030
* themed app page where inheriting is what should happen — the card no longer
3131
* decides for itself.
32+
*
33+
* Nothing is asked *unprompted* when the policy lookup failed. `/init` answers
34+
* from a third-party origin, and when that origin refuses the request — a bot
35+
* challenge returns `403` with an HTML body — the runtime substitutes a generic
36+
* opt-in policy rather than surfacing the failure. Volunteering a banner from
37+
* it asks a question the visitor's jurisdiction may not require, and asks it of
38+
* people who already answered, because the substituted policy's fingerprint
39+
* never matches the one their stored consent was recorded under. The next load
40+
* that reaches the real policy asks properly if it still needs to.
41+
*
42+
* A dialog the visitor opened themselves still renders, fallback or not: the
43+
* Cookie Policy promises the choice can be changed at any time, and a control
44+
* that silently does nothing breaks that promise. A choice saved during a
45+
* fallback is recorded against the substituted policy and will be asked for
46+
* again once the real one resolves, which is the lesser of the two failures.
3247
*/
3348
export function ConsentBanner() {
3449
const { banner, dialog, openDialog, performAction, saveCustomPreferences } =
3550
useHeadlessConsentUI()
51+
const { initDataSource } = useConsentManager()
3652
const prefersReducedMotion = useReducedMotion()
3753

54+
const isPolicyResolved = initDataSource !== 'offline-fallback'
3855
const isExpanded = dialog.isVisible
3956
const surfaceName = isExpanded ? 'dialog' : 'banner'
4057
const { allowedActions } = isExpanded ? dialog : banner
4158
const enterOffset = prefersReducedMotion ? 0 : 8
4259

4360
return (
4461
<AnimatePresence>
45-
{(banner.isVisible || dialog.isVisible) && (
62+
{((isPolicyResolved && banner.isVisible) || dialog.isVisible) && (
4663
<motion.section
4764
aria-label='Cookie preferences'
4865
initial={{ opacity: 0, y: enterOffset }}

‎apps/sim/lib/consent/constants.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@
1414
* Sim's consent instance. Public by construction — the browser calls it
1515
* directly, so it is a client-visible origin like the GTM and GA container IDs
1616
* in the root layout, not a credential.
17+
*
18+
* The browser must keep calling it directly. c15t documents a same-origin
19+
* rewrite (`/api/c15t/:path*`) as an optimization, and it would also sidestep
20+
* the bot challenge this origin sometimes answers with — but it resolves the
21+
* jurisdiction from the address the request arrives from, and proxying makes
22+
* every visitor arrive from our servers. Measured against the live instance:
23+
* `x-forwarded-for`, `x-real-ip`, `true-client-ip`, `cf-connecting-ip` and
24+
* `x-vercel-ip-country` are all ignored, and only c15t's own `x-c15t-country`
25+
* override is honored. Sim has no edge that supplies a country header for us to
26+
* forward into it, so behind a proxy every visitor would resolve to our region
27+
* and no one in the EU would be asked for consent at all. The same dependency
28+
* rules out the SSR prefetch, which reads those headers through
29+
* `extractRelevantHeaders`. Revisit only alongside an edge that provides geo.
1730
*/
1831
export const CONSENT_BACKEND_URL = 'https://sim-sim.inth.app'
1932

0 commit comments

Comments
 (0)