captcha: add captcha widget to the signup window

This commit is contained in:
XiaochenCui
2025-08-26 16:32:37 -07:00
parent e48179a1f9
commit bb2c51b840
7 changed files with 191 additions and 1 deletions
+57
View File
@@ -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 doesnt 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)** | **MediumHigh** (v3 score + server rules; Enterprise adds features & support). | **GoodOK** (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** | **MediumHigh** (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). | **GoodOK** (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
+17
View File
@@ -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,
@@ -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,
},
}));
}
+76 -1
View File
@@ -79,6 +79,15 @@ function UIWindowSignup(options){
// bot trap - if this value is submitted server will ignore the request
h += `<input type="text" name="p102xyzname" class="p102xyzname" value="">`;
// Turnstile widget
if(window.gui_params?.turnstileSiteKey){
h += `<div style="margin-bottom: 20px; display: flex; justify-content: center;">`;
// Get Turnstile site key from configuration, with fallback
const turnstileSiteKey = window.gui_params?.turnstileSiteKey || '3x00000000000000000000FF';
h += `<div class="cf-turnstile" data-sitekey="${turnstileSiteKey}"></div>`;
h += `</div>`;
}
// terms and privacy
h += `<p class="signup-terms">${i18n('tos_fineprint', [], false)}</p>`;
// 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 || '';
+3
View File
@@ -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);
}
+8
View File
@@ -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'));
+28
View File
@@ -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);
});
},
};