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_USER=resend
SMTP_PASSWORD= SMTP_PASSWORD=
MAIL_FROM=send@read.atanasov.fi 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 ## 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 | | Device | Email pattern |
|---|---| |---|---|
| Amazon Kindle | `*@kindle.com` | | Amazon Kindle | `*@kindle.com` (or `*.kindle.com` subdomains) |
| PocketBook | `*@pbsync.com` | | 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 ## Prerequisites
@ -63,6 +64,7 @@ npm run start
| `SMTP_USER` | yes | — | For Resend, literally the string `resend` | | `SMTP_USER` | yes | — | For Resend, literally the string `resend` |
| `SMTP_PASSWORD` | yes | — | For Resend, your API key (`re_…`) | | `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. | | `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 | | `PORT` | no | `3000` | Listen port |
## Resend setup (quick) ## Resend setup (quick)

View file

@ -260,7 +260,12 @@
<h3>notes:</h3> <h3>notes:</h3>
<ul> <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>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>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> <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 SMTP_PASSWORD = process.env.SMTP_PASSWORD || "";
const MAIL_FROM = process.env.MAIL_FROM || ""; 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) { if (!SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM) {
console.warn( console.warn(
"Warning: SMTP_USER, SMTP_PASSWORD, or MAIL_FROM not set. Outgoing mail will fail until they are configured.", "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@]+$/; 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( app.post(
"/send-article", "/send-article",
sendLimiter, sendLimiter,
@ -198,8 +222,10 @@ app.post(
res.status(400).json({ error: "No e-reader email provided" }); res.status(400).json({ error: "No e-reader email provided" });
return; return;
} }
if (!emailShape.test(readerEmail)) { if (!isAllowedReaderEmail(readerEmail)) {
res.status(400).json({ error: "Invalid e-reader email address" }); res.status(400).json({
error: `E-reader email must end in ${readerDomainsHumanReadable()}.`,
});
return; return;
} }
@ -267,9 +293,11 @@ app.post(
res.status(400).json({ error: "No e-reader email provided" }); res.status(400).json({ error: "No e-reader email provided" });
return; return;
} }
if (!emailShape.test(readerEmail)) { if (!isAllowedReaderEmail(readerEmail)) {
cleanup(); cleanup();
res.status(400).json({ error: "Invalid e-reader email address" }); res.status(400).json({
error: `E-reader email must end in ${readerDomainsHumanReadable()}.`,
});
return; return;
} }