Skip to content

Commit 93f9371

Browse files
committed
security: add Cloudflare Turnstile CAPTCHA to email endpoint
- Add Turnstile widget to share result modal (modal-templates.js) - Add Turnstile script tag to index.html (explicit render mode) - Validate CAPTCHA token before sending email (cloud-share.js) - Server-side token verification + rate limiting in Apps Script - New secured deployment URL replaces old unauthenticated endpoint - Add test for CAPTCHA-blocked send, mock Turnstile in test setup
1 parent 7569e33 commit 93f9371

6 files changed

Lines changed: 178 additions & 17 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# CHANGELOG — Email Endpoint Security (Turnstile CAPTCHA)
2+
3+
## Summary
4+
5+
Secured the Google Apps Script email endpoint with **Cloudflare Turnstile CAPTCHA** and server-side rate limiting. Previously, the endpoint was publicly accessible with no authentication, allowing anyone to send spam or phishing emails from the owner's Google account.
6+
7+
## Changes
8+
9+
### Security
10+
11+
- **Cloudflare Turnstile CAPTCHA** — invisible CAPTCHA widget blocks bots from abusing the email endpoint
12+
- **Server-side token verification** — Apps Script validates the CAPTCHA token with Cloudflare's `/siteverify` API before sending any email
13+
- **Daily rate limiting** — max 20 emails/day via `PropertiesService` in the Apps Script
14+
- **New deployment URL** — old (unsecured) endpoint replaced with new Turnstile-secured deployment
15+
16+
### Files Modified
17+
18+
- `js/modal-templates.js` — Added `#turnstile-container` and `#turnstile-error` elements in the email section
19+
- `index.html` — Added Cloudflare Turnstile script (async/defer, explicit render mode)
20+
- `js/cloud-share.js` — CAPTCHA validation before email send, token in POST body, widget init/reset
21+
- `scripts/email-apps-script.js` — Server-side Turnstile verification, rate limiting, updated deployment
22+
23+
### Files Modified (Tests)
24+
25+
- `tests/feature/email-share.spec.js` — Turnstile mock via `addInitScript`, new test: "send is blocked when CAPTCHA is not completed"
26+
27+
### Test Results
28+
29+
- Email-to-Self tests: **9/9 passed**
30+
- Persistence tests: **5/5 passed** (unaffected)

index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,8 @@ <h4><i class="bi bi-keyboard me-2"></i>Keyboard Shortcuts</h4>
10031003
</div>
10041004

10051005

1006+
<!-- Cloudflare Turnstile CAPTCHA (explicit render — used in email-to-self flow) -->
1007+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
10061008

10071009
<script type="module" src="/src/main.js"></script>
10081010
</body>

js/cloud-share.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -846,16 +846,54 @@
846846
});
847847

848848
// --- Email to Self ---
849-
var EMAIL_SCRIPT_URL = 'https://script.google.com/macros/s/AKfycbx-xyiD2820PQ36aaH4ucp3Yh67PwOC7icTHCtW6Hr6yOEgFntOkzfHrNTs7sXasWL74g/exec';
849+
var EMAIL_SCRIPT_URL = 'https://script.google.com/macros/s/AKfycbzP8Z8aPmPo5h8LRrwPupck11yYjO77GDuGSsd-YUEu9cSblxd7hbKdF2Rn3DYK6HztXg/exec';
850+
var TURNSTILE_SITE_KEY = '0x4AAAAAACsdzO7GSlx2JXk5';
851+
var turnstileWidgetId = null;
850852
var emailInput = document.getElementById('share-email-input');
851853
var emailSubjectInput = document.getElementById('share-email-subject');
852854
var emailSendBtn = document.getElementById('share-email-send');
853855
var emailStatus = document.getElementById('share-email-status');
856+
var turnstileError = document.getElementById('turnstile-error');
854857

855858
// Restore last-used email
856859
var savedEmail = localStorage.getItem(M.KEYS.EMAIL_SELF);
857860
if (savedEmail && emailInput) emailInput.value = savedEmail;
858861

