diff options
| author | Adam Soltys <[email protected]> | 2026-10-06 21:12:19 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-06 21:12:19 -0700 |
| commit | fb7c0086bbfcf17fbaba1ea83a1e3faf2849315b (patch) | |
| tree | 15645db10aee673dce02c5ed28ecb8c6cd131e49 | |
| parent | f53e70ed98eb723479b4276c5b621b599fe570a0 (diff) | |
| download | coinos-server-fb7c0086bbfcf17fbaba1ea83a1e3faf2849315b.tar.gz coinos-server-fb7c0086bbfcf17fbaba1ea83a1e3faf2849315b.zip | |
email: escape support form HTML and enforce recaptcha v3 score
Escape the message body, stop accepting a client-supplied subject, strip
control characters from header values, require the expected action and a
minimum score from siteverify, and drop the adminpass captcha bypass.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | routes/email.ts | 93 |
1 files changed, 56 insertions, 37 deletions
diff --git a/routes/email.ts b/routes/email.ts index eb256f1a..749fba5e 100644 --- a/routes/email.ts +++ b/routes/email.ts @@ -1,59 +1,78 @@ -import { bail } from "$lib/utils"; +import { bail, fail } from "$lib/utils"; import got from "got"; import config from "$config"; import { l, warn } from "$lib/logging"; import { SESClient } from "@aws-sdk/client-ses"; import { SendEmailCommand } from "@aws-sdk/client-ses"; +// reCAPTCHA v3 never challenges: siteverify returns success:true for any +// well-formed token and reports how human the request looked in `score`. +// Checking `success` alone let a headless scanner submit freely. +const MIN_SCORE = 0.5; + +const escapeHtml = (s: string) => + s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +// Strip CR/LF and other control characters from single-line header values. +const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f]+/g, " ").trim(); + export default { - async send({ body }, res) { + async send({ body, headers }, res) { try { const { email, message, username, token: response } = body; + if (typeof message !== "string" || !message.trim()) fail("Message required"); + if (typeof email !== "string" || !email.trim()) fail("Email required"); + if (username != null && typeof username !== "string") fail("Invalid username"); + if (typeof response !== "string" || !response) fail("failed captcha"); const Charset = "UTF-8"; const { recaptcha: secret } = config; - const { success } = await got + const remoteip = headers?.["cf-connecting-ip"]; + const { success, score, action } = await got .post("https://www.google.com/recaptcha/api/siteverify", { - form: { - secret, - response, - }, + form: { secret, response, ...(remoteip && { remoteip }) }, + timeout: { request: 6000 }, }) - .json(); + .json<any>(); - if (success || (!!config.adminpass && response === config.adminpass)) { - body.token = undefined; + if (!success || action !== "submit" || !(score >= MIN_SCORE)) { + warn("support request rejected", email, "score", score, "action", action); + fail("failed captcha"); + } - warn("support request from", email); - const client = new SESClient({ region: "us-east-2" }); - await client.send( - new SendEmailCommand({ - Destination: { - CcAddresses: [], - ToAddresses: [config.support], + l("support request from", email, "score", score); + const name = username ? oneLine(username).slice(0, 100) : ""; + const client = new SESClient({ region: "us-east-2" }); + await client.send( + new SendEmailCommand({ + Destination: { + CcAddresses: [], + ToAddresses: [config.support], + }, + Message: { + Body: { + Html: { Charset, Data: escapeHtml(message).replace(/\n/g, "<br>") }, + Text: { Charset, Data: message }, }, - Message: { - Body: { - Html: { Charset, Data: message.replace(/\n/g, "<br>") }, - Text: { Charset, Data: message }, - }, - Subject: { - Charset, - Data: - body.subject || - `Support Request${username ? ` From ${username}` : ""}`, - }, + Subject: { + Charset, + Data: `Support Request${name ? ` From ${name}` : ""}`, }, - ReplyToAddresses: [email], - Source: config.support, - }), - ); + }, + ReplyToAddresses: [oneLine(email)], + Source: config.support, + }), + ); - res.send({ ok: true }); - } else { - bail(res, "failed captcha"); - } - } catch (e) {} + res.send({ ok: true }); + } catch (e) { + bail(res, e.message); + } }, }; |
