Skip to content

Commit 0b3eaca

Browse files
authored
Merge pull request #217 from ayoub3bidi/fix/turnstile-signup-gate
Fix: restore Cloudflare Turnstile client wiring for Google signup gate
2 parents 2055487 + 672647f commit 0b3eaca

10 files changed

Lines changed: 175 additions & 13 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ VITE_SUPABASE_ANON_KEY=your-anon-key
77
# Google Identity Services — Web OAuth client ID (public)
88
VITE_GOOGLE_WEB_CLIENT_ID=your-web-client-id.apps.googleusercontent.com
99

10+
# Cloudflare Turnstile (signup bot gate) — public site key only
11+
# Matching secret key is a Supabase Edge Function secret: TURNSTILE_SECRET_KEY
12+
VITE_TURNSTILE_SITE_KEY=0x4AAAAA...your-turnstile-site-key
13+
1014
# PostHog analytics (optional — skip in local dev)
1115
VITE_POSTHOG_API_HOST=https://us.i.posthog.com
1216
VITE_POSTHOG_API_KEY=phc_your_project_token

.github/workflows/deploy-cloudflare.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
5353
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
5454
VITE_GOOGLE_WEB_CLIENT_ID: ${{ secrets.VITE_GOOGLE_WEB_CLIENT_ID }}
55+
VITE_TURNSTILE_SITE_KEY: ${{ secrets.VITE_TURNSTILE_SITE_KEY }}
5556
VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }}
5657
VITE_POSTHOG_API_HOST: ${{ github.event.workflow_run.head_branch == 'main' && 'https://e.bayanflow.com' || 'https://e.dev.bayanflow.com' }}
5758

.github/workflows/preview-cloudflare.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ jobs:
5858
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
5959
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
6060
VITE_GOOGLE_WEB_CLIENT_ID: ${{ secrets.VITE_GOOGLE_WEB_CLIENT_ID }}
61+
VITE_TURNSTILE_SITE_KEY: ${{ secrets.VITE_TURNSTILE_SITE_KEY }}
6162
VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }}
6263
VITE_POSTHOG_API_HOST: https://e.dev.bayanflow.com
6364

index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,12 @@
216216
}
217217
})();
218218
</script>
219+
<!-- Cloudflare Turnstile — invisible CAPTCHA for bot signup prevention -->
220+
<script
221+
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
222+
async
223+
defer
224+
></script>
219225
</head>
220226
<body>
221227
<div id="root"></div>

public/_headers

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Referrer-Policy: strict-origin-when-cross-origin
66
Permissions-Policy: camera=(), microphone=(), geolocation=(), identity-credentials-get=(self "https://accounts.google.com")
77
# Pyodide CDN origins: jsDelivr below; custom VITE_PYODIDE_CDN_BASE origins appended at build (vite.config.js)
8-
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://*.posthog.com https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com; connect-src 'self' blob: https://*.posthog.com https://e.bayanflow.com https://e.dev.bayanflow.com https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com; object-src 'none'; base-uri 'self'; form-action 'self'
8+
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://*.posthog.com https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com https://challenges.cloudflare.com; connect-src 'self' blob: https://*.posthog.com https://e.bayanflow.com https://e.dev.bayanflow.com https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com https://challenges.cloudflare.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com https://challenges.cloudflare.com; object-src 'none'; base-uri 'self'; form-action 'self'
99
Link: <https://bayanflow.com/sitemap.xml>; rel="sitemap"
1010
Link: </.well-known/api-catalog>; rel="api-catalog"
1111
Link: </.well-known/agent-card.json>; rel="service-desc"

scripts/cspHeaders.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,5 +171,23 @@ export function assertAuthCspDirectives(csp, source) {
171171
);
172172
}
173173

174+
if (!scriptSrc?.includes('https://challenges.cloudflare.com')) {
175+
throw new Error(
176+
`${source}: script-src must include https://challenges.cloudflare.com (Turnstile)`
177+
);
178+
}
179+
180+
if (!connectSrc?.includes('https://challenges.cloudflare.com')) {
181+
throw new Error(
182+
`${source}: connect-src must include https://challenges.cloudflare.com (Turnstile)`
183+
);
184+
}
185+
186+
if (!frameSrc?.includes('https://challenges.cloudflare.com')) {
187+
throw new Error(
188+
`${source}: frame-src must include https://challenges.cloudflare.com (Turnstile)`
189+
);
190+
}
191+
174192
return { connectSrc: connectSrc ?? '', imgSrc: imgSrc ?? '' };
175193
}

