How to verify woocommerce webhook signature in web2py auth decorator

150 views
Skip to first unread message

Manuele Pesenti

unread,
Feb 28, 2018, 4:51:38 AM2/28/18
to web2py
Hi!

I need to develop a web service that listen to webhook calls from a
woocommerce site, I thought to write a little check class to pass to
auth.requires decorator like the following:


class HookCheck(object):
    secret = ''

    def __init__(self):
        super(HookCheck, self).__init__()
        self()

    def compute(self, body):
        dig = hmac.new(self.secret.encode(),
            msg = body.encode(), # your_bytes_string
            digestmod = hashlib.sha256
        ).digest()
        computed = base64.b64encode(dig).decode()
        return computed

    def __call__(self):
        signature = ''                        # <- how can I get from
the request headers?
        body = request.body.read() # <- Is it the right string to encode?
        computed = self.compute(body)
        print signature, computed, signature==computed
        return signature==computed


@service.json
@auth.requires(HookCheck(), requires_login=False)
def listenToHooks():
    return {}


can somebody help me to get the correct values of the hook signature and
the raw call body to check?

As far as I know the signature contained in the header field
"X-Wc-Webhook-Signature" and I'm not sure if the string from which get
the hmac hash is just what I get from the read method of the
request.body object.

thank a lot

    Manuele

Anthony

unread,
Feb 28, 2018, 9:50:39 AM2/28/18
to web...@googlegroups.com
The webhook request headers will be in request.env.http_x_wc_webhook_[specific header] (e.g., request.env.http_x_wc_webhook_signature). Note, request.env is the WSGI environment passed to web2py from the WSGI-compliant web server, and the WSGI spec puts all request headers in that environment, each prefixed with "http_".

It looks like WooCommerce makes a POST request, so the values posted should end up in request.post_vars.

As an aside, you can probably simplify your code to just be a function rather than a class, and I don't think there is much gained by putting it inside an @auth.requires decorator -- just run the relevant code directly in the listenToHooks function.

Anthony

Manuele Pesenti

unread,
Feb 28, 2018, 10:34:06 AM2/28/18
to web...@googlegroups.com
Thank Antony,


On 28/02/2018 15:50, Anthony wrote:
> The webhook request headers will be in
> request.env.http_x_wc_webhook_[specific header] (e.g.,
> request.env.http_x_wc_webhook_signature).

ok got it!

>
> It looks like WooCommerce makes a POST request, so the values posted
> should end up in request.post_vars.

maybe I don't understand... what I think I need to check is the raw body
of the request... isn't it? How should I check the request.post_vars?
Isn't it a dictionary or a Storage object?

>
> As an aside, you can probably simplify your code to just be a function
> rather than a class,

ok I agree

> and I don't think there is much gained by putting it inside

ok... but why not?

> an @auth.requires decorator -- just run the relevant code directly in
> the listenToHooks function.
>
> Anthony

Cheers
    Manuele

Anthony

unread,
Feb 28, 2018, 11:10:26 AM2/28/18
to web2py-users
> It looks like WooCommerce makes a POST request, so the values posted
> should end up in request.post_vars.

maybe I don't understand... what I think I need to check is the raw body
of the request... isn't it? How should I check the request.post_vars?
Isn't it a dictionary or a Storage object?

You could parse the request body yourself, but web2py will do it automatically and put the variables in request.post_vars (if JSON is posted, its keys will become the keys of request.post_vars).

I'm not sure what you mean by "check the request.post_vars". If there are variables you are expecting in the posted body, they will be in request.post_vars. Looking at the example log here, it looks like you might expect request.post_vars.action and request.post_vars.arg. The "action" value will also be in one of the request headers. Not sure if you need or care about "arg".
 
> and I don't think there is much gained by putting it inside

ok... but why not?

It's just another level of indirection for no benefit. Actually, if the @auth.requires check fails, it will end up redirecting to the web2py Auth "not_authorized" HTML page (with a 200 response). A better response would simply be to raise an HTTP(403) exception.

Anthony

Manuele Pesenti

unread,
Feb 28, 2018, 4:41:01 PM2/28/18
to web...@googlegroups.com
Il 28/02/18 17:10, Anthony ha scritto:
You could parse the request body yourself, but web2py will do it automatically and put the variables in request.post_vars (if JSON is posted, its keys will become the keys of request.post_vars).

I'm not sure what you mean by "check the request.post_vars". If there are variables you are expecting in the posted body, they will be in request.post_vars. Looking at the example log here, it looks like you might expect request.post_vars.action and request.post_vars.arg. The "action" value will also be in one of the request headers. Not sure if you need or care about "arg".

A little step backward... I want to verify the call origin and authenticity.

Each time a call is performed by a webhook it is signed with a signature in the header obtained by encoding the body and I want to verify this signature in order to be sure from where the call comes from. I've found something similar for other languages and environments but not for python and web2py, for example this one https://stackoverflow.com/q/42182387/1039510. The concept is quite easy but there are some details I miss.

