Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: more flexible argument extraction and signature testing #707

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

ianroberts
Copy link
Contributor

@ianroberts ianroberts commented Oct 29, 2024

This started out as a more general fix for #643 but it has evolved into a larger refactor with a number of open questions, so I'm submitting it as a draft PR initially for discussion:

Overview

Issue #643 discusses the idea of verifying a signature that was computed over something other than just the request body content, and the related PR #644 provides an implementation of one special case of this general idea, concatenating one or more request header values to the end of the body data before calculating the signature. However there are many variations on this theme, for example

In this PR I've tried to come up with a general way to support cases like those as well.

The implementation is in two parts:

  1. I've introduced a new Argument type "source": "template", where the argument value is computed by evaluating a Go template against a context derived from the Request. This lets you combine values from different headers, take substrings, etc.
  2. I've refactored the various payload-hmac-* "match" rules into a new "check-signature" rule type, which gives the option to use an Argument for the "string-to-sign" as well as the one for the reference signature value.

Together these let you compute signatures over any combination of the body, headers and query parameters, concatenated together with no delimiter, newlines, colons, etc. as required by your particular webhook sender.

Template argument type

- source: template
  name: |
    {{- .BodyText -}}:{{- .GetHeader "x-request-id" -}}

The template argument type evaluates a Go template to build the argument value. The template context gives access to all the things that the other argument types provide, including

  • the raw payload (.Body as a []byte, .BodyText as a string)
  • the parsed JSON/XML payload (.Payload)
  • URL query parameters (.Query)
  • request headers (the raw map as .Headers, but also the .GetHeader "name" function in the example above that canonicalises the header name the same way as a "source": "header" argument would do)
  • webhook's own request ID (.ID)
  • the remote address, content type and request method (.RemoteAddr, .ContentType and .Method)

I've deliberately not exposed the whole of the RawRequest from the internal Request struct.

The check-signature rule type

I've refactored all the payload-hmac-sha1/256/512 match types into a new kind of rule. The old

{
  "match":
  {
    "type": "payload-hmac-<type>",
    "secret": "secret",
    "parameter":
    {
      "source": "header",
      "name": "X-Signature"
    }
  }
}

is now expressed as

{
  "check-signature":
  {
    "algorithm": "<type>",
    "secret": "secret",
    "signature":
    {
      "source": "header",
      "name": "X-Signature"
    }
  }
}

However, as well as "algorithm", "secret" and "signature", you can now specify "string-to-sign" as another argument, which could be a simple argument like a single header

{
  "check-signature":
  { "algorithm": "sha256", "secret": "secret",
    "signature":
    {
      "source": "header",
      "name": "X-Signature"
    },
    "string-to-sign":
    {
      "source": "header",
      "name": "Digest"
    }
  }
}

or it could be a template argument like the example above

- check-signature:
    algorithm: sha256
    secret: my-secret
    signature:
      source: header
      name: x-signature
    string-to-sign:
      source: template
        name: |
          {{- .BodyText -}}:{{- .GetHeader "x-request-id" -}}

Backwards compatibility

I've made sure that existing hooks files (YAML and JSON) will still parse as before, with the payload-hmac-* match rules being converted to check-signature rules during the loading process. The only wrinkle is if you want to start using templates in arguments and you need to parse the hooks file itself as a template, then it can get quite fiddly to escape the runtime templates from the parse-time engine. To mitigate this I've added a -template-delims CLI option to change the delimiters used for the parse-time template engine, so you can say -template-delims='[[,]]' and then use [[ getenv "..." ]] for the parse-time template and {{ .GetHeader "..." }} for the runtime ones.


Open questions for discussion

Are Go Templates the right paradigm?

Go templates are very flexible, but should we stick with something simpler and with fewer gotchas around whitespace handling etc.? #512 mentions cel-go as an alternative.

Template context

Is the template context appropriate? Are there other things we could/should expose to the templates, or more sensible names for the things that are already there?

