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:
parent
548c19f3fa
commit
a89788f531
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
1209
main-site/package-lock.json
generated
1209
main-site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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(' ');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user