This commit is contained in:
Felix Klement committed 2026-10-05 10:44:55 +02:00
1 parent 490444032c
commit efecc500c4
15 files changed
+349 -47

No files matched your search

+82 -4
View File
@@ -1,14 +1,20 @@
/**
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static.
*
* The form posts first_name, last_name, email, message, lang and the honeypot website. A browser
* with scripting asks for JSON and shows the result in place; without scripting the answer is a
* redirect to /contact/sent or /contact/error (under /de for German).
* The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the
* answer to a proof-of-work challenge from GET /contact/challenge (ALTCHA, solved in the browser
* while the visitor types). The browser asks for JSON and shows the result in place; a post that
* asks for HTML is answered with a redirect to /contact/sent or /contact/error (under /de for German).
* A post without a solved challenge is refused, so the form needs scripting; the address next
* to it does not.
*
* Configuration comes from the environment only, see README.md.
*/
import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import nodemailer from 'nodemailer';
import { createChallenge, randomInt, verifySolution } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/pbkdf2';
const env = process.env;
const PORT = Number(env.CONTACT_PORT || 3000);
@@ -22,6 +28,11 @@ if (!env.SMTP_HOST || !FROM) {
process.exit(78); // EX_CONFIG: the entrypoint does not restart on this one.
}
/* Challenge secrets. Without them set, each start makes its own; challenges then only outlive a
restart if the visitor fetched a new one, which the form does after every send. */
const ALTCHA_SECRET = env.ALTCHA_HMAC_SECRET || randomBytes(32).toString('hex');
const ALTCHA_KEY_SECRET = env.ALTCHA_HMAC_KEY_SECRET || randomBytes(32).toString('hex');
const transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: SMTP_PORT,
@@ -38,6 +49,64 @@ const MAX = { first_name: 100, last_name: 100, email: 254, message: 5000 };
const PER_IP = { count: 5, windowMs: 10 * 60 * 1000 };
const GLOBAL = { count: 60, windowMs: 60 * 60 * 1000 };
/* Proof of work. About half a second of a laptop's time and one to two of a phone's, spent while
the visitor is still typing; a script pays it for every message. The challenge is signed, so
the server keeps nothing until a solution comes back. */
const POW = { algorithm: 'PBKDF2/SHA-256', cost: 1000, counterMin: 2000, counterMax: 5000 };
const CHALLENGE_TTL_MS = 30 * 60 * 1000;
// Nobody writes a message in three seconds from first touching the form; a script does.
const MIN_FILL_MS = 3000;
// Solved challenges, kept until they expire, so one solution sends one message.
const spent = new Map();
setInterval(() => {
const now = Date.now();
for (const [sig, expires] of spent) if (expires < now) spent.delete(sig);
}, 60 * 1000).unref();
function issueChallenge() {
const now = Date.now();
return createChallenge({
algorithm: POW.algorithm,
cost: POW.cost,
counter: randomInt(POW.counterMin, POW.counterMax),
deriveKey,
data: { issued: now },
expiresAt: new Date(now + CHALLENGE_TTL_MS),
hmacSignatureSecret: ALTCHA_SECRET,
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
});
}
/** The posted altcha field: base64 JSON of { challenge, solution }. */
async function solved(field, now = Date.now()) {
let payload;
try {
payload = JSON.parse(Buffer.from(field || '', 'base64').toString('utf8'));
} catch {
return false;
}
const { challenge, solution } = payload || {};
if (!challenge?.signature || spent.has(challenge.signature)) return false;
let result;
try {
result = await verifySolution({
challenge,
solution,
deriveKey,
hmacSignatureSecret: ALTCHA_SECRET,
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
});
} catch {
return false;
}
if (!result.verified) return false;
// Signed with the challenge, so it is the time the server issued it.
const issued = Number(challenge.parameters.data?.issued);
if (!(now - issued >= MIN_FILL_MS)) return false;
spent.set(challenge.signature, challenge.parameters.expiresAt * 1000);
return true;
}
const hits = new Map();
const globalHits = [];
function limited(ip, now = Date.now()) {
@@ -85,7 +154,7 @@ function readBody(req) {
}
function reply(req, res, lang, outcome) {
const status = { sent: 200, invalid: 400, limited: 429, failed: 502 }[outcome];
const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, failed: 502 }[outcome];
if ((req.headers.accept || '').includes('application/json')) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ ok: outcome === 'sent', outcome }));
@@ -99,6 +168,11 @@ function reply(req, res, lang, outcome) {
const server = createServer(async (req, res) => {
const path = (req.url || '').split('?')[0];
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
if (path === '/contact/challenge' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(await issueChallenge()));
return;
}
if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; }
// A cross-site post is not a visitor using the form.
@@ -121,6 +195,10 @@ const server = createServer(async (req, res) => {
const data = validate(form);
if (!data) { reply(req, res, lang, 'invalid'); return; }
// No solved challenge: a script, or a browser without scripting. Checked before the rate
// limits, so unsolved posts cannot use up the budget meant for visitors.
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); return; }
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown';
if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; }
+70
View File
@@ -6,12 +6,82 @@
"": {
"name": "ciphercell-contact",
"dependencies": {
"altcha-lib": "^2.6.0",
"nodemailer": "^7.0.13"
},
"engines": {
"node": ">=20"
}
},
"node_modules/altcha-lib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
"license": "MIT",
"bin": {
"altcha-lib": "bin/cli.mjs"
},
"peerDependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.0 || ^9.0.0",
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@sveltejs/kit": "^2.0.0",
"cookie-parser": "^1.0.0",
"cors": "^2.0.0",
"express": "^4.0.0 || ^5.0.0",
"fastify": "^5.0.0",
"h3": "^2.0.0",
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"@fastify/cookie": {
"optional": true
},
"@fastify/cors": {
"optional": true
},
"@fastify/formbody": {
"optional": true
},
"@nestjs/common": {
"optional": true
},
"@nestjs/core": {
"optional": true
},
"@nestjs/platform-express": {
"optional": true
},
"@nestjs/platform-fastify": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"cookie-parser": {
"optional": true
},
"cors": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
},
"h3": {
"optional": true
},
"hono": {
"optional": true
}
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
+1
View File
@@ -10,6 +10,7 @@
"node": ">=20"
},
"dependencies": {
"altcha-lib": "^2.6.0",
"nodemailer": "^7.0.13"
}
}