mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-13 02:28:50 -05:00
captcha: add captcha widget to the signup window
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user