Do we want to add some more useful functions to the template engine? One extreme would be to introduce something like Sprig, the same function library used by Helm. This includes functions for things like date parsing and formatting, which would also open up a way to use template arguments to check things like "is the Date header less than 15 minutes old":

- match:
    type: "value"
    value: "true"
    parameter:
      source: template
      name: |
        {{- (.GetHeader "date" | toDate "Mon Jan 2 15:04:05 MST 2006").After (now | dateModify "-15m") -}}

Though something like that probably deserves its own dedicated rule type.

Added a new "source": "template" argument type, that evaluates a Go text/template against a context containing the request Body, Query, Payload and Headers, enabling much richer mapping from request attributes to argument parameters.
When you want to use "source":"template" arguments inside your hook file but also parse the hook file itself as a template, it is necessary use different delimiters on the two template parsers to avoid having to pepper all the inner templates with constructs like {{"{{"}}.  Added an extra command line argument -template-delims that expects a comma-separated pair of delimiters like '[[,]]' that will be used when parsing the whole hooks file template.  Inner templates in an Argument always use the default double-brace style.
Move the signature checking rules out of MatchRule into their own dedicated SignatureRule, configured as "check-signature" in the hooks file.  This takes an algorithm, secret and Argument giving the source of the signature, and by default behaves exactly like the old payload-hmac-<algorithm> match rules.  However it can also take a second optional Argument to customize how to generate the "string to sign", allowing signatures to be computed over something other than the full request body content.

This could be a single header or payload item but more likely will be a "template" argument to combine items from different places in the request, such as the body content and one or more headers, e.g. to compute a signature over the X-Request-Id header, Date header, and request body, concatenated with CRLF, you could specify

check-signature:
  algorithm: sha512
  secret: 5uper5eecret
  signature:
    source: header
    name: X-Hook-Signature
  string-to-sign:
    source: template
    name: |
      {{- printf "%s\r\n" (.GetHeader "x-request-id") -}}
      {{- printf "%s\r\n" (.GetHeader "date") -}}
      {{- .BodyText -}}
There are other places in the logic that depend on errors extracting Argument values being of type ParameterNodeError specifically, so we shouldn't wrap these errors further.
@moorereason
Copy link
Collaborator

I haven't had much time to commit to webhook in a while, but this PR got my attention. Kudos for making a great PR and explaining the situation well.

Regarding templates, see this discussion. I feel like HCL is probably too complex and may hem us in in some areas. For purely adding a template language to our existing configs, I'd vote for expr. See what you think about it.

I'm curious if we could solve the various hmac scenarios with templates instead of extending the config schema with the check-signature and string-to-sign stuff. I like the simplicity of the existing schema and am not against extending it to make it easier on the end users, but some of these hmac requirements are pretty complex. I'd recommend thinking through the template feature more to see how far it can take us.

@moorereason
Copy link
Collaborator

Iterating on this idea using expr while trying to stick with the match model:

# Assumes the hmac() function could derive the hash function and secret from the match stanza.
- match:
    type: hmac-sha256
    secret: secret
    parameter:
      source: expr
      name: |
        hmac($bodyText + ":" + getHeader("x-request-id")) == trimPrefix(getHeader("x-signature"), "sha256:")

- match:
    type: value
    value: true
    parameter:
      source: expr
        name: |
          date(getHeader("date")) > now() - duration("15m")

@adnanh
Copy link
Owner

adnanh commented Nov 9, 2024

I've been wanting to implement this for a while now, but never got the necessary focus time...

I agree with @moorereason that some generic expressions format would be a more human readable format compared to Go templates, but either way it would be a long awaited huuuge improvement to webhook.

At my full time job, I used to work a lot with mapbox mapping SDK which includes expressions for data styling, the bottom line is that it was very simple to learn and very powerful to use. Here's a documentation page for those, maybe it could serve as a source of inspiration for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants