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}
|
SMTP_PASS: ${SMTP_PASS}
|
||||||
CONTACT_TO: ${CONTACT_TO}
|
CONTACT_TO: ${CONTACT_TO}
|
||||||
NTFY_URL: ${NTFY_URL:-}
|
NTFY_URL: ${NTFY_URL:-}
|
||||||
|
ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./main-site/update.json:/usr/src/app/update.json
|
- ./main-site/update.json:/usr/src/app/update.json
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@ -141,6 +141,16 @@
|
|||||||
return;
|
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();
|
const fd = new FormData();
|
||||||
fd.append('firstName', form.querySelector('[name="firstName"]').value.trim());
|
fd.append('firstName', form.querySelector('[name="firstName"]').value.trim());
|
||||||
fd.append('lastName', form.querySelector('[name="lastName"]').value.trim());
|
fd.append('lastName', form.querySelector('[name="lastName"]').value.trim());
|
||||||
@ -153,6 +163,7 @@
|
|||||||
if (ed) fd.append('eventDate', ed.value);
|
if (ed) fd.append('eventDate', ed.value);
|
||||||
const hp = form.querySelector('[name="website"]');
|
const hp = form.querySelector('[name="website"]');
|
||||||
if (hp) fd.append('website', hp.value);
|
if (hp) fd.append('website', hp.value);
|
||||||
|
fd.append('altcha', altchaValue);
|
||||||
selectedFiles.forEach(f => fd.append('photos', f));
|
selectedFiles.forEach(f => fd.append('photos', f));
|
||||||
|
|
||||||
submitBtn.classList.add('is-loading');
|
submitBtn.classList.add('is-loading');
|
||||||
|
|||||||
@ -143,6 +143,14 @@
|
|||||||
|
|
||||||
<div id="formAlert" class="notification" style="display:none;"></div>
|
<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="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" id="submitBtn" class="button is-info is-fullwidth">Send Message</button>
|
<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="../script.js"></script>
|
||||||
<script src="../easter-egg.js"></script>
|
<script src="../easter-egg.js"></script>
|
||||||
<script src="../contact-form.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>
|
</body>
|
||||||
</html>
|
</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": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"altcha": "^3.1.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const cors = require('cors');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
|
const { createChallenge, verifySolution, sha: altchaSha } = require('altcha/dist/lib/index.umd.cjs');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3050;
|
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) => {
|
apiRouter.post('/contact', upload.array('photos', 3), async (req, res) => {
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
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 });
|
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 { firstName, lastName, email, phone, message, eventType, eventDate } = req.body;
|
||||||
const name = [firstName, lastName].filter(Boolean).join(' ');
|
const name = [firstName, lastName].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user