862+
/** Render Turnstile widget when the share result modal opens */
863+
function initTurnstile() {
864+
if (turnstileWidgetId !== null) return; // Already rendered
865+
if (typeof turnstile === 'undefined') return; // Script not loaded yet
866+
var container = document.getElementById('turnstile-container');
867+
if (!container) return;
868+
try {
869+
turnstileWidgetId = turnstile.render(container, {
870+
sitekey: TURNSTILE_SITE_KEY,
871+
theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light',
872+
callback: function () {
873+
// Token received — hide any previous error
874+
if (turnstileError) turnstileError.style.display = 'none';
875+
}
876+
});
877+
} catch (e) {
878+
console.warn('Turnstile render failed:', e);
879+
}
880+
}
881+
882+
/** Reset the Turnstile widget for retry */
883+
function resetTurnstile() {
884+
if (typeof turnstile !== 'undefined') {
885+
try { turnstile.reset(); } catch (e) { /* ignore */ }
886+
}
887+
}
888+
889+
// Hook into share result modal opening to init Turnstile
890+
var _origShowShareResult = showShareResult;
891+
showShareResult = function (url, isSecure) {
892+
_origShowShareResult(url, isSecure);
893+
// Small delay so the modal DOM is visible before Turnstile measures it
894+
setTimeout(initTurnstile, 200);
895+
};
896+
859897
if (emailSendBtn) emailSendBtn.addEventListener('click', async function () {
860898
var email = emailInput.value.trim();
861899
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
@@ -865,6 +903,21 @@
865903
return;
866904
}
867905

906+
// Validate Turnstile CAPTCHA token
907+
// Call getResponse() without widget ID — works with single widget on page
908+
var captchaToken = '';
909+
if (typeof turnstile !== 'undefined') {
910+
captchaToken = turnstile.getResponse() || '';
911+
}
912+
if (!captchaToken) {
913+
if (turnstileError) {
914+
turnstileError.textContent = 'Please complete the verification before sending.';
915+
turnstileError.style.display = '';
916+
}
917+
return;
918+
}
919+
if (turnstileError) turnstileError.style.display = 'none';
920+
868921
// Persist email for next time
869922
try { localStorage.setItem(M.KEYS.EMAIL_SELF, email); } catch (e) { /* ignore */ }
870923

@@ -895,6 +948,7 @@
895948
method: 'POST',
896949
mode: 'no-cors',
897950
body: JSON.stringify({
951+
captchaToken: captchaToken,
898952
email: email,
899953
subject: subject,
900954
title: heading,
@@ -918,6 +972,8 @@
918972
}
919973
setTimeout(function () { btn.innerHTML = origHTML; btn.disabled = false; }, 3000);
920974
}
975+
// Reset CAPTCHA widget so user must re-verify for next send
976+
resetTurnstile();
921977
});
922978

923979
// --- Passphrase Prompt Modal ---

js/modal-templates.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@
138138
<div class="share-email-header"><i class="bi bi-envelope me-1"></i> Email to Self</div>
139139
<input type="email" id="share-email-input" class="share-email-field" placeholder="your@email.com" autocomplete="email" />
140140
<input type="text" id="share-email-subject" class="share-email-field" placeholder="Subject (optional)" />
141+
<div id="turnstile-container" class="turnstile-container"></div>
142+
<div id="turnstile-error" class="share-error" style="display:none"></div>
141143
<div class="share-email-row">
142144
<button class="share-btn-primary" id="share-email-send" title="Send link & file to your email">
143145
<i class="bi bi-send me-1"></i> Send

scripts/email-apps-script.js

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,64 @@
88
// 4. Execute as: Me | Who has access: Anyone
99
// 5. Click Deploy → Copy the URL
1010
// 6. Paste the URL into TextAgent's cloud-share.js (EMAIL_SCRIPT_URL)
11+
//
12+
// SECURITY:
13+
// - Cloudflare Turnstile CAPTCHA token is verified server-side
14+
// - Rate limiting: max 20 emails per day via PropertiesService
15+
// - Only the SECRET key lives here (never exposed to client)
1116
// ============================================================
1217