Hereunder I tryied to rewrite the example code[*] in a more clear way (I hope).

Does anybody tryied it before or somebody with some woocommerce webhook experience can point me to what's wrong in it?


def compute(body):
    secret = '<here is my secret key>'
    dig = hmac.new(secret.encode(),
        msg = body.encode(),


        digestmod = hashlib.sha256
    ).digest()
    computed = base64.b64encode(dig).decode()
    return computed   

def hookCheck(func):
    def wrapper(*args, **kw):
        signature = request.env.http_x_wc_webhook_signature
        body = request.body.read() # ??
        computed = compute(body)
        if signature==computed:
            return func(*args, **kw)
        raise HTTP(403)
    return wrapper

@service.json
def listenToHooks():
    @hookCheck
    def _main_():
        # do stuff
        return {}
    return _main_()


Best regards

    Manuele


[*] https://gist.github.com/manuelep/4b64492ceeaa07f095302f94956ea554

Anthony

unread,
Feb 28, 2018, 6:50:16 PM2/28/18
to web2py-users
I think you're on the right track. If you need the original request body to verify the signature, request.body.read() should do it. Does that not work?

Also, I don't think you need the decorator and nested function. Just write a simple function and call it at the beginning of the handler:

def verify_signature():

    secret
= '<here is my secret key>'

    body
= request.body.read()
    dig
= hmac.new(secret.encode(), msg=body.encode(), digestmod=hashlib.sha256).digest()
   
if request.env.http_x_wc_webhook_signature != base64.b64encode(dig).decode():
       
raise HTTP(403)  

@service.json
def listenToHooks():
    verify_signature
()
   
# do stuff

Anthony

Dave S

unread,
Feb 28, 2018, 9:25:18 PM2/28/18
to web2py-users


On Wednesday, February 28, 2018 at 3:50:16 PM UTC-8, Anthony wrote:
I think you're on the right track. If you need the original request body to verify the signature, request.body.read() should do it. Does that not work?

Also, I don't think you need the decorator and nested function. Just write a simple function and call it at the beginning of the handler:

def verify_signature():
    secret
= '<here is my secret key>'
    body
= request.body.read()
    dig
= hmac.new(secret.encode(), msg=body.encode(), digestmod=hashlib.sha256).digest()
   
if request.env.http_x_wc_webhook_signature != base64.b64encode(dig).decode():
       
raise HTTP(403)  

@service.json
def listenToHooks():
    verify_signature
()
   
# do stuff

Anthony



Don't you want a dummy parameter on verify_signature(), to prevent it being a URL-visible function?

Like

def verify_signature(isinternal=True):

/dps

Manuele Pesenti

unread,
Mar 1, 2018, 3:31:53 AM3/1/18
to web...@googlegroups.com



On 01/03/2018 03:25, Dave S wrote:

Don't you want a dummy parameter on verify_signature(), to prevent it being a URL-visible function?
well actually it can even stay inside the models not a controller... in that case if it's not decorate as a service it cannot be visible. right?

       M.

Anthony

unread,
Mar 1, 2018, 7:28:57 AM3/1/18
to web2py-users
On Wednesday, February 28, 2018 at 3:50:16 PM UTC-8, Anthony wrote:
I think you're on the right track. If you need the original request body to verify the signature, request.body.read() should do it. Does that not work?

Also, I don't think you need the decorator and nested function. Just write a simple function and call it at the beginning of the handler:

def verify_signature():
    secret
= '<here is my secret key>'
    body
= request.body.read()
    dig
= hmac.new(secret.encode(), msg=body.encode(), digestmod=hashlib.sha256).digest()
   
if request.env.http_x_wc_webhook_signature != base64.b64encode(dig).decode():
       
raise HTTP(403)  

@service.json
def listenToHooks():
    verify_signature
()
   
# do stuff

Anthony



Don't you want a dummy parameter on verify_signature(), to prevent it being a URL-visible function?

Instead of a dummy parameter, you can start the name with a double underscore. But if verify_signature is needed in multiple places, I would move it to a model or module. If only needed in this one place, I probably wouldn't make a separate function and simply add those few lines directly to listenToHooks.

Anthony

Manuele Pesenti

unread,
Mar 15, 2018, 1:09:31 PM3/15/18
to web...@googlegroups.com
On 01/03/2018 00:50, Anthony wrote:

