Add ALTCHA proof-of-work spam protection to contact form

- Server: /api/altcha generates a SHA-256 challenge (v3 API); /api/contact
  verifies the widget payload before processing the submission
- Widget: added <altcha-widget> from CDN above the submit button
- contact-form.js: blocks submission if altcha value is missing and
  appends it to FormData
- docker-compose.yml: passes ALTCHA_HMAC_KEY env var to main-site container
- package.json: added altcha@3.1.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-11 14:23:42 -04:00
parent 548c19f3fa
commit a89788f531
6 changed files with 1266 additions and 3 deletions

View File

@ -32,6 +32,7 @@ services:
SMTP_PASS: ${SMTP_PASS}
CONTACT_TO: ${CONTACT_TO}
NTFY_URL: ${NTFY_URL:-}
ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY}
volumes:
- ./main-site/update.json:/usr/src/app/update.json
restart: always

View File

@ -141,6 +141,16 @@
return;
}
const altchaWidget = form.querySelector('altcha-widget');
const altchaValue = altchaWidget ? altchaWidget.value : '';
if (!altchaValue) {
const errEl = document.getElementById('err-altcha');
if (errEl) { errEl.style.display = ''; }
showAlert('Please complete the verification.', 'is-warning');
altchaWidget && altchaWidget.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
const fd = new FormData();
fd.append('firstName', form.querySelector('[name="firstName"]').value.trim());
fd.append('lastName', form.querySelector('[name="lastName"]').value.trim());
@ -153,6 +163,7 @@
if (ed) fd.append('eventDate', ed.value);
const hp = form.querySelector('[name="website"]');
if (hp) fd.append('website', hp.value);
fd.append('altcha', altchaValue);
selectedFiles.forEach(f => fd.append('photos', f));
submitBtn.classList.add('is-loading');

View File

@ -143,6 +143,14 @@
<div id="formAlert" class="notification" style="display:none;"></div>
<div class="field">
<altcha-widget
challengeurl="/api/altcha"
style="--altcha-color-border: #dbdbdb; --altcha-border-radius: 4px; --altcha-color-text: #363636;"
></altcha-widget>
<p class="help is-danger" id="err-altcha" style="display:none;">Please complete the verification.</p>
</div>
<div class="field">
<div class="control">
<button type="submit" id="submitBtn" class="button is-info is-fullwidth">Send Message</button>
@ -155,5 +163,6 @@
<script src="../script.js"></script>
<script src="../easter-egg.js"></script>
<script src="../contact-form.js"></script>
<script async src="https://cdn.jsdelivr.net/npm/altcha/dist/altcha.min.js" type="module"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"altcha": "^3.1.0",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",

View File

@ -11,6 +11,7 @@ const cors = require('cors');
const multer = require('multer');
const sharp = require('sharp');
const nodemailer = require('nodemailer');
const { createChallenge, verifySolution, sha: altchaSha } = require('altcha/dist/lib/index.umd.cjs');
const app = express();
const port = 3050;
@ -100,6 +101,21 @@ apiRouter.post('/update-status', requireAuth, (req, res) => {
});
});
apiRouter.get('/altcha', async (req, res) => {
try {
const challenge = await createChallenge({
algorithm: 'SHA-256',
cost: 100,
deriveKey: altchaSha.deriveKey,
hmacSignatureSecret: process.env.ALTCHA_HMAC_KEY || 'dev-key-change-in-production',
});
res.json(challenge);
} catch (err) {
console.error(`[${new Date().toISOString()}] ALTCHA challenge error:`, err);
res.status(500).json({ error: 'Failed to generate challenge' });
}
});
apiRouter.post('/contact', upload.array('photos', 3), async (req, res) => {
// Rate limiting
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
@ -117,6 +133,28 @@ apiRouter.post('/contact', upload.array('photos', 3), async (req, res) => {
return res.json({ success: true });
}
// ALTCHA proof-of-work verification
const altchaPayload = req.body.altcha;
if (!altchaPayload) {
return res.status(400).json({ success: false, message: 'Please complete the verification.' });
}
let altchaOk = false;
try {
const decoded = JSON.parse(Buffer.from(altchaPayload, 'base64').toString('utf-8'));
const result = await verifySolution({
challenge: decoded.challenge,
solution: decoded.solution,
deriveKey: altchaSha.deriveKey,
hmacSignatureSecret: process.env.ALTCHA_HMAC_KEY || 'dev-key-change-in-production',
});
altchaOk = result.verified;
} catch {
altchaOk = false;
}
if (!altchaOk) {
return res.status(400).json({ success: false, message: 'Verification failed. Please refresh and try again.' });
}
const { firstName, lastName, email, phone, message, eventType, eventDate } = req.body;
const name = [firstName, lastName].filter(Boolean).join(' ');