Restrict readerEmail to allowlisted domains

Prevents the form being abused as an open email relay to arbitrary
addresses (which is the realistic abuse vector — disk fill, server
compromise, and device exploitation are all bounded already).

Default allowlist: kindle.com, pbsync.com. Suffix matching so
subdomains (e.g. free.kindle.com) are also accepted.

Configurable via ALLOWED_READER_DOMAINS env var so the list can be
extended without code changes.
This commit is contained in:
Stefan 2026-05-21 23:54:55 +03:00
parent 39292bfee9
commit 99882bb80a
4 changed files with 50 additions and 9 deletions

View file

@ -8,3 +8,9 @@ SMTP_PORT=587
SMTP_USER=resend
SMTP_PASSWORD=
MAIL_FROM=send@read.atanasov.fi
# Comma-separated list of allowed destination email domains. Anything that
# doesn't match (suffix-wise, so subdomains like free.kindle.com count) is
# rejected with a 400. This prevents the app from being abused as an open
# email relay to arbitrary addresses.
ALLOWED_READER_DOMAINS=kindle.com,pbsync.com

View file

@ -20,15 +20,16 @@ Two ways to send something to your e-reader:
## Supported devices
Any device that accepts files via email works — the app doesn't restrict by domain.
The destination email domain is checked against an allowlist (default: `kindle.com`, `pbsync.com`) to prevent the app being used as an open email relay. Subdomains count, so `free.kindle.com` works too.
| Device | Email pattern |
|---|---|
| Amazon Kindle | `*@kindle.com` |
| Amazon Kindle | `*@kindle.com` (or `*.kindle.com` subdomains) |
| PocketBook | `*@pbsync.com` |
| Anything else | whatever address your device gives you |
You'll need to add the **sender** address (the Gmail account this app sends from) to your device's approved-senders list — that's a one-time per-device setup.
You can extend the list via the `ALLOWED_READER_DOMAINS` env var (see below).
You'll also need to add the **sender** address to your device's approved-senders list — one-time per device.
## Prerequisites
@ -63,6 +64,7 @@ npm run start
| `SMTP_USER` | yes | — | For Resend, literally the string `resend` |
| `SMTP_PASSWORD` | yes | — | For Resend, your API key (`re_…`) |
| `MAIL_FROM` | yes | — | Sender address, e.g. `read@atanasov.fi`. Must be from a verified domain on your provider. |
| `ALLOWED_READER_DOMAINS` | no | `kindle.com,pbsync.com` | Comma-separated allowlist of destination email domains. Suffix-matching, so subdomains are allowed. |
| `PORT` | no | `3000` | Listen port |
## Resend setup (quick)

View file

@ -260,7 +260,12 @@
<h3>notes:</h3>
<ul>
<li>any email works — domain isn't checked, use whatever your device gave you</li>
<li>
only <code>@kindle.com</code> and <code>@pbsync.com</code>
destination addresses are accepted (subdomains too, e.g.
<code>free.kindle.com</code>). This is to prevent the form being
abused as an open email relay.
</li>
<li>article should be publicly accessible (no paywalls / login required)</li>
<li>uploaded files are passed through as-is — your device decides what formats it accepts (EPUB, PDF, MOBI, etc.)</li>
<li>max file size: 25 MB</li>

View file

@ -20,6 +20,13 @@ const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASSWORD = process.env.SMTP_PASSWORD || "";
const MAIL_FROM = process.env.MAIL_FROM || "";
const ALLOWED_READER_DOMAINS = (
process.env.ALLOWED_READER_DOMAINS || "kindle.com,pbsync.com"
)
.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
if (!SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM) {
console.warn(
"Warning: SMTP_USER, SMTP_PASSWORD, or MAIL_FROM not set. Outgoing mail will fail until they are configured.",
@ -184,6 +191,23 @@ async function sendToReader(
const emailShape = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isAllowedReaderEmail(email: string): boolean {
if (!emailShape.test(email)) return false;
const at = email.lastIndexOf("@");
if (at < 0) return false;
const domain = email.slice(at + 1).toLowerCase();
return ALLOWED_READER_DOMAINS.some(
(d) => domain === d || domain.endsWith("." + d),
);
}
function readerDomainsHumanReadable(): string {
if (ALLOWED_READER_DOMAINS.length === 0) return "(none)";
if (ALLOWED_READER_DOMAINS.length === 1) return `@${ALLOWED_READER_DOMAINS[0]}`;
const prefixed = ALLOWED_READER_DOMAINS.map((d) => `@${d}`);
return prefixed.slice(0, -1).join(", ") + " or " + prefixed[prefixed.length - 1];
}
app.post(
"/send-article",
sendLimiter,
@ -198,8 +222,10 @@ app.post(
res.status(400).json({ error: "No e-reader email provided" });
return;
}
if (!emailShape.test(readerEmail)) {
res.status(400).json({ error: "Invalid e-reader email address" });
if (!isAllowedReaderEmail(readerEmail)) {
res.status(400).json({
error: `E-reader email must end in ${readerDomainsHumanReadable()}.`,
});
return;
}
@ -267,9 +293,11 @@ app.post(
res.status(400).json({ error: "No e-reader email provided" });
return;
}
if (!emailShape.test(readerEmail)) {
if (!isAllowedReaderEmail(readerEmail)) {
cleanup();
res.status(400).json({ error: "Invalid e-reader email address" });
res.status(400).json({
error: `E-reader email must end in ${readerDomainsHumanReadable()}.`,
});
return;
}