This commit is contained in:
Felix Klement committed 2026-10-05 21:04:51 +02:00
1 parent efecc500c4
commit cd45c5d98f
24 files changed
+689 -281

No files matched your search

+166 -37
View File
@@ -1,20 +1,29 @@
/**
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static.
* The handler for the site's two forms; nginx proxies their paths here and everything else stays
* static. Both send by SMTP.
*
* The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the
* 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, POST /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/confirm carrying a signed token (address, language, time of consent, expiry). That
* page posts the token back to /notify/verify, which checks the signature and sends the confirmed
* registration to CONTACT_TO. The office mailbox is the list. The confirmation is a button, not
* the link itself, because mail scanners open every link in a message.
*
* Configuration comes from the environment only, see README.md.
*/
import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
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);
@@ -22,6 +31,8 @@ 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.');
@@ -32,6 +43,9 @@ if (!env.SMTP_HOST || !FROM) {
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,
@@ -153,55 +167,68 @@ function readBody(req) {
});
}
function reply(req, res, lang, outcome) {
/* 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;
}
const page = outcome === 'sent' ? 'sent' : 'error';
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}/contact/${page}`, 'Cache-Control': 'no-store' });
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();
}
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; }
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; }
// 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; }
if (limited(clientIp(req))) { reply(req, res, lang, 'limited'); return; }
const name = `${data.first_name} ${data.last_name}`;
try {
@@ -226,6 +253,108 @@ const server = createServer(async (req, res) => {
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}${lang === 'de' ? '/de' : ''}/notify/confirm?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's token comes back; the confirmed registration goes to the office. */
async function verify(req, res, form) {
const t = unsign(form.get('t'));
const lang = t?.lang === 'de' ? 'de' : form.get('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, '/notify/verify': verify };
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;
}
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}`));
+107
View File
@@ -0,0 +1,107 @@
/**
* The alpha sign-up confirmation email, in the site's voice and look: warm paper, warm black,
* one neo-grotesque, hairlines, one square button. Email clients ignore most CSS, so the layout
* is tables with inline styles, the type falls back from Inter Tight to Helvetica and Arial, and
* the logo travels as an inline attachment (cid) rather than a remote image.
*
* Colours are the tokens from src/styles/tokens.css, written out: email has no custom properties.
* The hairline is --line (ink at 14 %) flattened onto paper.
*/
import { fileURLToPath } from 'node:url';
const C = {
paper: '#E9E5DF',
ink: '#1A1A18',
muted: '#57564F',
line: '#CCC9C3',
};
const FONT = "'Inter Tight', Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif";
const LOGO_CID = 'logo@ciphercell.de';
const LOGO = { path: fileURLToPath(new URL('./logo.png', import.meta.url)), width: 140, height: 32 };
const COPY = {
en: {
subject: 'Confirm your CipherVerifier alpha registration',
preheader: 'One click confirms your registration for the CipherVerifier alpha.',
title: 'Confirm your registration.',
body: (email) => `You registered ${email} for the CipherVerifier alpha. Confirm the address, and we will email you once, when the alpha is released. Nothing else.`,
button: 'Confirm registration',
expiry: 'The link is valid for 7 days. If you did not register, ignore this email. Your address is not stored.',
fallback: 'If the button does not work, open this address in your browser:',
},
de: {
subject: 'Bitte bestätigen Sie Ihre Registrierung für die CipherVerifier-Alpha',
preheader: 'Ein Klick bestätigt Ihre Registrierung für die Alpha von CipherVerifier.',
title: 'Registrierung bestätigen.',
body: (email) => `Sie haben ${email} für die Alpha von CipherVerifier registriert. Bestätigen Sie die Adresse, und wir schreiben Ihnen einmal, sobald die Alpha erscheint. Sonst nichts.`,
button: 'Registrierung bestätigen',
expiry: 'Der Link ist 7 Tage gültig. Wenn Sie sich nicht registriert haben, ignorieren Sie diese E-Mail. Ihre Adresse wird nicht gespeichert.',
fallback: 'Falls der Button nicht funktioniert, öffnen Sie diese Adresse im Browser:',
},
};
const ADDRESS = ['CipherCell', 'Innstr. 43, 94032 Passau'];
const SITE_LABEL = 'www.ciphercell.de';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
/** { subject, text, html, attachments } for nodemailer. */
export function confirmMail({ lang, email, link, site }) {
const t = COPY[lang] ?? COPY.en;
const text = [
t.title,
'',
t.body(email),
'',
`${t.button}:`,
link,
'',
t.expiry,
'',
'-- ',
...ADDRESS,
SITE_LABEL,
].join('\n');
const p = (content, { size = 16, color = C.ink, lineHeight = 1.55, extra = '' } = {}) =>
`<p style="margin:0;font-family:${FONT};font-size:${size}px;line-height:${lineHeight};color:${color};${extra}">${content}</p>`;
const row = (content, padding) => `<tr><td style="padding:${padding};">${content}</td></tr>`;
const rule = `<tr><td style="padding:0;border-top:1px solid ${C.line};font-size:0;line-height:0;">&nbsp;</td></tr>`;
const html = `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>${esc(t.subject)}</title>
</head>
<body style="margin:0;padding:0;background:${C.paper};-webkit-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:${C.paper};">${esc(t.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${C.paper};">
<tr><td align="left" style="padding:40px 24px 48px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
${row(`<a href="${esc(site)}" style="text-decoration:none;"><img src="cid:${LOGO_CID}" width="${LOGO.width}" height="${LOGO.height}" alt="CipherCell" style="display:block;border:0;width:${LOGO.width}px;height:${LOGO.height}px;font-family:${FONT};font-size:18px;font-weight:600;color:${C.ink};"></a>`, '0 0 20px')}
${rule}
${row(p(esc(t.title), { size: 34, lineHeight: 1.08, extra: 'font-weight:500;letter-spacing:-0.02em;' }), '48px 0 20px')}
${row(p(esc(t.body(email))), '0 0 32px')}
${row(`<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td bgcolor="${C.ink}" style="background:${C.ink};">
<a href="${esc(link)}" style="display:inline-block;padding:15px 26px;font-family:${FONT};font-size:16px;line-height:1;font-weight:500;color:${C.paper};text-decoration:none;">${esc(t.button)}</a>
</td></tr></table>`, '0 0 32px')}
${row(p(esc(t.expiry), { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 16px')}
${row(p(`${esc(t.fallback)}<br><a href="${esc(link)}" style="color:${C.muted};text-decoration:underline;word-break:break-all;">${esc(link)}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 48px')}
${rule}
${row(p(`${ADDRESS.map(esc).join('<br>')}<br><a href="${esc(site)}" style="color:${C.muted};text-decoration:underline;">${SITE_LABEL}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '20px 0 0')}
</table>
</td></tr>
</table>
</body>
</html>`;
return {
subject: t.subject,
text,
html,
attachments: [{ filename: 'ciphercell.png', path: LOGO.path, cid: LOGO_CID }],
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB