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:
parent
39292bfee9
commit
99882bb80a
4 changed files with 50 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
36
src/app.ts
36
src/app.ts
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue