DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The URL our webhook fetches does not come from the webhook, and we pin its host anyway

Munchable's domain has a catch-all MX record pointing at Resend, so mail to any address at munchable.app arrives as a webhook. Support mail becomes a ticket, everything else gets forwarded. Somewhere in the middle of that pipeline the server does something it does nowhere else in the codebase: it fetches a URL that it did not write.

const { data: email } = await getResend().emails.receiving.get(emailId);
const downloadUrl = email?.raw?.download_url;
Enter fullscreen mode Exit fullscreen mode

That is the raw MIME of the message, which has to be downloaded before it can be parsed for attachments and inline images. The URL is a string from a JSON response, and the next thing the code wants to do is pass it to fetch.

The argument for not bothering

It is a good argument, which is the problem.

The URL does not come from the webhook body. The webhook body is signature-verified anyway. The URL comes from a separate, authenticated, outbound HTTPS call to the Resend API, made by us, with our key. An attacker who can control that response can already do considerably worse things than make our server issue a GET.

Every clause of that is true, and the conclusion is still wrong, because it is reasoning about who the data came from rather than about what the code does with it. The code takes a string from the network and turns it into a request from inside our infrastructure. That is the definition of the primitive. Whether today's upstream is trustworthy is a fact about today.

What pinning costs

export const ALLOWED_DOWNLOAD_HOSTS: RegExp[] = [
  /(^|\.)resend\.app$/,
  /(^|\.)resend\.com$/,
  /(^|\.)amazonaws\.com$/,
  /(^|\.)cloudfront\.net$/,
];

/** True when `url` is an HTTPS URL on a host Resend serves signed URLs from. */
export function isAllowedDownloadHost(url: string): boolean {
  try {
    const parsed = new URL(url);
    if (parsed.protocol !== 'https:') return false;
    return ALLOWED_DOWNLOAD_HOSTS.some((pattern) => pattern.test(parsed.hostname));
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Eleven lines. Three details in them are load bearing.

(^|\.) and $ together. A naive /resend\.com/ matches resend.com.evil.net and notresend.com alike. The leading alternation demands that the match begin at the start of the hostname or immediately after a dot, and the $ demands it reach the end. mail.resend.com passes, evil-resend.com does not, resend.com.attacker.example does not.

The protocol check is separate and comes first. http:// to an internal address is the classic shape, and so is file://. Checking the hostname of a URL whose scheme you never looked at is checking the lock on one of two doors.

The try wraps the parse. An unparseable URL is not an error condition here, it is a refusal. Returning false from the catch means malformed input is denied by the same path as hostile input.

Being honest about what this does and does not buy

amazonaws.com and cloudfront.net are broad. Resend fronts its signed URLs with S3 and CloudFront, so those have to be in the list, and in the list they mean "any S3 bucket" and "any CloudFront distribution". Anyone can create either.

So this is not an allowlist of four addresses. It is the removal of a category. What SSRF is actually worth to an attacker is the stuff you cannot reach from outside: 169.254.169.254 and its instance credentials, 127.0.0.1 and whatever admin thing is bound to it, 10.0.0.0/8, a Redis with no password, a metadata endpoint on a port nobody meant to expose. None of those is on a public CDN. The allowlist turns "fetch anything, including the inside of our network" into "fetch a public object over TLS", and the second one is close to worthless.

Naming that limitation beats pretending the list is tighter than it is. The next person to read the file needs to know which threat it answers.

Refusing is a return, not a throw

if (!isAllowedDownloadHost(downloadUrl)) {
  console.error(
    `[resend-webhook] Raw email download URL host not allowed: ${safeHostForLogging(downloadUrl)}`,
  );
  return;
}
Enter fullscreen mode Exit fullscreen mode

The webhook is delivered by Svix, which retries on a 5xx. A thrown error here would mean the same refusal re-evaluated on a schedule for hours. A disallowed host is not a transient failure: it will be disallowed next time too. So it logs loudly and returns, and the delivery is acknowledged.

That distinction runs through the whole handler. Transient failures throw and get retried; decisions never do.

The log line that does not log the URL

/** Hostname of `url` (or `'<unparseable>'`) for logging without leaking the
 *  signed URL's signature query params. */
export function safeHostForLogging(url: string): string {
  try {
    return new URL(url).hostname;
  } catch {
    return '<unparseable>';
  }
}
Enter fullscreen mode Exit fullscreen mode

A signed S3 or CloudFront URL carries its credential and signature in the query string. Log the URL and you have written a bearer token for somebody's raw email into a log aggregator with a different retention policy and a different access list from the mailbox it came from. The hostname is the only part of it worth having, because the only question the log has to answer is "what did it try to reach".

Everything cheap happens before anything expensive

The host pin is one link. The route refuses in a deliberate order, and the ordering is the design:

// 1. Rate limit, fails open
// 2. Reject oversized bodies before buffering
// 3. Signature verification, fails closed
// 4. Idempotency claim
// 5. Handle
Enter fullscreen mode Exit fullscreen mode

The size cap is checked twice.

const contentLength = request.headers.get('content-length');
if (contentLength && Number(contentLength) > MAX_BODY_BYTES) {
  return NextResponse.json({ error: 'Payload too large' }, { status: 413 });
}

const rawBody = await request.text();
if (Buffer.byteLength(rawBody, 'utf8') > MAX_BODY_BYTES) {
  return NextResponse.json({ error: 'Payload too large' }, { status: 413 });
}
Enter fullscreen mode Exit fullscreen mode

The header is a claim made by the caller. Checking it first is an optimisation that lets an honest oversized request be refused without buffering. Checking the actual byte length afterwards is the part that is true. The same pattern repeats on the raw email download with a 25 MB cap, for the same reason: content-length on the response is a hint, the measured body is a fact.

The signature check fails closed, including on misconfiguration.

const webhookSecret = process.env.RESEND_WEBHOOK_SECRET;
if (!webhookSecret) {
  console.error('[resend-webhook] RESEND_WEBHOOK_SECRET is not configured');
  return NextResponse.json({ error: 'Webhook configuration error' }, { status: 500 });
}
Enter fullscreen mode Exit fullscreen mode

A missing secret is the one case where an unverified endpoint tends to appear: somebody deploys to a new environment, the variable is not set, and a check written as if (secret && !verify(...)) silently becomes an open relay that sends email through your account. Write it as two statements and the absence of a secret is its own refusal.

The rate limiter is the only thing that fails open, because dropping real mail is worse than accepting a burst of it, and everything after it still has to pass.

The fetch has a timeout. AbortSignal.timeout(10_000), inside a route declaring maxDuration = 30. A download with no timeout inside a function with a hard ceiling is a function that dies on the ceiling instead of handling the failure.

See it working

You cannot watch a webhook from the outside, which is the point of the signature. What you can see is the end of the pipeline:

  • munchable.app/support is the form, and the thread it opens is the same thread an inbound email joins. It does not ask you to sign in, deliberately.
  • munchable.app/privacy sets out what happens to a message once it arrives, which is the commitment all of the above exists to keep.
  • munchable.app is the rest of the product, whose scan path makes no outbound third-party calls at all. This webhook is the only place in the system that fetches a URL it did not construct, which is exactly why it is worth eleven lines of allowlist.

If there is one thing to take from this: "the input comes from a trusted source" is a claim about a relationship, and the code only knows about a string.

Top comments (0)