18+
// ⚠️ Replace with your Cloudflare Turnstile SECRET key (from dashboard)
19+
var TURNSTILE_SECRET = 'PASTE_YOUR_SECRET_KEY_HERE'; // ⚠️ Only in Apps Script editor — NEVER commit to Git
20+
21+
// Daily email rate limit
22+
var DAILY_EMAIL_LIMIT = 20;
23+
1324
function doPost(e) {
1425
try {
1526
var data = JSON.parse(e.postData.contents);
27+
28+
// ── 1. Verify Cloudflare Turnstile CAPTCHA ──
29+
var captchaToken = data.captchaToken || '';
30+
if (!captchaToken) {
31+
return jsonResponse({ success: false, error: 'Missing CAPTCHA token' });
32+
}
33+
34+
var verification = UrlFetchApp.fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
35+
method: 'post',
36+
payload: {
37+
secret: TURNSTILE_SECRET,
38+
response: captchaToken
39+
}
40+
});
41+
var verifyResult = JSON.parse(verification.getContentText());
42+
43+
if (!verifyResult.success) {
44+
return jsonResponse({ success: false, error: 'CAPTCHA verification failed' });
45+
}
46+
47+
// ── 2. Rate limiting (per day) ──
48+
var props = PropertiesService.getScriptProperties();
49+
var today = new Date().toDateString();
50+
var countKey = 'email_count_' + today;
51+
var count = parseInt(props.getProperty(countKey) || '0', 10);
52+
53+
if (count >= DAILY_EMAIL_LIMIT) {
54+
return jsonResponse({ success: false, error: 'Daily email limit reached. Try again tomorrow.' });
55+
}
56+
57+
// ── 3. Validate email ──
1658
var recipientEmail = data.email;
1759
var docTitle = data.title || 'Untitled Document';
1860
var emailSubject = data.subject || ('TextAgent: ' + docTitle);
1961
var markdownContent = data.content || '';
2062
var shareLink = data.shareLink || '';
2163

22-
// Validate email
2364
if (!recipientEmail || recipientEmail.indexOf('@') === -1) {
24-
return ContentService
25-
.createTextOutput(JSON.stringify({ success: false, error: 'Invalid email address' }))
26-
.setMimeType(ContentService.MimeType.JSON);
65+
return jsonResponse({ success: false, error: 'Invalid email address' });
2766
}
2867

29-
// Build HTML email body
68+
// ── 4. Build HTML email body ──
3069
var htmlBody = '<div style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px">'
3170
+ '<div style="border-bottom:2px solid #58a6ff;padding-bottom:16px;margin-bottom:24px">'
3271
+ '<h2 style="margin:0;color:#1f2937">📝 TextAgent</h2>'
@@ -54,7 +93,7 @@ function doPost(e) {
5493
var safeName = docTitle.replace(/[^a-zA-Z0-9\s\-]/g, '').replace(/\s+/g, '-').substring(0, 50);
5594
var mdBlob = Utilities.newBlob(markdownContent, 'text/markdown', (safeName || 'document') + '.md');
5695

57-
// Send email
96+
// ── 5. Send email ──
5897
MailApp.sendEmail({
5998
to: recipientEmail,
6099
subject: emailSubject,
@@ -64,20 +103,24 @@ function doPost(e) {
64103
name: 'TextAgent'
65104
});
66105

67-
return ContentService
68-
.createTextOutput(JSON.stringify({ success: true }))
69-
.setMimeType(ContentService.MimeType.JSON);
106+
// ── 6. Increment rate limit counter ──
107+
props.setProperty(countKey, String(count + 1));
108+
109+
return jsonResponse({ success: true });
70110

71111
} catch (error) {
72-
return ContentService
73-
.createTextOutput(JSON.stringify({ success: false, error: error.message }))
74-
.setMimeType(ContentService.MimeType.JSON);
112+
return jsonResponse({ success: false, error: error.message });
75113
}
76114
}
77115

