postalserver / postal

📮 A fully featured open source mail delivery platform for incoming & outgoing e-mail
https://postalserver.io
MIT License
14.82k stars 1.05k forks source link

Any docs on verifying the X-Postal-Signature header passed to webhooks? #432

Closed dsnopek closed 6 years ago

dsnopek commented 6 years ago

So, we don't want the receiving end of webhooks to be open to the internet (especially on Open Source plugns where the path will be predictable), we need to verify the requests somehow.

I can see a X-Postal-Signature on requests the webhook end point. Is there any documentation on verifying that?

willpower232 commented 6 years ago

Do you mean on Postal itself or from Postal to your app?

Can I ask what your concerns are?

dsnopek commented 6 years ago

I mean verifying in my "app" that the request to the webhook authentically came from the Postal instance that I'm expecting the request to come from.

And, I say "app" (in quotes) because I'm actually writing a CiviCRM extension to integrate with Postal, with the webhook to record bounces and clicks. Later, I plan to write a Drupal module too!

Anyway, my concern is that potential attackers will know that the CiviCRM extension puts the webhook at /civicrm/postal/webhook and just try submitting data on that endpoint on every CiviCRM site they find, probably in order to SPAM the admins of the CiviCRM installation (ie. it could add activities to contacts that get sent to admins via notification e-mails).

Similar services like Mandrill, pass a X-Mandrill-Signature in a header to the webhook which allows the webhook to verify that the request really came from Mandrill - see:

https://mandrill.zendesk.com/hc/en-us/articles/205583257-How-to-Authenticate-Webhook-Requests

The name of the X-Postal-Signature leads me to believe it serves the same purpose? However, I don't see a webhook "authentication key" anywhere in the UI, and I couldn't find any docs on it.

Does that all make sense?

willpower232 commented 6 years ago

That makes sense.

When you send messages you should get a return from Postal which contains both an id and a token. If you store these values in your database, you will be able to compare them against the body of the webhook.

In PHP, using the FuelPHP framework, the process looks a little like the following: https://github.com/SynergiTech/fuelphp-postal/blob/master/classes/sendmessage.php#L129-L149 https://github.com/SynergiTech/fuelphp-postal/blob/master/classes/webhook.php

In our case, we ignore traffic unless we match both the id and token.

I would hope that it shouldn't be difficult to use a similar approach in your systems.

dsnopek commented 6 years ago

Hm. So, this is something you're looking for on the messages referenced in the payload from webhook? I guess that could work if all the events include messages... I'll have to check that.

So, what's the purpose of X-Postal-Signature then? The name makes me think it should be used for authentication...

Another way to do this is to pass some secret token in GET arguments to the webhook (which the webhook checks), which should work OK if the webhook uses HTTPS, but we actually can't guarantee that for a generic CiviCRM or Drupal extension...

dsnopek commented 6 years ago

Looking at the code:

https://github.com/atech/postal/blob/1e62f9bd0e09926bebea73f44fa76d210b508202/lib/postal/http.rb#L40

It appears that X-Postal-Signature is signing the body with the 'signing_key' which is the key used for DKIM... Maybe there is a way to verify that by using the DKIM info from DNS? I'm really not sure... I wish there were some docs!

willpower232 commented 6 years ago

I wish there were some docs!

Don't we all :-P

It seems this is related to https://github.com/atech/postal/issues/279

X-Postal-Signature is generated using EncryptoSigno which is also made by atech and bundled in with Postal.

I presume you would have to include or port the library code to decrypt and then verify the token.

FWIW the token is a random-looking string of characters so should be relatively sufficient by itself as it would be pretty lucky to land on a message using a random id and token.

dsnopek commented 6 years ago

Digging into the code a litlte more, I suspect that DKIM is the key to this... I haven't tried it yet, but I think if I extracted the RSA public key from the TXT record for DKIM and signed the request body with it, then that would match the X-Postal-Signature... Project maintainers, feel free to let me know if I'm getting warm :-)

dsnopek commented 6 years ago

Ok! I got this to work, but it's not the public key from the TXT DKIM record for the domain the webhook is setup for, but the public key from the 'postal._domainkey.rp' (the return path) TXT record, which you get originally during installation from running postal default-dkim-record per https://github.com/atech/postal/wiki/Domains-&-DNS-Configuration

I feel like it'd make more sense if each domain (or even each webhook) got it's own public key, but it really isn't any less secure - you know that the request must be coming from this specific postal server.

Here's a cleaned up version of my PHP code:

    $dkim_public_key = '...'; // Just the p=... part of the TXT record (without the semicolon at the end)
    $rsa_key_pem = "-----BEGIN PUBLIC KEY-----\r\n" .
      chunk_split($dkim_public_key, 64) .
      "-----END PUBLIC KEY-----\r\n";
    $rsa_key = openssl_pkey_get_public($rsa_key_pem);

    $body = file_get_contents('php://input');

    $signature = $_SERVER['HTTP_X_POSTAL_SIGNATURE'];
    $signature = base64_decode($signature);

    $result = openssl_verify($body, $signature, $rsa_key, OPENSSL_ALGO_SHA1);

    if ($result === 1) {
      // Verified!
    }

