diff --git a/doc/RFCS/20250826_captcha.md b/doc/RFCS/20250826_captcha.md new file mode 100644 index 000000000..2011833a7 --- /dev/null +++ b/doc/RFCS/20250826_captcha.md @@ -0,0 +1,57 @@ +- Feature Name: Cloudflare Turnstile CAPTCHA +- Status: In Progress +- Created: 2025-08-26 + +## Summary + +We propose integrating **Cloudflare Turnstile** to protect our signup flow against automated bot activity, while maintaining a seamless experience for legitimate users. + +## Motivation + +Puter allocates resources to **free** user account — including storage, compute, and AI credits. To prevent these from being exploited by bots, we need a more robust verification mechanism. Although Puter currently includes a [custom CAPTCHA service](https://github.com/HeyPuter/puter/blob/4c3a68ee51a1b255edbe6b3c7e4c4e3b0394dae3/src/backend/src/modules/captcha/services/CaptchaService.js), it has several shortcomings: + +* The text-recognition CAPTCHA creates friction and disrupts the user experience. +* Maintaining a token pool is resource-intensive and doesn’t scale well. The validation logic also requires ongoing maintenance within the codebase. + +## Choose of Service Provider + +We choose Cloudflare Turnstile since: + +* It's free for unlimited use. +* It's easy to integrate. +* It's relative secure. + +Here's a comparison of major CAPTCHA providers: + +| Provider | Security (typical) | User experience (typical) | Price (publicly listed) | +| --------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Cloudflare Turnstile** | **High** for most sites; adaptive challenges; works without image puzzles. | **Excellent** (can be fully invisible or auto-verify; checkbox only for risky traffic). | **Free for everyone (unlimited use)**. ([The Cloudflare Blog][1], [cloudflare.com][2]) | +| **Google reCAPTCHA (Essentials / Standard / Enterprise)** | **Medium–High** (v3 score + server rules; Enterprise adds features & support). | **Good–OK** (v3 is invisible; v2 can show puzzles). | **Free up to 10k assessments/mo; \$8 for up to 100k/mo; then \$1 per 1k** (Enterprise tiers). ([Google Cloud][3]) | +| **hCaptcha (Basic / Pro / Enterprise)** | **High** (ML signals; enterprise options). | **Good** on Basic; **Very good** on Pro with “low-friction 99.9% passive mode.” | **Basic: Free. Pro: \$99/mo annual (\$139 month-to-month) incl. 100k evals, then \$0.99/1k**; Enterprise custom. ([hcaptcha.com][4]) | +| **Friendly Captcha** | **Medium–High** (proof-of-work + risk signals). | **Excellent** (invisible/automatic challenge; no image tasks). | **Starter €9/mo (1k req/mo); Growth €39/mo (5k/mo); Advanced €200/mo (50k/mo); Free non-commercial 1k/mo**; Enterprise custom. ([Friendly Captcha][5]) | +| **Arkose Labs (FunCaptcha / MatchKey)** | **Very High** (step-up, anti-farm, enterprise focus). | **Good–OK** (challenge can be more involved when risk is high). | **Enterprise pricing (contact sales)**; publicly not listed. (Product overview only.) ([Arkose Labs][6]) | + +[1]: https://blog.cloudflare.com/turnstile-ga/?utm_source=chatgpt.com "Cloudflare is free of CAPTCHAs; Turnstile is free for everyone" +[2]: https://www.cloudflare.com/application-services/products/turnstile/?utm_source=chatgpt.com "Cloudflare Turnstile | CAPTCHA Replacement Solution" +[3]: https://cloud.google.com/recaptcha/docs/compare-tiers?utm_source=chatgpt.com "Compare features between reCAPTCHA tiers" +[4]: https://www.hcaptcha.com/pricing?utm_source=chatgpt.com "Pricing" +[5]: https://friendlycaptcha.com/ "Friendly Captcha - Privacy-First Bot Protection" +[6]: https://www.arkoselabs.com/arkose-matchkey/?utm_source=chatgpt.com "Arkose MatchKey Advanced CAPTCHA Software" + +## Implementation + +### Signup Flow + +When a user submits the signup form, the client will include a **Turnstile token** alongside the other form data. +On the backend, Puter will call the **Cloudflare Turnstile verification API** to validate this token before provisioning a new account. + +Only if the token is verified as valid will the signup request be processed. Invalid or missing tokens will result in a rejected signup attempt. + +### Desktop Rendering + +TODO + +## Configuration + +TODO + diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js index 63fdbc2ca..af7b43fca 100644 --- a/src/backend/src/routers/signup.js +++ b/src/backend/src/routers/signup.js @@ -83,6 +83,23 @@ module.exports = eggspress(['/signup'], { return res.send(); + // cloudflare turnstile validation + if (config.services?.['cloudflare-turnstile']?.enabled) { + const formData = new FormData(); + formData.append('secret', config.services?.['cloudflare-turnstile']?.secret_key); + formData.append('response', req.body['cf-turnstile-response']); + formData.append('remoteip', req.headers['x-forwarded-for'] || req.connection.remoteAddress); + + const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + if (!result.success) + return res.status(400).send('captcha verification failed'); + } + // send event let event = { allow: true, diff --git a/src/backend/src/services/PuterHomepageService.js b/src/backend/src/services/PuterHomepageService.js index e2a3607da..858ad49ac 100644 --- a/src/backend/src/services/PuterHomepageService.js +++ b/src/backend/src/services/PuterHomepageService.js @@ -166,6 +166,8 @@ class PuterHomepageService extends BaseService { co_isolation_enabled: req.co_isolation_enabled, // Add captcha requirements to GUI parameters captchaRequired: captchaRequired, + // Add Turnstile site key to GUI parameters + turnstileSiteKey: config.services?.['cloudflare-turnstile']?.site_key, }, })); } diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 64ccda61f..997576941 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -79,6 +79,15 @@ function UIWindowSignup(options){ // bot trap - if this value is submitted server will ignore the request h += ``; + // Turnstile widget + if(window.gui_params?.turnstileSiteKey){ + h += `
`; + // Get Turnstile site key from configuration, with fallback + const turnstileSiteKey = window.gui_params?.turnstileSiteKey || '3x00000000000000000000FF'; + h += `
`; + h += `
`; + } + // terms and privacy h += `