src/security/cspHeaders.test.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import fs from 'node:fs';
88
import path from 'node:path';
99
import { fileURLToPath } from 'node:url';
10-
import { describe, it, vi, beforeEach } from 'vitest';
10+
import { describe, it, expect, vi, beforeEach } from 'vitest';
1111
import {
1212
assertAnalyticsCspDirectives,
1313
assertAuthCspDirectives,
@@ -45,4 +45,17 @@ describe('CSP security headers', () => {
4545
it('public/_headers allows PostHog analytics', () => {
4646
assertAnalyticsCspDirectives(headersCsp, 'public/_headers');
4747
});
48+
49+
it('public/_headers allows Cloudflare Turnstile', () => {
50+
const directives = parseCspDirectives(headersCsp);
51+
expect(directives.get('script-src')).toContain(
52+
'https://challenges.cloudflare.com'
53+
);
54+
expect(directives.get('connect-src')).toContain(
55+
'https://challenges.cloudflare.com'
56+
);
57+
expect(directives.get('frame-src')).toContain(
58+
'https://challenges.cloudflare.com'
59+
);
60+
});
4861
});

src/services/authService.js

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,26 @@ function requireClient() {
2424
/**
2525
* Render an invisible Cloudflare Turnstile widget and resolve with a
2626
* verification token. Returns null when the site key is not configured
27-
* or the Turnstile script failed to load.
27+
* (local/CI without Turnstile). Rejects when the site key is set but the
28+
* script is missing or the challenge fails — avoids sending a token-less
29+
* signup into the server gate.
2830
* @returns {Promise<string | null>}
2931
*/
3032
function getTurnstileToken() {
31-
return new Promise(resolve => {
33+
return new Promise((resolve, reject) => {
3234
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
33-
if (
34-
!siteKey ||
35-
typeof (/** @type {any} */ (globalThis).turnstile) === 'undefined'
36-
) {
35+
if (!siteKey) {
3736
resolve(null);
3837
return;
3938
}
4039

40+
if (typeof (/** @type {any} */ (globalThis).turnstile) === 'undefined') {
41+
reject(
42+
new Error('Turnstile is not available. Please refresh and try again.')
43+
);
44+
return;
45+
}
46+
4147
const turnstile = /** @type {any} */ (globalThis).turnstile;
4248
const container = document.createElement('div');
4349
container.style.cssText =
@@ -46,7 +52,7 @@ function getTurnstileToken() {
4652

4753
const timeoutId = setTimeout(() => {
4854
container.remove();
49-
resolve(null);
55+
reject(new Error('Turnstile verification timed out. Please try again.'));
5056
}, 10000);
5157

5258
turnstile.render(container, {
@@ -60,7 +66,7 @@ function getTurnstileToken() {
6066
'error-callback': () => {
6167
clearTimeout(timeoutId);
6268
container.remove();
63-
resolve(null);
69+
reject(new Error('Turnstile verification failed. Please try again.'));
6470
},
6571
});
6672
});

src/services/authService.test.js

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { describe, it, expect, beforeEach, vi } from 'vitest';
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
28
import {
39
mockSupabaseConfigured,
410
resetSupabaseMocks,
@@ -23,6 +29,13 @@ describe('authService', () => {
2329
vi.mocked(requestGoogleSignInPopup).mockResolvedValue({
2430
idToken: 'google-id-token',
2531
});
32+
vi.stubEnv('VITE_TURNSTILE_SITE_KEY', '');
33+
delete globalThis.turnstile;
34+
});
35+
36+
afterEach(() => {
37+
vi.unstubAllEnvs();
38+
delete globalThis.turnstile;
2639
});
2740

2841
it('signInWithGoogleIdToken calls Supabase signInWithIdToken', async () => {
@@ -77,6 +90,23 @@ describe('authService', () => {
7790
});
7891
});
7992

93+
it('signInWithGoogleIdToken includes Turnstile token in user metadata when provided', async () => {
94+
await authService.signInWithGoogleIdToken(
95+
'token-123',
96+
undefined,
97+
undefined,
98+
'turnstile-token'
99+
);
100+
101+
expect(supabaseAuthMock.signInWithIdToken).toHaveBeenCalledWith({
102+
provider: 'google',
103+
token: 'token-123',
104+
nonce: undefined,
105+
access_token: undefined,
106+
data: { cf_turnstile_response: 'turnstile-token' },
107+
});
108+
});
109+
80110
it('signInWithGoogle uses GIS button then Supabase signInWithIdToken', async () => {
81111
await authService.signInWithGoogle();
82112

@@ -89,6 +119,81 @@ describe('authService', () => {
89119
});
90120
});
91121

122+
it('signInWithGoogle omits Turnstile metadata when site key is unset', async () => {
123+
await authService.signInWithGoogle();
124+
125+
expect(supabaseAuthMock.signInWithIdToken).toHaveBeenCalledWith({
126+
provider: 'google',
127+
token: 'google-id-token',
128+
nonce: undefined,
129+
access_token: undefined,
130+
});
131+
});
132+
133+
it('signInWithGoogle passes Turnstile token when widget succeeds', async () => {
134+
vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'test-site-key');
135+
globalThis.turnstile = {
136+
render: vi.fn((_container, options) => {
137+
options.callback('cf-turnstile-ok');
138+
return 'widget-id';
139+
}),
140+
};
141+
142+
await authService.signInWithGoogle();
143+
144+
expect(supabaseAuthMock.signInWithIdToken).toHaveBeenCalledWith({
145+
provider: 'google',
146+
token: 'google-id-token',
147+
nonce: undefined,
148+
access_token: undefined,
149+
data: { cf_turnstile_response: 'cf-turnstile-ok' },
150+
});
151+
});
152+
153+
it('signInWithGoogle throws when site key is set but Turnstile script is missing', async () => {
154+
vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'test-site-key');
155+
156+
await expect(authService.signInWithGoogle()).rejects.toThrow(
157+
/Turnstile is not available/
158+
);
159+
expect(supabaseAuthMock.signInWithIdToken).not.toHaveBeenCalled();
160+
});
161+
162+
it('signInWithGoogle throws when Turnstile challenge errors', async () => {
163+
vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'test-site-key');
164+
globalThis.turnstile = {
165+
render: vi.fn((_container, options) => {
166+
options['error-callback']();
167+
return 'widget-id';
168+
}),
169+
};
170+
171+
await expect(authService.signInWithGoogle()).rejects.toThrow(
172+
/Turnstile verification failed/
173+
);
174+
expect(supabaseAuthMock.signInWithIdToken).not.toHaveBeenCalled();
175+
});
176+
177+
it('signInWithGoogle throws when Turnstile challenge times out', async () => {
178+
vi.useFakeTimers();
179+
try {
180+
vi.stubEnv('VITE_TURNSTILE_SITE_KEY', 'test-site-key');
181+
globalThis.turnstile = {
182+
render: vi.fn(() => 'widget-id'),
183+
};
184+
185+
const signInPromise = authService.signInWithGoogle();
186+
const rejection = expect(signInPromise).rejects.toThrow(
187+
/Turnstile verification timed out/
188+
);
189+
await vi.advanceTimersByTimeAsync(10000);
190+
await rejection;
191+
expect(supabaseAuthMock.signInWithIdToken).not.toHaveBeenCalled();
192+
} finally {
193+
vi.useRealTimers();
194+
}
195+
});
196+
92197
it('signOut delegates to Supabase auth', async () => {
93198
await authService.signOut();
94199
expect(supabaseAuthMock.signOut).toHaveBeenCalled();

supabase/functions/before-signup/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,15 @@ async function verifyTurnstileToken(
9090

9191
export async function handleRequest(req) {
9292
if (req.method !== 'POST') {
93-
return await reject('method_not_allowed', { method: req.method });
93+
// Probes/scanners hit this URL with GET — log only, no Telegram noise.
94+
console.warn('before-signup: method_not_allowed', { method: req.method });
95+
return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
96+
status: 405,
97+
headers: {
98+
'Content-Type': 'application/json',
99+
Allow: 'POST',
100+
},
101+
});
94102
}
95103

96104
try {
@@ -125,7 +133,7 @@ export async function handleRequest(req) {
125133
);
126134

127135
if (!turnstile.success) {
128-
return await reject('turnstile_failed', { ip });
136+
return await reject('turnstile_failed', { email, ip });
129137
}
130138

131139
const supabase = getServiceClient();

0 commit comments

Comments
 (0)