/** * The handler for the site's two forms; nginx proxies their paths here and everything else stays * static. Both send by SMTP. * * Contact (POST /contact). 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. * * Alpha sign-up (POST /notify, GET /notify/verify). Double opt-in without a database: the form * posts email, consent=yes, list, lang, website and altcha; the handler emails the address a link * to /notify/verify carrying a signed token (address, language, time of consent, expiry). Opening * the link confirms: the handler checks the signature, sends the confirmed registration to * CONTACT_TO and redirects to /notify/confirmed. The office mailbox is the list. * * Configuration comes from the environment only, see README.md. */ import { createServer } from 'node:http'; import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import nodemailer from 'nodemailer'; import { createChallenge, randomInt, verifySolution } from 'altcha-lib'; import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; import { confirmMail } from './mail/confirm.mjs'; 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); // Confirmation links point here, never at the Host header a request happened to carry. const SITE = (env.SITE_URL || 'https://www.ciphercell.de').replace(/\/$/, ''); 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'); /* Signs alpha confirmation links. Without it set, links sent before a restart stop working. */ const NOTIFY_SECRET = env.NOTIFY_SECRET || randomBytes(32).toString('hex'); if (!env.NOTIFY_SECRET) console.warn('contact: NOTIFY_SECRET not set; confirmation links will not survive a restart.'); 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); }); } /* Alpha sign-up. */ const CONFIRM_TTL_MS = 7 * 24 * 60 * 60 * 1000; const NOTIFY_LIST = 'cipherverifier-alpha'; // The wording the visitor agreed to, recorded with the registration. Same text as // soon.notify.consent in src/i18n/ui.ts; change both together. const CONSENT = { en: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.', de: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.', }; // Confirmed tokens, until they expire, so a second click does not file the address twice. const confirmed = new Map(); setInterval(() => { const now = Date.now(); for (const [mac, expires] of confirmed) if (expires < now) confirmed.delete(mac); }, 60 * 60 * 1000).unref(); const mac = (body) => createHmac('sha256', NOTIFY_SECRET).update(body).digest(); function sign(payload) { const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); return `${body}.${mac(body).toString('base64url')}`; } /** The payload of a token this handler signed, or null. */ function unsign(token) { const [body, sig] = String(token || '').split('.'); if (!body || !sig) return null; const expected = mac(body); const given = Buffer.from(sig, 'base64url'); if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null; try { return { sig, ...JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) }; } catch { return null; } } /* Answers. A browser with scripting asks for JSON; a plain post is redirected to a page. */ const PAGES = { contact: { sent: '/contact/sent', error: '/contact/error' }, notify: { sent: '/cipherverifier', error: '/notify/error' }, }; function reply(req, res, lang, outcome, pages = PAGES.contact) { 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; } redirect(res, lang, outcome === 'sent' ? pages.sent : pages.error); } function redirect(res, lang, path) { res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}${path}`, 'Cache-Control': 'no-store' }); res.end(); } async function contact(req, res, form, lang) { 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; } if (limited(clientIp(req))) { 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'); } } /** Step one of the sign-up: a confirmation link to the address given. Nothing is kept. */ async function notify(req, res, form, lang) { const email = oneLine(form.get('email') || ''); const valid = email.length <= MAX.email && EMAIL.test(email) && form.get('consent') === 'yes' && form.get('list') === NOTIFY_LIST; if (!valid) { reply(req, res, lang, 'invalid', PAGES.notify); return; } // The proof of work matters most here: this path sends mail to an address a stranger typed. if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified', PAGES.notify); return; } if (limited(clientIp(req))) { reply(req, res, lang, 'limited', PAGES.notify); return; } const now = Date.now(); const token = sign({ email, lang, consented: now, expires: now + CONFIRM_TTL_MS }); const link = `${SITE}/notify/verify?t=${token}`; try { await transport.sendMail({ from: { name: 'CipherCell', address: FROM }, to: email, ...confirmMail({ lang, email, link, site: SITE }), }); reply(req, res, lang, 'sent', PAGES.notify); } catch (err) { console.error('notify: sending the confirmation failed:', err.message); reply(req, res, lang, 'failed', PAGES.notify); } } /** Step two: the link is opened; the confirmed registration goes to the office. */ async function verify(req, res, query) { const t = unsign(query.get('t')); const lang = t?.lang === 'de' ? 'de' : 'en'; const now = Date.now(); if (!t || typeof t.email !== 'string' || !(t.expires > now) || !(t.consented > 0)) { redirect(res, lang, '/notify/error'); return; } if (confirmed.has(t.sig)) { redirect(res, lang, '/notify/confirmed'); return; } try { await transport.sendMail({ from: { name: 'CipherCell website', address: FROM }, to: TO, replyTo: t.email, subject: `Alpha registration: ${t.email}`, text: [ `Email: ${t.email}`, `List: ${NOTIFY_LIST}`, `Language: ${lang}`, `Consent given: ${new Date(t.consented).toISOString()}`, `Confirmed: ${new Date(now).toISOString()}`, '', 'Consent wording:', CONSENT[lang], '', '-- ', 'Confirmed by double opt-in on ciphercell.de. Per the privacy policy, delete this address', 'once the alpha notification has been sent, or when the person withdraws consent.', ].join('\n'), }); confirmed.set(t.sig, t.expires); redirect(res, lang, '/notify/confirmed'); } catch (err) { console.error('notify: filing the registration failed:', err.message); redirect(res, lang, '/notify/error'); } } // nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs. const clientIp = (req) => String(req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown'); const ROUTES = { '/contact': contact, '/notify': notify }; const server = createServer(async (req, res) => { const [path, search = ''] = (req.url || '').split('?'); 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 (path === '/notify/verify' && req.method === 'GET') { await verify(req, res, new URLSearchParams(search)); return; } const route = ROUTES[path]; if (req.method !== 'POST' || !route) { 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', path === '/contact' ? PAGES.contact : PAGES.notify); return; } await route(req, res, form, lang); }); server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));