Skip to content

Commit

Permalink
Feature/add signature validator (#13)
Browse files Browse the repository at this point in the history
* feat: [no-ticket] add support for  request verification

---------

Co-authored-by: Andrii Gerasymchuk <[email protected]>
  • Loading branch information
Gera3dartist and Andrii Gerasymchuk authored Aug 22, 2023
1 parent 4443295 commit 802b78d
Show file tree
Hide file tree
Showing 10 changed files with 89 additions and 32 deletions.
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ repos:
- id: check-yaml

- repo: https://github.com/psf/black
rev: 22.10.0
rev: 23.1.0
hooks:
- id: black
language_version: python3.8
language_version: python3.11
args: [--line-length=120, --skip-string-normalization]

- repo: https://gitlab.com/pycqa/flake8
rev: 5.0.4
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
hooks:
- id: flake8

Expand All @@ -30,7 +30,7 @@ repos:
stages: [commit]

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991
rev: v1.1.1
hooks:
- id: mypy
args: [--no-error-summary, --hide-error-codes, --follow-imports=skip]
Expand Down
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog


## 1.1.0
- Added helper function for validation of request origin using X-CIO-Signature and X-CIO-Timestamps

## 1.0.0

- [BREAKING] For consistency across APIs following actions have been taken:
Expand Down
1 change: 1 addition & 0 deletions async_customerio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from async_customerio.api import AsyncAPIClient, SendEmailRequest # noqa
from async_customerio.errors import AsyncCustomerIOError # noqa
from async_customerio.regions import Regions # noqa
from async_customerio.request_validator import validate_signature # noqa
from async_customerio.track import AsyncCustomerIO # noqa


Expand Down
36 changes: 18 additions & 18 deletions async_customerio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
import base64
import typing as t
from typing import Optional

from typing_extensions import TypedDict

Expand All @@ -22,30 +23,29 @@ class SendEmailRequest:

def __init__(
self,
transactional_message_id: t.Union[str, int] = None,
to: str = None,
identifiers: t.Union[IdentifierID, IdentifierEMAIL, IdentifierCIOID] = None,
_from: str = None,
headers: t.Dict[str, str] = None,
reply_to: str = None,
bcc: str = None,
subject: str = None,
preheader: str = None,
body: str = None,
body_amp: str = None,
body_plain: str = None,
fake_bcc: str = None,
transactional_message_id: Optional[t.Union[str, int]] = None,
to: Optional[str] = None,
identifiers: Optional[t.Union[IdentifierID, IdentifierEMAIL, IdentifierCIOID]] = None,
_from: Optional[str] = None,
headers: Optional[t.Dict[str, str]] = None,
reply_to: Optional[str] = None,
bcc: Optional[str] = None,
subject: Optional[str] = None,
preheader: Optional[str] = None,
body: Optional[str] = None,
body_amp: Optional[str] = None,
body_plain: Optional[str] = None,
fake_bcc: Optional[str] = None,
disable_message_retention: bool = False,
send_to_unsubscribed: bool = True,
tracked: bool = True,
queue_draft: bool = False,
message_data: dict = None,
attachments: t.Dict[str, str] = None,
message_data: Optional[dict] = None,
attachments: Optional[t.Dict[str, str]] = None,
disable_css_preproceessing: bool = False,
send_at: int = None,
language: str = None,
send_at: Optional[int] = None,
language: Optional[str] = None,
):

self.transactional_message_id = transactional_message_id
self.to = to
self.identifiers = identifiers
Expand Down
5 changes: 3 additions & 2 deletions async_customerio/client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import typing as t
import uuid
from typing import Optional

import httpx

Expand Down Expand Up @@ -42,8 +43,8 @@ async def send_request(
method: str,
url: str,
*,
json_payload: t.Dict[str, t.Any] = None,
headers: t.Dict[str, str] = None,
json_payload: Optional[t.Dict[str, t.Any]] = None,
headers: Optional[t.Dict[str, str]] = None,
auth: t.Optional[t.Tuple[str, str]] = None,
) -> t.Union[dict]:
"""
Expand Down
18 changes: 18 additions & 0 deletions async_customerio/request_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import hashlib
import hmac


def validate_signature(signing_key: str, timestamp: int, request_body: bytes, signature: str) -> bool:
"""Validate that request was sent from Customer.io
Doc: https://customer.io/docs/journeys/webhooks/#securely-verify-requests
:param signing_key: value for SIGNING KEY from Customer.io
:param timestamp: unix timestamp, value from header X-CIO-Timestamp
:param request_body: body of the request
:param signature: value from header X-CIO-Signature
:returns: True if the request passes validation, False if not
"""
payload = b"v0:" + str(timestamp).encode() + b":" + request_body
computed_signature = hmac.new(key=signing_key.encode(), msg=payload, digestmod=hashlib.sha256).hexdigest()
return hmac.compare_digest(computed_signature, signature)
11 changes: 6 additions & 5 deletions async_customerio/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
import typing as t
from datetime import datetime
from typing import Optional
from urllib.parse import quote

from async_customerio.client_base import AsyncClientBase
Expand Down Expand Up @@ -32,10 +33,10 @@ def __init__(
self,
site_id: str,
api_key: str,
host: str = None,
host: Optional[str] = None,
region: Region = Regions.US,
port: int = None,
url_prefix: str = None,
port: Optional[int] = None,
url_prefix: Optional[str] = None,
retries: int = 3,
timeout: int = 10,
):
Expand Down Expand Up @@ -273,8 +274,8 @@ async def send_request(
method: str,
url: str,
*,
json_payload: t.Dict[str, t.Any] = None,
headers: t.Dict[str, str] = None,
json_payload: Optional[t.Dict[str, t.Any]] = None,
headers: Optional[t.Dict[str, str]] = None,
auth: t.Optional[t.Tuple[str, str]] = None
) -> t.Union[dict]:
return await super().send_request(
Expand Down
7 changes: 6 additions & 1 deletion async_customerio/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import math
import typing as t
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import quote, urlencode, urljoin

from .errors import AsyncCustomerIOError
Expand Down Expand Up @@ -32,7 +33,11 @@ def stringify_list(customer_ids: t.List[t.Union[str, int]]) -> t.List[str]:


def join_url(
base: str, *parts: t.Union[str, int], params: dict = None, leading_slash: bool = False, trailing_slash: bool = False
base: str,
*parts: t.Union[str, int],
params: Optional[dict] = None,
leading_slash: bool = False,
trailing_slash: bool = False,
) -> str:
"""Construct a full ("absolute") URL by combining a "base URL" (base) with another URL (url) parts.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "async-customerio"
version = "1.0.0"
version = "1.1.0"
description = "Async CustomerIO Client - a Python client to interact with CustomerIO in an async fashion."
license = "MIT"
authors = [
Expand Down
27 changes: 27 additions & 0 deletions tests/test_request_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import pytest
from async_customerio import validate_signature

BODY = (
b'{"data":{"action_id":42,"campaign_id":23,"content":"Welcome to the club, we are with you.",'
b'"customer_id":"user-123","delivery_id":"RAECAAFwnUSneIa0ZXkmq8EdkAM==","headers":{"Custom-Header":["custom-value"]},'
b'"identifiers":{"id":"user-123"},"recipient":"[email protected]","subject":"Thanks for signing up"},'
b'"event_id":"01E2EMRMM6TZ12TF9WGZN0WJQT","metric":"sent","object_type":"email","timestamp":1692633432}'
)
X_CIO_SIGNATURE = "c097b83a7d57a0810625180a61213eab7e0389a54b33dd11c3a6f17790c8427a"
X_CIO_TIMESTAMP = 1692633432

@pytest.mark.parametrize("signature, body, x_cio_timestamp, expected", [
(X_CIO_SIGNATURE, BODY, X_CIO_TIMESTAMP, True),
(X_CIO_SIGNATURE, BODY, int(f'{X_CIO_TIMESTAMP + 1}'), False),
("WRONG" + X_CIO_SIGNATURE[5:], BODY, X_CIO_TIMESTAMP, False),
(X_CIO_SIGNATURE, b'{"malicious_key": "malicious_value"}', X_CIO_TIMESTAMP, False),
])
def test_validate_signature(signature, body, x_cio_timestamp, expected):
signing_key = '755781b5e03a973f3405a85474d5a032a60fd56fabaad66039b12eadd83955fa'
assert validate_signature(
signing_key=signing_key,
timestamp=x_cio_timestamp,
request_body=body,
signature=signature
) is expected

0 comments on commit 802b78d

Please sign in to comment.