232 lines
9.0 KiB
JavaScript
232 lines
9.0 KiB
JavaScript
/**
|
|
* 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, 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);
|
|
const HOST = env.CONTACT_HOST || '127.0.0.1';
|
|
const TO = env.CONTACT_TO || 'office@ciphercell.de';
|
|
const FROM = env.CONTACT_FROM || env.SMTP_USER;
|
|
const SMTP_PORT = Number(env.SMTP_PORT || 587);
|
|
|
|
if (!env.SMTP_HOST || !FROM) {
|
|
console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.');
|
|
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,
|
|
// 465 speaks TLS from the first byte; 587 and 25 upgrade with STARTTLS, which we then require.
|
|
// SMTP_REQUIRE_TLS=false is for a local mail catcher only.
|
|
secure: env.SMTP_SECURE ? env.SMTP_SECURE === 'true' : SMTP_PORT === 465,
|
|
requireTLS: env.SMTP_REQUIRE_TLS !== 'false' && SMTP_PORT !== 465,
|
|
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
|
|
});
|
|
|
|
/* Limits. The body cap is generous for four short fields; the rate caps stop a flood, not a person. */
|
|
const MAX_BODY = 32 * 1024;
|
|
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()) {
|
|
while (globalHits.length && now - globalHits[0] > GLOBAL.windowMs) globalHits.shift();
|
|
const mine = (hits.get(ip) || []).filter((t) => now - t < PER_IP.windowMs);
|
|
if (mine.length >= PER_IP.count || globalHits.length >= GLOBAL.count) return true;
|
|
mine.push(now);
|
|
hits.set(ip, mine);
|
|
globalHits.push(now);
|
|
return false;
|
|
}
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, list] of hits) if (list.every((t) => now - t >= PER_IP.windowMs)) hits.delete(ip);
|
|
}, PER_IP.windowMs).unref();
|
|
|
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const oneLine = (s) => s.replace(/[\r\n\t]+/g, ' ').trim();
|
|
|
|
function validate(form) {
|
|
const data = {
|
|
first_name: oneLine(form.get('first_name') || ''),
|
|
last_name: oneLine(form.get('last_name') || ''),
|
|
email: oneLine(form.get('email') || ''),
|
|
message: (form.get('message') || '').replace(/\r\n/g, '\n').trim(),
|
|
};
|
|
for (const [key, max] of Object.entries(MAX)) {
|
|
if (!data[key] || data[key].length > max) return null;
|
|
}
|
|
return EMAIL.test(data.email) ? data : null;
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let size = 0;
|
|
const chunks = [];
|
|
req.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > MAX_BODY) { reject(new Error('too large')); req.destroy(); return; }
|
|
chunks.push(chunk);
|
|
});
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function reply(req, res, lang, 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 }));
|
|
return;
|
|
}
|
|
const page = outcome === 'sent' ? 'sent' : 'error';
|
|
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}/contact/${page}`, 'Cache-Control': 'no-store' });
|
|
res.end();
|
|
}
|
|
|
|
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.
|
|
const origin = req.headers.origin;
|
|
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
|
if (origin && host && new URL(origin).host !== host) { res.writeHead(403).end(); return; }
|
|
|
|
let form;
|
|
try {
|
|
form = new URLSearchParams(await readBody(req));
|
|
} catch {
|
|
res.writeHead(413).end();
|
|
return;
|
|
}
|
|
const lang = form.get('lang') === 'de' ? 'de' : 'en';
|
|
|
|
// Filled honeypot: answer as if sent, so the bot learns nothing.
|
|
if (form.get('website')) { reply(req, res, lang, 'sent'); return; }
|
|
|
|
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; }
|
|
|
|
const name = `${data.first_name} ${data.last_name}`;
|
|
try {
|
|
await transport.sendMail({
|
|
from: { name: 'CipherCell website', address: FROM },
|
|
to: TO,
|
|
replyTo: { name, address: data.email },
|
|
subject: `Website enquiry from ${name}`,
|
|
text: [
|
|
`Name: ${name}`,
|
|
`Email: ${data.email}`,
|
|
`Language: ${lang}`,
|
|
'',
|
|
data.message,
|
|
'',
|
|
'-- ',
|
|
'Sent from the contact form on ciphercell.de. Reply to this email to answer.',
|
|
].join('\n'),
|
|
});
|
|
reply(req, res, lang, 'sent');
|
|
} catch (err) {
|
|
console.error('contact: sending failed:', err.message);
|
|
reply(req, res, lang, 'failed');
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));
|