Would be sweet to get this into the docs somewhere, somehow!

dsnopek commented 6 years ago

In case anyone is curious, here's my CiviCRM extension so far:

https://gitlab.com/roundearth/io.roundearth.postal/

(The signature verifying bits are here: https://gitlab.com/roundearth/io.roundearth.postal/blob/master/src/SignatureVerifier.php)

adamcooke commented 6 years ago

We use this library for signing the request. The request is signed with the private key found in signing.key in the config directory.

https://github.com/atech/encrypto-signo

dsnopek commented 6 years ago

@adamcooke As you can see above I figured out how to verify the signature. Someone had earlier posted a link to that library. However, that's not really in itself very useful because there's nothing that says what key is being used - through reading the code and trial and error I had to figure out that it's the key from postal default-dkim-record. As this issue is about documentation, personally, I think it should stay open until a documentation change is merged!

adamcooke commented 6 years ago

I've made an issue to track the documentation :) #465

dsnopek commented 6 years ago

Awesome, thanks!

oasis1234 commented 6 years ago

Thank you @dsnopek, you save my day!

The DKIM pub key from postal default-dkim-record comes from /opt/postal/config/signing.key and it's used for the webhook signing (X-Postal-Signature). The key in the DKIM Record (DNS Setup page) comes from the database and it's the actual key used for email signing. See: https://github.com/atech/postal/issues/616

Did you mean? $signature = $_SERVER['X_POSTAL_SIGNATURE'];

dsnopek commented 6 years ago

Interesting! I haven't looked at email signing, but thanks for tracking down where the key is. :-)

Did you mean? $signature = $_SERVER['X_POSTAL_SIGNATURE'];

Hm, no, HTTP headers are supposed to be prefixed with 'HTTP_' so I meant what I wrote in my comment. See the PHP docs on the $_SERVER variable:

http://php.net/reserved.variables.server

However, ideally, you'll be using some framework (ex. Symfony) which wraps the request in an object and gives a nicer way to access HTTP headers.

oasis1234 commented 6 years ago

ouch! this happened to me for posting untested code. I've just comfirmed, $_SERVER['HTTP_X_POSTAL_SIGNATURE']; is the right one. Sorry.

danielboven commented 3 years ago

Thank you all for the information above, it has been very helpful to me! In case anyone wants to verify the signature on a webhook request using Node.js (with Express, for example), I have been working on replicating the steps at https://github.com/postalhq/postal/issues/432#issuecomment-353143578, now using Node.js and the crypto module:

import crypto from "crypto"

function validateSignatureWebhook(req) {
    const postalWebhookPK = '...' // Just the p=... part of the TXT record (without the semicolon at the end)

    // convert postal public key to PEM (X.509) format
    const publicKey =  '-----BEGIN PUBLIC KEY-----\r\n'+
        chunk_split(postalWebhookPK, 64, '\r\n')+
        '-----END PUBLIC KEY-----'

    const signature = req.headers['x-postal-signature']

    const verifier = crypto.createVerify('SHA1')
    verifier.update(JSON_to_s(req.body, false))

    // return verify result (true or false)
    return verifier.verify(publicKey, signature, 'base64')
}

Please notice:

  1. chunk_split() is not a native JS function. I have used the following code from this post:
function chunk_split (body, chunklen, end) { // eslint-disable-line camelcase
    //  discuss at: https://locutus.io/php/chunk_split/
    // original by: Paulo Freitas
    //    input by: Brett Zamir (https://brett-zamir.me)
    // bugfixed by: Kevin van Zonneveld (https://kvz.io)
    // improved by: Theriault (https://github.com/Theriault)
    //   example 1: chunk_split('Hello world!', 1, '*')
    //   returns 1: 'H*e*l*l*o* *w*o*r*l*d*!*'
    //   example 2: chunk_split('Hello world!', 10, '*')
    //   returns 2: 'Hello worl*d!*'

    chunklen = parseInt(chunklen, 10) || 76
    end = end || '\r\n'

    if (chunklen < 1) {
      return false
    }

    return body.match(new RegExp('.{0,' + chunklen + '}', 'g'))
      .join(end)
}
  1. Moreover, JSON_to_s() is a function which converts the object into a string with the same character encoding as the (Postal) Ruby script does. Using JS native's JSON.stringify() solely is not possible here because it does not escape the characters &, <, > correctly. Using the following function takes care of that:

    function JSON_to_s(s, emit_unicode) {
    const json = JSON.stringify(s)
    //         goal: use a regex to simulate Ruby json_escape
    // regex source: https://api.rubyonrails.org/classes/ERB/Util.html
    //  inspired by: https://gist.github.com/composite/8396541
    return emit_unicode ? json : json.replace(/[\u2028\u2029&><]/gu,
        c => '\\u'+('0000'+c.charCodeAt(0).toString(16)).slice(-4)
    )
    }
  2. Calling the function validateSignatureWebhook() with an Express request object will work. In case you're using a different framework/setup than Express, you might have to adjust req.headers['x-postal-signature'] and req.body.

  3. I recommend putting the constant postalWebhookPK in a .env file. If you do, you could replace it with this: const { POSTAL_WEBHOOK_PK } = process.env.