Repository: pynamodb/PynamoDB (Python)

Issue:
Bug v6.0.0: `extra_headers` included into signature causing `InvalidSignatureException`
## Issue: 
```
pynamodb.exceptions.GetError: Failed to get item: An error occurred (InvalidSignatureException) on request (<...>) on table (...) when calling the GetItem operation: The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.
```
when `extra_headers` configured to have headers used and striped by a proxy

## Potential cause
Per documentation https://pynamodb.readthedocs.io/en/stable/settings.html#extra-headers

> A dictionary of headers that should be added to every request. This is only useful when interfacing with DynamoDB through a proxy, where headers are stripped by the proxy before forwarding along. Failure to strip these headers before sending to AWS will result in an InvalidSignatureException due to request signing.


https://github.com/pynamodb/PynamoDB/blob/d61d906d1d5b35c4060f48af237a93b007163574/pynamodb/connection/base.py#L334-L337

@raulmyocu found that request is signed after `extra_headers` are added in v6.0.0, in older version (v5.5.1 where error is not observed), requests are signed before `extra_headers` being added.

https://github.com/pynamodb/PynamoDB/blob/7b60000164696fa2c668bdd74e3344fa2e9a6970/pynamodb/connection/base.py#L324-L329

Related: #1079 


Discussion:

Patch:
diff --git a/pynamodb/connection/base.py b/pynamodb/connection/base.py
index d0181a81..0b70cc1f 100644
--- a/pynamodb/connection/base.py
+++ b/pynamodb/connection/base.py
@@ -331,10 +331,9 @@ def send_pre_boto_callback(self, operation_name, req_uuid, table_name):
         except Exception:
             log.exception("pre_boto callback threw an exception.")
 
-    def _before_sign(self, request, **_) -> None:
+    def _before_send(self, request, **_) -> None:
         if self._extra_headers is not None:
-            for k, v in self._extra_headers.items():
-                request.headers.add_header(k, v)
+            request.headers.update(self._extra_headers)
 
     def _make_api_call(self, operation_name: str, operation_kwargs: Dict) -> Dict:
         try:
@@ -412,7 +411,7 @@ def client(self) -> BotocoreBaseClientPrivate:
             )
             self._client = cast(BotocoreBaseClientPrivate, self.session.create_client(SERVICE_NAME, self.region, endpoint_url=self.host, config=config))
 
-            self._client.meta.events.register_first('before-sign.*.*', self._before_sign)
+            self._client.meta.events.register_first('before-send.*.*', self._before_send)
         return self._client
 
     def add_meta_table(self, meta_table: MetaTable) -> None:
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 05660865..f0da32c3 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,6 +1,7 @@
 pytest>=6
 pytest-env
 pytest-mock
+freezegun
 
 # only used in CI
 coveralls


Test patch:
diff --git a/tests/test_base_connection.py b/tests/test_base_connection.py
index 7e5f5223..23112759 100644
--- a/tests/test_base_connection.py
+++ b/tests/test_base_connection.py
@@ -3,6 +3,9 @@
 """
 import base64
 import json
+from datetime import datetime
+from uuid import UUID
+
 import urllib3
 from unittest import mock
 from unittest.mock import patch
@@ -13,6 +16,7 @@
 from botocore.exceptions import BotoCoreError
 
 import pytest
+from freezegun import freeze_time
 
 from pynamodb.connection import Connection
 from pynamodb.connection.base import MetaTable
@@ -1515,11 +1519,13 @@ def test_connection__botocore_config():
     assert c.client._client_config.max_pool_connections == 20
 
 
-@mock.patch('botocore.httpsession.URLLib3Session.send')
-def test_connection_make_api_call___extra_headers(send_mock):
+@freeze_time()
+def test_connection_make_api_call___extra_headers(mocker):
     good_response = mock.Mock(spec=AWSResponse, status_code=200, headers={}, text='{}', content=b'{}')
+    send_mock = mocker.patch('botocore.httpsession.URLLib3Session.send', return_value=good_response)
 
-    send_mock.return_value = good_response
+    # return constant UUID
+    mocker.patch('uuid.uuid4', return_value=UUID('01FC4BDB-B223-4B86-88F4-DEE79B77F275'))
 
     c = Connection(extra_headers={'foo': 'bar'}, max_retry_attempts=0)
     c._make_api_call(
@@ -1529,7 +1535,18 @@ def test_connection_make_api_call___extra_headers(send_mock):
 
     assert send_mock.call_count == 1
     request = send_mock.call_args[0][0]
-    assert request.headers.get('foo').decode() == 'bar'
+    assert request.headers['foo'] == 'bar'
+
+    c = Connection(extra_headers={'foo': 'baz'}, max_retry_attempts=0)
+    c._make_api_call(
+        'DescribeTable',
+        {'TableName': 'MyTable'},
+    )
+
+    assert send_mock.call_count == 2
+    request2 = send_mock.call_args[0][0]
+    # all headers, including signatures, and except 'foo', should match
+    assert {**request.headers, 'foo': ''} == {**request2.headers, 'foo': ''}
 
 
 @mock.patch('botocore.httpsession.URLLib3Session.send')