Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions lib/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ function getRecipient(fields, options) {
const [toStr, toJson] = toJsonOrString(options.to);
const to = toStr || toJson[fields[options.tokenField]];
if (!to) {
console.error('No email address found in the form', { toStr, toJson }, `Add a field named ${options.tokenField} with a value that matches one of the tokens in the TO env var`);
throw new Error(`No email address found for token: ${fields[options.tokenField]}`);
// Do not log the TO env var: it maps every token to its recipients
console.error('No email address found for token', fields[options.tokenField], `Add a field named ${options.tokenField} with a value that matches one of the tokens in the TO env var`);
const err = new Error(`No email address found for token: ${fields[options.tokenField]}`);
err.statusCode = 400;
throw err;
}
return to
}
Expand Down Expand Up @@ -74,7 +77,8 @@ function getHook(fields, options) {
return getHookParsed(fields, options.hook, options);
}
} catch(e) {
console.error('hook option is not a JSON object', e.message, options.hook);
// Do not log options.hook: it contains the hook credentials
console.error('hook option is not a JSON object', e.message);
throw new Error(`hook option is not a JSON object. ${e.message}`);
}
}
Expand All @@ -90,7 +94,7 @@ function handleResponse(res, fields, options) {
}

function handleError(res, err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.writeHead(err.statusCode || 500, { 'Content-Type': 'text/plain' });
res.end(`Error: ${err.message}`);
}

Expand All @@ -102,13 +106,18 @@ export function processForm(req, res, options) {
fields[field] = value;
});

form.on('end', async () => {
form.on('end', () => {
// Check the token before answering: an unknown token gets a 400 instead of crashing the process
try {
handleForm(fields, req.headers.referer, options, JSON.stringify(req.headers));
handleResponse(res, fields, options);
getRecipient(fields, options);
} catch (err) {
handleError(res, err);
return;
}
// handleForm is async: an unhandled rejection would kill the process and every form with it
handleForm(fields, req.headers.referer, options, JSON.stringify(req.headers))
.catch(err => console.error('Form submission failed', err.message));
handleResponse(res, fields, options);
});

try {
Expand Down
59 changes: 59 additions & 0 deletions tests/process.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from 'assert';
import http from 'http';
import { processForm } from '../lib/post.js';

describe('processForm', function () {
let server;
let port;
const options = {
to: JSON.stringify({ knownToken: 'alex@test.com' }),
tokenField: 'token',
thanksField: 'thanks',
honeyField: 'email2',
redirect: 'true',
// Unreachable SMTP: the mail fails after the answer, the process must survive it
mail: { host: '127.0.0.1', port: 9 },
};

before(function (done) {
server = http.createServer((req, res) => processForm(req, res, options));
server.listen(0, () => {
port = server.address().port;
done();
});
});

after(function (done) {
server.close(done);
});

function post(body) {
return fetch(`http://localhost:${port}/`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body),
redirect: 'manual',
});
}

it('should answer 400 to an unknown token without crashing', async function () {
const res = await post({ token: 'unknownToken', email: 'a@test.com', thanks: 'https://example.com/merci' });
assert.strictEqual(res.status, 400);
const text = await res.text();
assert.ok(!text.includes('alex@test.com'), 'The recipients must not leak in the answer');
// The server is still up
const again = await post({ token: 'unknownToken' });
assert.strictEqual(again.status, 400);
});

it('should redirect a known token and survive a mail failure', async function () {
this.timeout(15000);
const res = await post({ token: 'knownToken', email: 'a@test.com', thanks: 'https://example.com/merci' });
assert.strictEqual(res.status, 302);
assert.strictEqual(res.headers.get('location'), 'https://example.com/merci');
// Let the mail fail in the background, then check the server still answers
await new Promise(resolve => setTimeout(resolve, 3000));
const again = await post({ token: 'unknownToken' });
assert.strictEqual(again.status, 400);
});
});
Loading