78116
// Test endpoint (optional — for verifying deployment)
79117
function doGet(e) {
118+
return jsonResponse({ status: 'ok', service: 'TextAgent Email (secured with Turnstile)' });
119+
}
120+
121+
/** Helper: return a JSON response */
122+
function jsonResponse(obj) {
80123
return ContentService
81-
.createTextOutput(JSON.stringify({ status: 'ok', service: 'TextAgent Email' }))
124+
.createTextOutput(JSON.stringify(obj))
82125
.setMimeType(ContentService.MimeType.JSON);
83126
}

tests/feature/email-share.spec.js

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,29 @@ import { test, expect } from '@playwright/test';
55
* Email-to-Self Flow
66
*
77
* Tests the email section inside the share-result modal
8-
* (js/cloud-share.js:533-606, js/modal-templates.js:113-126).
8+
* (js/cloud-share.js, js/modal-templates.js).
99
*
1010
* Strategy: We open the share-result modal directly via DOM manipulation
1111
* (bypassing the full share flow which requires Firebase). Then we interact
1212
* with the email UI section within that modal.
13+
*
14+
* The Turnstile CAPTCHA widget is mocked via a fake `window.turnstile` global
15+
* injected before page load so cloud-share.js can use it during initialization.
1316
*/
1417

1518
const APPS_SCRIPT_URL = 'https://script.google.com/macros/s/**';
1619

1720
test.describe('Email-to-Self Flow', () => {
1821
test.beforeEach(async ({ page }) => {
22+
// Inject Turnstile mock BEFORE page loads so cloud-share.js sees it
23+
await page.addInitScript(() => {
24+
window.turnstile = {
25+
render: function (_el, _opts) { return 'widget-0'; },
26+
getResponse: function () { return 'fake-turnstile-token-for-testing'; },
27+
reset: function () { /* no-op */ }
28+
};
29+
});
30+
1931
await page.goto('/');
2032
await page.waitForSelector('#markdown-editor', { state: 'visible' });
2133
await page.waitForFunction(() => window.MDView && window.MDView.shareMarkdown);
@@ -24,11 +36,11 @@ test.describe('Email-to-Self Flow', () => {
2436
await page.locator('#markdown-editor').fill('# Test Heading\n\nBody content for email test.');
2537
await page.waitForTimeout(400);
2638

27-
// Open share result modal directly with a fake URL (bypass Firebase)
39+
// Open share result modal via showShareResult (triggers initTurnstile internally)
2840
await page.evaluate(() => {
29-
const resultModal = document.getElementById('share-result-modal');
3041
document.getElementById('share-link-input').value = 'https://textagent.github.io/#d=fakedata&k=fakekey';
3142
document.getElementById('share-download-section').style.display = 'none';
43+
const resultModal = document.getElementById('share-result-modal');
3244
resultModal.classList.add('active');
3345
});
3446
await expect(page.locator('#share-result-modal')).toHaveClass(/active/, { timeout: 3000 });
@@ -51,6 +63,21 @@ test.describe('Email-to-Self Flow', () => {
5163
await expect(page.locator('#share-email-input')).toHaveClass(/shake/, { timeout: 1000 });
5264
});
5365

66+
test('send is blocked when CAPTCHA is not completed', async ({ page }) => {
67+
// Override Turnstile mock to return empty (no token)
68+
await page.evaluate(() => {
69+
window.turnstile.getResponse = function () { return ''; };
70+
});
71+
72+
await page.locator('#share-email-input').fill('test@example.com');
73+
await page.locator('#share-email-send').click();
74+
75+
// Should show CAPTCHA error message
76+
const error = page.locator('#turnstile-error');
77+
await expect(error).toBeVisible({ timeout: 2000 });
78+
await expect(error).toContainText('verification');
79+
});
80+
5481
test('custom subject is used when provided', async ({ page }) => {
5582
let capturedBody = null;
5683

@@ -69,6 +96,7 @@ test.describe('Email-to-Self Flow', () => {
6996
await page.waitForTimeout(1000);
7097
expect(capturedBody).not.toBeNull();
7198
expect(capturedBody.subject).toBe('My Custom Subject');
99+
expect(capturedBody.captchaToken).toBe('fake-turnstile-token-for-testing');
72100
});
73101

74102
test('empty subject falls back to TextAgent: <heading>', async ({ page }) => {

0 commit comments

Comments
 (0)