> I think you're on the right track. If you need the original request
> body to verify the signature, request.body.read() should do it. Does
> that not work?
Hi Anthony,
actually no :( it doesn't work, here[1] I tried to extrapolate the very
essential code in order to test a use case.
To obtain the data I used such a web service like "requestb.in" as a
webhook url and saved the woocommerce product.

running the test the result is:

$ python -m test
E
======================================================================
ERROR: test_authenticate (__main__.TestWoo)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "[...]/woohook/test.py", line 16, in test_authenticate
    res = WooHook.check(body, signature, secret)
  File "woohook.py", line 23, in check
    raise AuthenticationError(result)
AuthenticationError: WNeVWlUGBX6pSusRngDavUWlck6eAhVpTRoTYBbJdYM=

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

Any idea or suggestion will be appreciated!

Cheers
    Manuele

[1] https://gist.github.com/manuelep/b6f6c00b4dec5234ab97229199bb223d

Anthony

unread,
Mar 15, 2018, 3:21:02 PM3/15/18
to web2py-users
Hard to say what's wrong. Where did you get that signature and request body? You might be better off getting help from folks who know WooCommerce, as this issue does not appear to be web2py specific.

Anthony

Manuele Pesenti

unread,
Mar 15, 2018, 5:17:50 PM3/15/18
to web...@googlegroups.com
Il 15/03/18 20:21, Anthony ha scritto:
> Hard to say what's wrong. Where did you get that signature and request
> body? You might be better off getting help from folks who know
> WooCommerce, as this issue does not appear to be web2py specific.
>
Yes for sure! Thanks a lot.

    M.

Manuele Pesenti

unread,
Mar 16, 2018, 6:43:38 AM3/16/18
to web2py@googlegroups.com >> web2py-users



On 15/03/2018 22:17, Manuele Pesenti wrote:
You might be better off getting help from folks who know
WooCommerce, as this issue does not appear to be web2py specific.

Yes for sure! Thanks a lot.

    M.
Before to definitely fly to other places where to find answers to my problem I have one little question related with web2py...
In woocommerce documentation they say this about request signature:

"X-WC-Webhook-Signature - a base64 encoded HMAC-SHA256 hash of the payload."[1]

Till now I interpreted "payload" as the request body... so as the json string I can read simply using `request.body.read()`.
Could it even be interpreted as the whole send content including the the request header?

How could it be get or reconstructed from the request storage object?

Thanks a lot
    Manuele

[1] https://github.com/woocommerce/woocommerce-rest-api-docs/blob/master/source/includes/wp-api-v1/_webhooks.md

Anthony

unread,
Mar 16, 2018, 10:59:42 AM3/16/18
to web2py-users
Before to definitely fly to other places where to find answers to my problem I have one little question related with web2py...
In woocommerce documentation they say this about request signature:

"X-WC-Webhook-Signature - a base64 encoded HMAC-SHA256 hash of the payload."[1]

Till now I interpreted "payload" as the request body... so as the json string I can read simply using `request.body.read()`.
Could it even be interpreted as the whole send content including the the request header?

I would be surprised if that were the case given that (1) order of HTTP headers is not supposed to be significant (but would need to be if being used to generate a hash) and (2) WSGI applications receive incoming requests in the form of an environment dictionary generated by the WSGI-compliant web server, not the original HTTP message.
 
How could it be get or reconstructed from the request storage object?

I'm not sure if it includes the entire original HTTP message or just the request body, but you can try request.env['wsgi.input']. If that doesn't work, web2py (and probably any WSGI-compliant framework) would not have access to the original HTTP message (which is parsed by the web server before passing request data to the web framework/application).

Anthony

Manuele Pesenti

unread,
Mar 17, 2018, 2:19:58 PM3/17/18
to web...@googlegroups.com
Il 16/03/18 15:59, Anthony ha scritto:
I'm not sure if it includes the entire original HTTP message or just the request body, but you can try request.env['wsgi.input']. If that doesn't work, web2py (and probably any WSGI-compliant framework) would not have access to the original HTTP message (which is parsed by the web server before passing request data to the web framework/application).

Anthony

Thanks Anthony for your attention,

now I've solved... there was no problem in the procedure but in the tested data I copied from web services (such as requestb.in or directly from the woocommerce event log web page) that I didn't notice they were converting string such as "&euro;" into the character €. That's why I didn't get the correct encoded string.
Directly using what I get from request.body.read() everything worked fine.

Best regards

    Manuele


Patrick Rodrigues

unread,
Mar 25, 2018, 8:25:16 AM3/25/18
to web2py-users
I was developing the same feature for my website today, and this help me a lot.
In my case I was using Dango Rest Framework, and I was using request.data and parsing it to JSON, insted of using request.body.
But now it works, thank you about this conversation.

Manuele Pesenti

unread,
Mar 25, 2018, 10:42:00 AM3/25/18
to web...@googlegroups.com
Il 25/03/18 00:51, Patrick Rodrigues ha scritto:
> I was developing the same feature for my website today, and this help
> me a lot.
> In my case I was using Dango Rest Framework, and I was using
> request.data and parsing it to JSON, insted of using request.body.
> But now it works, thank you about this conversation.

Hi Patrick!

Happy to had been useful in some way :)

    M.

Reply all
Reply to author
Forward
0 new messages