${i18n('tos_fineprint', [], false)}

`; // Create Account @@ -115,6 +124,48 @@ function UIWindowSignup(options){ center: true, onAppend: function(el_window){ $(el_window).find(`.username`).get(0).focus({preventScroll:true}); + + // Initialize Turnstile widget with callback to capture token + const initTurnstile = () => { + if (window.turnstile) { + // Get Turnstile site key from configuration, with fallback + const turnstileSiteKey = window.gui_params?.turnstileSiteKey; + window.turnstile.render('.cf-turnstile', { + sitekey: turnstileSiteKey, + callback: function(token) { + // Store the token for the signup request + $(el_window).find('.cf-turnstile').attr('data-token', token); + // Enable the signup button once CAPTCHA is completed + $(el_window).find('.signup-btn').prop('disabled', false); + // Add visual feedback + $(el_window).find('.cf-turnstile').addClass('captcha-completed'); + }, + 'expired-callback': function() { + // Reset when token expires + $(el_window).find('.cf-turnstile').removeAttr('data-token'); + $(el_window).find('.cf-turnstile').removeClass('captcha-completed'); + $(el_window).find('.signup-btn').prop('disabled', true); + } + }); + // Initially disable signup button until CAPTCHA is completed + $(el_window).find('.signup-btn').prop('disabled', true); + } else { + // If Turnstile isn't loaded yet, wait for it + setTimeout(initTurnstile, 100); + } + }; + + // Helper function to reset Turnstile state + const resetTurnstile = () => { + if (window.turnstile) { + window.turnstile.reset('.cf-turnstile'); + $(el_window).find('.cf-turnstile').removeAttr('data-token'); + $(el_window).find('.cf-turnstile').removeClass('captcha-completed'); + $(el_window).find('.signup-btn').prop('disabled', true); + } + }; + + initTurnstile(); }, window_class: 'window-signup', window_css:{ @@ -129,7 +180,19 @@ function UIWindowSignup(options){ 'justify-content': 'center', 'align-items': 'center', padding: '30px 10px 10px 10px', - } + }, + // Add custom CSS for CAPTCHA states + custom_css: ` + .cf-turnstile.captcha-completed { + border: 2px solid #4CAF50; + border-radius: 4px; + padding: 2px; + } + .signup-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + ` }) $(el_window).find('.login-c2a-clickable').on('click', async function(e){ @@ -205,6 +268,14 @@ function UIWindowSignup(options){ return; } + // Check if CAPTCHA was completed + const turnstileToken = $(el_window).find('.cf-turnstile').attr('data-token'); + if (!turnstileToken) { + $(el_window).find('.signup-error-msg').html(i18n('captcha_required') || 'Please complete the CAPTCHA verification'); + $(el_window).find('.signup-error-msg').fadeIn(); + return; + } + //xyzname let p102xyzname = $(el_window).find('.p102xyzname').val(); @@ -224,6 +295,7 @@ function UIWindowSignup(options){ referrer: options.referrer ?? window.referrerStr, send_confirmation_code: options.send_confirmation_code, p102xyzname: p102xyzname, + 'cf-turnstile-response': turnstileToken }; $.ajax({ @@ -254,6 +326,9 @@ function UIWindowSignup(options){ // re-enable 'Create Account' button so user can try again $(el_window).find('.signup-btn').prop('disabled', false); + // Reset Turnstile widget for retry + resetTurnstile(); + // Process error response const errorText = err.responseText || ''; diff --git a/src/gui/src/index.js b/src/gui/src/index.js index c08cb4bd7..e98007730 100644 --- a/src/gui/src/index.js +++ b/src/gui/src/index.js @@ -78,6 +78,9 @@ window.gui = async (options) => { await window.loadCSS('/dist/bundle.min.css'); } + // Load Cloudflare Turnstile script + await window.loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js', { defer: true }); + // 🚀 Launch the GUI 🚀 window.initgui(options); } diff --git a/tools/api-tester/tests/__entry__.js b/tools/api-tester/tests/__entry__.js index 784375d9d..b3fcde670 100644 --- a/tools/api-tester/tests/__entry__.js +++ b/tools/api-tester/tests/__entry__.js @@ -1,4 +1,12 @@ module.exports = registry => { + // ====================================================================== + // Auth + // ====================================================================== + registry.add_test('auth', require('./auth')); + + // ====================================================================== + // File System + // ====================================================================== registry.add_test('write_cart', require('./write_cart')); registry.add_test('move_cart', require('./move_cart')); registry.add_test('copy_cart', require('./copy_cart')); diff --git a/tools/api-tester/tests/auth.js b/tools/api-tester/tests/auth.js new file mode 100644 index 000000000..56cf1ac46 --- /dev/null +++ b/tools/api-tester/tests/auth.js @@ -0,0 +1,28 @@ +const axios = require('axios'); + +module.exports = { + name: 'auth', + do: async t => { + await t.case('signup', async () => { + const endpoint = 'signup'; + const params = { + username: 'test', + password: 'test', + }; + const res = await axios.request({ + httpsAgent: this.httpsAgent, + method: 'post', + url: t.getURL(endpoint), + data: params, + headers: { + ...t.headers_, + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + } + }) + console.log('res.status:', res?.status); + console.log('res.statusText:', res?.statusText); + console.log('res.data:', res?.data); + }); + }, +}; \ No newline at end of file