requests-hardened 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- requests_hardened-1.0.0/LICENSE +28 -0
- requests_hardened-1.0.0/PKG-INFO +180 -0
- requests_hardened-1.0.0/README.rst +144 -0
- requests_hardened-1.0.0/pyproject.toml +82 -0
- requests_hardened-1.0.0/requests_hardened/__init__.py +3 -0
- requests_hardened-1.0.0/requests_hardened/client.py +51 -0
- requests_hardened-1.0.0/requests_hardened/config.py +27 -0
- requests_hardened-1.0.0/requests_hardened/ip_filter.py +80 -0
- requests_hardened-1.0.0/requests_hardened/ip_filter_adapter.py +76 -0
- requests_hardened-1.0.0/requests_hardened/manager.py +21 -0
- requests_hardened-1.0.0/requests_hardened/types.py +5 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023, Saleor Commerce
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: requests-hardened
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A library that overrides the default behaviors of the requests library, and adds new security features.
|
|
5
|
+
License: BSD-3-Clause
|
|
6
|
+
Author: Saleor Commerce
|
|
7
|
+
Author-email: hello@saleor.io
|
|
8
|
+
Requires-Python: >=3.9,<4.0
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
12
|
+
Classifier: Natural Language :: English
|
|
13
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Operating System :: POSIX :: BSD
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
26
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
27
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
28
|
+
Classifier: Topic :: Security
|
|
29
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
30
|
+
Project-URL: Changelog, https://github.com/saleor/requests-hardened/releases/
|
|
31
|
+
Project-URL: Homepage, https://github.com/saleor/requests-hardened/
|
|
32
|
+
Project-URL: Issues, https://github.com/saleor/requests-hardened/issues
|
|
33
|
+
Project-URL: Source, https://github.com/saleor/requests-hardened/
|
|
34
|
+
Description-Content-Type: text/x-rst
|
|
35
|
+
|
|
36
|
+
=================
|
|
37
|
+
requests-hardened
|
|
38
|
+
=================
|
|
39
|
+
|
|
40
|
+
|pypi-latest-version| |pypi-python-versions| |pypi-implementations|
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
``requests-hardened`` is a library that overrides the default behaviors of the ``requests``
|
|
44
|
+
library, and adds new security features.
|
|
45
|
+
|
|
46
|
+
Installation
|
|
47
|
+
============
|
|
48
|
+
|
|
49
|
+
The project is available on PyPI_:
|
|
50
|
+
|
|
51
|
+
.. code-block::
|
|
52
|
+
|
|
53
|
+
pip install requests-hardened
|
|
54
|
+
|
|
55
|
+
Features
|
|
56
|
+
========
|
|
57
|
+
|
|
58
|
+
- `SSRF Filters`_: blocks private and loopback IP ranges.
|
|
59
|
+
- HTTP Redirects: can be used safely alongside the SSRF filter feature.
|
|
60
|
+
- `Proxy Support`_: proxies can be used in combination with SSRF Filters for a defense in depth.
|
|
61
|
+
- Handy `Overrides of Defaults`_: allows to enforce secure defaults globally, such as to
|
|
62
|
+
mitigate DoS attacks.
|
|
63
|
+
|
|
64
|
+
Overrides of Defaults
|
|
65
|
+
---------------------
|
|
66
|
+
|
|
67
|
+
This library allows to override some default values from the ``requests`` library
|
|
68
|
+
that can have a security impact:
|
|
69
|
+
|
|
70
|
+
- ``Config.never_redirect = False`` always reject HTTP redirects
|
|
71
|
+
- ``Config.default_timeout = (2, 10)`` sets the default timeout value when no value or ``None`` is passed
|
|
72
|
+
- ``Config.user_agent_override = None`` optional config to override ``User-Agent`` header. When set to ``None``, ``requests`` library will set its `default user-agent <https://github.com/psf/requests/blob/ee93fac6b2f715151f1aa9a1a06ddba9f7dcc59a/src/requests/utils.py#L886-L892>`_.
|
|
73
|
+
|
|
74
|
+
SSRF Filters
|
|
75
|
+
------------
|
|
76
|
+
|
|
77
|
+
A SSRF IP filter can be used to reject HTTP(S) requests targeting private and loopback
|
|
78
|
+
IP addresses.
|
|
79
|
+
|
|
80
|
+
Settings:
|
|
81
|
+
|
|
82
|
+
- ``Config.ip_filter_enable`` whether or not to filter the IP addresses
|
|
83
|
+
- ``ip_filter_allow_loopback_ips`` whether or not to allow loopback IP addresses
|
|
84
|
+
|
|
85
|
+
Proxy Support
|
|
86
|
+
^^^^^^^^^^^^^
|
|
87
|
+
|
|
88
|
+
The SSRF IP filter's behavior with proxies are as follows:
|
|
89
|
+
|
|
90
|
+
- **Proxy's IP Address:** does not block private and loopback IP addresses (no filtering).
|
|
91
|
+
Instead, the filter assumes that the proxy URL is never tainted with untrusted
|
|
92
|
+
user input.
|
|
93
|
+
- **Target IP Address (Tunneled HTTP Requests):** by default, the tunneled requests are
|
|
94
|
+
filtered for potential SSRF attacks.
|
|
95
|
+
- **Protocols Supported:** SOCKS4, SOCKS5, HTTP, and HTTPS proxy server protocols are supported.
|
|
96
|
+
|
|
97
|
+
.. note::
|
|
98
|
+
|
|
99
|
+
We rely on the ``requests`` and ``urllib3`` thus the list may change over time.
|
|
100
|
+
|
|
101
|
+
.. warning::
|
|
102
|
+
|
|
103
|
+
For SOCKS4 and SOCKS5, you need to run ``pip install requests[socks]``
|
|
104
|
+
|
|
105
|
+
Example Usage:
|
|
106
|
+
|
|
107
|
+
.. code-block:: python
|
|
108
|
+
|
|
109
|
+
from requests_hardened import Config, Manager
|
|
110
|
+
|
|
111
|
+
http_manager = Manager(
|
|
112
|
+
Config(
|
|
113
|
+
default_timeout=(2, 10),
|
|
114
|
+
never_redirect=False,
|
|
115
|
+
# Enable SSRF IP filter
|
|
116
|
+
ip_filter_enable=True,
|
|
117
|
+
ip_filter_allow_loopback_ips=False,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# List of proxies
|
|
122
|
+
proxies = {
|
|
123
|
+
"https": "socks5://127.0.0.1:8888",
|
|
124
|
+
"http": "socks5://127.0.0.1:8888",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Sends the HTTP request using the proxy
|
|
128
|
+
resp = http_manager.send_request("GET", "https://example.com", proxies=proxies)
|
|
129
|
+
print(resp)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
.. note::
|
|
133
|
+
|
|
134
|
+
For more details on using proxies with the ``requests`` library, see the `official
|
|
135
|
+
documentation <https://docs.python-requests.org/en/latest/user/advanced/#proxies>`_.
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
Full Example
|
|
139
|
+
============
|
|
140
|
+
|
|
141
|
+
.. code-block:: python
|
|
142
|
+
|
|
143
|
+
from requests_hardened import Config, Manager
|
|
144
|
+
|
|
145
|
+
# Creates a global "manager" that can be used to create ``requests.Session``
|
|
146
|
+
# objects with hardening in place.
|
|
147
|
+
http_manager = Manager(
|
|
148
|
+
Config(
|
|
149
|
+
default_timeout=(2, 10),
|
|
150
|
+
never_redirect=False,
|
|
151
|
+
ip_filter_enable=True,
|
|
152
|
+
ip_filter_allow_loopback_ips=False,
|
|
153
|
+
user_agent_override=None
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Sends an HTTP request without re-using ``requests.Session``:
|
|
158
|
+
resp = http_manager.send_request("GET", "https://example.com")
|
|
159
|
+
print(resp)
|
|
160
|
+
|
|
161
|
+
# Sends HTTP requests with reusable ``requests.Session``:
|
|
162
|
+
with http_manager.get_session() as sess:
|
|
163
|
+
sess.request("GET", "https://example.com")
|
|
164
|
+
sess.request("POST", "https://example.com", json={"foo": "bar"})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
.. _PyPI: https://pypi.org/project/requests-hardened
|
|
168
|
+
|
|
169
|
+
.. |pypi-latest-version| image:: https://img.shields.io/pypi/v/requests-hardened.svg
|
|
170
|
+
:alt: Latest Version
|
|
171
|
+
:target: `PyPI`_
|
|
172
|
+
|
|
173
|
+
.. |pypi-python-versions| image:: https://img.shields.io/pypi/pyversions/requests-hardened.svg
|
|
174
|
+
:alt: Supported Python Versions
|
|
175
|
+
:target: `PyPI`_
|
|
176
|
+
|
|
177
|
+
.. |pypi-implementations| image:: https://img.shields.io/pypi/implementation/requests-hardened.svg
|
|
178
|
+
:alt: Supported Implementations
|
|
179
|
+
:target: `PyPI`_
|
|
180
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
=================
|
|
2
|
+
requests-hardened
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
|pypi-latest-version| |pypi-python-versions| |pypi-implementations|
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
``requests-hardened`` is a library that overrides the default behaviors of the ``requests``
|
|
9
|
+
library, and adds new security features.
|
|
10
|
+
|
|
11
|
+
Installation
|
|
12
|
+
============
|
|
13
|
+
|
|
14
|
+
The project is available on PyPI_:
|
|
15
|
+
|
|
16
|
+
.. code-block::
|
|
17
|
+
|
|
18
|
+
pip install requests-hardened
|
|
19
|
+
|
|
20
|
+
Features
|
|
21
|
+
========
|
|
22
|
+
|
|
23
|
+
- `SSRF Filters`_: blocks private and loopback IP ranges.
|
|
24
|
+
- HTTP Redirects: can be used safely alongside the SSRF filter feature.
|
|
25
|
+
- `Proxy Support`_: proxies can be used in combination with SSRF Filters for a defense in depth.
|
|
26
|
+
- Handy `Overrides of Defaults`_: allows to enforce secure defaults globally, such as to
|
|
27
|
+
mitigate DoS attacks.
|
|
28
|
+
|
|
29
|
+
Overrides of Defaults
|
|
30
|
+
---------------------
|
|
31
|
+
|
|
32
|
+
This library allows to override some default values from the ``requests`` library
|
|
33
|
+
that can have a security impact:
|
|
34
|
+
|
|
35
|
+
- ``Config.never_redirect = False`` always reject HTTP redirects
|
|
36
|
+
- ``Config.default_timeout = (2, 10)`` sets the default timeout value when no value or ``None`` is passed
|
|
37
|
+
- ``Config.user_agent_override = None`` optional config to override ``User-Agent`` header. When set to ``None``, ``requests`` library will set its `default user-agent <https://github.com/psf/requests/blob/ee93fac6b2f715151f1aa9a1a06ddba9f7dcc59a/src/requests/utils.py#L886-L892>`_.
|
|
38
|
+
|
|
39
|
+
SSRF Filters
|
|
40
|
+
------------
|
|
41
|
+
|
|
42
|
+
A SSRF IP filter can be used to reject HTTP(S) requests targeting private and loopback
|
|
43
|
+
IP addresses.
|
|
44
|
+
|
|
45
|
+
Settings:
|
|
46
|
+
|
|
47
|
+
- ``Config.ip_filter_enable`` whether or not to filter the IP addresses
|
|
48
|
+
- ``ip_filter_allow_loopback_ips`` whether or not to allow loopback IP addresses
|
|
49
|
+
|
|
50
|
+
Proxy Support
|
|
51
|
+
^^^^^^^^^^^^^
|
|
52
|
+
|
|
53
|
+
The SSRF IP filter's behavior with proxies are as follows:
|
|
54
|
+
|
|
55
|
+
- **Proxy's IP Address:** does not block private and loopback IP addresses (no filtering).
|
|
56
|
+
Instead, the filter assumes that the proxy URL is never tainted with untrusted
|
|
57
|
+
user input.
|
|
58
|
+
- **Target IP Address (Tunneled HTTP Requests):** by default, the tunneled requests are
|
|
59
|
+
filtered for potential SSRF attacks.
|
|
60
|
+
- **Protocols Supported:** SOCKS4, SOCKS5, HTTP, and HTTPS proxy server protocols are supported.
|
|
61
|
+
|
|
62
|
+
.. note::
|
|
63
|
+
|
|
64
|
+
We rely on the ``requests`` and ``urllib3`` thus the list may change over time.
|
|
65
|
+
|
|
66
|
+
.. warning::
|
|
67
|
+
|
|
68
|
+
For SOCKS4 and SOCKS5, you need to run ``pip install requests[socks]``
|
|
69
|
+
|
|
70
|
+
Example Usage:
|
|
71
|
+
|
|
72
|
+
.. code-block:: python
|
|
73
|
+
|
|
74
|
+
from requests_hardened import Config, Manager
|
|
75
|
+
|
|
76
|
+
http_manager = Manager(
|
|
77
|
+
Config(
|
|
78
|
+
default_timeout=(2, 10),
|
|
79
|
+
never_redirect=False,
|
|
80
|
+
# Enable SSRF IP filter
|
|
81
|
+
ip_filter_enable=True,
|
|
82
|
+
ip_filter_allow_loopback_ips=False,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# List of proxies
|
|
87
|
+
proxies = {
|
|
88
|
+
"https": "socks5://127.0.0.1:8888",
|
|
89
|
+
"http": "socks5://127.0.0.1:8888",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Sends the HTTP request using the proxy
|
|
93
|
+
resp = http_manager.send_request("GET", "https://example.com", proxies=proxies)
|
|
94
|
+
print(resp)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
.. note::
|
|
98
|
+
|
|
99
|
+
For more details on using proxies with the ``requests`` library, see the `official
|
|
100
|
+
documentation <https://docs.python-requests.org/en/latest/user/advanced/#proxies>`_.
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
Full Example
|
|
104
|
+
============
|
|
105
|
+
|
|
106
|
+
.. code-block:: python
|
|
107
|
+
|
|
108
|
+
from requests_hardened import Config, Manager
|
|
109
|
+
|
|
110
|
+
# Creates a global "manager" that can be used to create ``requests.Session``
|
|
111
|
+
# objects with hardening in place.
|
|
112
|
+
http_manager = Manager(
|
|
113
|
+
Config(
|
|
114
|
+
default_timeout=(2, 10),
|
|
115
|
+
never_redirect=False,
|
|
116
|
+
ip_filter_enable=True,
|
|
117
|
+
ip_filter_allow_loopback_ips=False,
|
|
118
|
+
user_agent_override=None
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Sends an HTTP request without re-using ``requests.Session``:
|
|
123
|
+
resp = http_manager.send_request("GET", "https://example.com")
|
|
124
|
+
print(resp)
|
|
125
|
+
|
|
126
|
+
# Sends HTTP requests with reusable ``requests.Session``:
|
|
127
|
+
with http_manager.get_session() as sess:
|
|
128
|
+
sess.request("GET", "https://example.com")
|
|
129
|
+
sess.request("POST", "https://example.com", json={"foo": "bar"})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
.. _PyPI: https://pypi.org/project/requests-hardened
|
|
133
|
+
|
|
134
|
+
.. |pypi-latest-version| image:: https://img.shields.io/pypi/v/requests-hardened.svg
|
|
135
|
+
:alt: Latest Version
|
|
136
|
+
:target: `PyPI`_
|
|
137
|
+
|
|
138
|
+
.. |pypi-python-versions| image:: https://img.shields.io/pypi/pyversions/requests-hardened.svg
|
|
139
|
+
:alt: Supported Python Versions
|
|
140
|
+
:target: `PyPI`_
|
|
141
|
+
|
|
142
|
+
.. |pypi-implementations| image:: https://img.shields.io/pypi/implementation/requests-hardened.svg
|
|
143
|
+
:alt: Supported Implementations
|
|
144
|
+
:target: `PyPI`_
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "poetry.core.masonry.api"
|
|
3
|
+
requires = ["poetry-core>=1.5.0"]
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "requests-hardened"
|
|
7
|
+
readme = "README.rst"
|
|
8
|
+
description = "A library that overrides the default behaviors of the requests library, and adds new security features."
|
|
9
|
+
authors = [
|
|
10
|
+
"Saleor Commerce <hello@saleor.io>"
|
|
11
|
+
]
|
|
12
|
+
license = "BSD-3-Clause"
|
|
13
|
+
version = "1.0.0"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: BSD License",
|
|
18
|
+
"Natural Language :: English",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Operating System :: POSIX",
|
|
21
|
+
"Operating System :: POSIX :: BSD",
|
|
22
|
+
"Operating System :: POSIX :: Linux",
|
|
23
|
+
"Operating System :: MacOS :: MacOS X",
|
|
24
|
+
'Operating System :: Microsoft :: Windows',
|
|
25
|
+
"Programming Language :: Python",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3.9",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
34
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.poetry.urls]
|
|
38
|
+
Homepage = "https://github.com/saleor/requests-hardened/"
|
|
39
|
+
Source = "https://github.com/saleor/requests-hardened/"
|
|
40
|
+
Issues = "https://github.com/saleor/requests-hardened/issues"
|
|
41
|
+
Changelog = "https://github.com/saleor/requests-hardened/releases/"
|
|
42
|
+
|
|
43
|
+
[tool.poetry.dependencies]
|
|
44
|
+
python = ">=3.9,<4.0"
|
|
45
|
+
# We require >=2.32.3 due to depending on `get_connection_with_tls_context`.
|
|
46
|
+
requests = ">=2.32.3,<3.0.0"
|
|
47
|
+
|
|
48
|
+
[tool.poetry.group.dev.dependencies]
|
|
49
|
+
pytest = "^7.4.0"
|
|
50
|
+
mypy = "^1.5.1"
|
|
51
|
+
types-requests = ">=2.32.0,<3.0.0"
|
|
52
|
+
trustme = ">=1.1.0,<2.0.0"
|
|
53
|
+
pytest-socket = "^0.7.0"
|
|
54
|
+
pproxy = "^2.7.9"
|
|
55
|
+
requests = { version = ">=2.32.3,<3.0.0" , extras = ["socks"] }
|
|
56
|
+
|
|
57
|
+
[tool.setuptools]
|
|
58
|
+
zip-safe = false
|
|
59
|
+
packages = ["requests_hardened"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
# Disallow creating sockets (using 'pytest-socket' library)
|
|
63
|
+
# We block sockets during tests in order to detect unexpected
|
|
64
|
+
# leaks/connections being made.
|
|
65
|
+
addopts = "--disable-socket"
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
# Checks
|
|
69
|
+
check_untyped_defs = true
|
|
70
|
+
ignore_missing_imports = true
|
|
71
|
+
allow_redefinition = true
|
|
72
|
+
|
|
73
|
+
# Error messages
|
|
74
|
+
pretty = true
|
|
75
|
+
show_column_numbers = true
|
|
76
|
+
show_error_codes = true
|
|
77
|
+
show_error_context = true
|
|
78
|
+
show_traceback = true
|
|
79
|
+
|
|
80
|
+
exclude = [
|
|
81
|
+
"tests/"
|
|
82
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Union, Any
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from requests import Request, PreparedRequest, Response
|
|
5
|
+
|
|
6
|
+
from requests_hardened.config import Config
|
|
7
|
+
from requests_hardened.ip_filter_adapter import IPFilterAdapter
|
|
8
|
+
|
|
9
|
+
T_TIMEOUT = Union[int, float]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HTTPSession(requests.Session):
|
|
13
|
+
def __init__(self, config: Config, **kwargs):
|
|
14
|
+
super().__init__(**kwargs)
|
|
15
|
+
|
|
16
|
+
if config.ip_filter_enable is True:
|
|
17
|
+
self.mount(
|
|
18
|
+
"http://",
|
|
19
|
+
IPFilterAdapter(
|
|
20
|
+
is_https_proto=False, # We use http:// (insecure)
|
|
21
|
+
allow_loopback=config.ip_filter_allow_loopback_ips,
|
|
22
|
+
tls_sni_support=config.ip_filter_tls_sni_support,
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
self.mount(
|
|
26
|
+
"https://",
|
|
27
|
+
IPFilterAdapter(
|
|
28
|
+
is_https_proto=True, # We use https:// (SSL/TLS)
|
|
29
|
+
allow_loopback=config.ip_filter_allow_loopback_ips,
|
|
30
|
+
tls_sni_support=config.ip_filter_tls_sni_support,
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
self._config = config
|
|
35
|
+
|
|
36
|
+
def send(self, request: PreparedRequest, **kwargs: Any) -> Response:
|
|
37
|
+
allow_redirects = kwargs.setdefault(
|
|
38
|
+
"allow_redirects", not self._config.never_redirect
|
|
39
|
+
)
|
|
40
|
+
if allow_redirects and self._config.never_redirect:
|
|
41
|
+
kwargs["allow_redirects"] = False
|
|
42
|
+
|
|
43
|
+
timeout = kwargs.setdefault("timeout", self._config.default_timeout)
|
|
44
|
+
if not timeout:
|
|
45
|
+
kwargs["timeout"] = self._config.default_timeout
|
|
46
|
+
return super().send(request, **kwargs)
|
|
47
|
+
|
|
48
|
+
def prepare_request(self, request: Request) -> PreparedRequest:
|
|
49
|
+
if self._config.user_agent_override is not None:
|
|
50
|
+
request.headers.update({"User-Agent": self._config.user_agent_override})
|
|
51
|
+
return super().prepare_request(request)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from requests_hardened.types import T_TIMEOUT_TUPLE
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
# If ``True``, then any private and loopback IP will be rejected.
|
|
10
|
+
ip_filter_enable: bool
|
|
11
|
+
|
|
12
|
+
# If ``True``, then loopback IPs are allowed
|
|
13
|
+
# when ``ip_filter_enable`` is set to ``True``.
|
|
14
|
+
ip_filter_allow_loopback_ips: bool
|
|
15
|
+
|
|
16
|
+
# If True, then HTTP redirects are never allowed.
|
|
17
|
+
never_redirect: bool
|
|
18
|
+
|
|
19
|
+
# The default timeout value to set to all requests.
|
|
20
|
+
default_timeout: Optional[T_TIMEOUT_TUPLE]
|
|
21
|
+
|
|
22
|
+
# Override the default User-Agent header of the requests library.
|
|
23
|
+
user_agent_override: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
# Whether to enable support for TLS SNI for IP filtering.
|
|
26
|
+
# Do not disable this option unless you are absolutely sure of what you are doing!
|
|
27
|
+
ip_filter_tls_sni_support: bool = True
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import logging
|
|
3
|
+
import socket
|
|
4
|
+
from typing import Tuple, Union
|
|
5
|
+
|
|
6
|
+
import requests.exceptions
|
|
7
|
+
from urllib3.util.connection import ( # type: ignore[attr-defined] # `allowed_gai_family` exists. # noqa: E501
|
|
8
|
+
allowed_gai_family,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidIPAddress(requests.RequestException):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_ip_address(
|
|
19
|
+
hostname: str, port: int, allow_loopback: bool
|
|
20
|
+
) -> Tuple[Union[ipaddress.IPv4Address, ipaddress.IPv6Address], int]:
|
|
21
|
+
# Development safeguard, hostname shouldn't be surrounded by brackets
|
|
22
|
+
# when calling this function.
|
|
23
|
+
assert not hostname.startswith("[")
|
|
24
|
+
|
|
25
|
+
addresses = socket.getaddrinfo(
|
|
26
|
+
hostname, port, family=allowed_gai_family(), type=socket.SOCK_STREAM
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
af, socktype, proto, _canonname, socket_address = addresses[0]
|
|
30
|
+
|
|
31
|
+
if af != socket.AF_INET and af != socket.AF_INET6:
|
|
32
|
+
# This code shouldn't be reachable.
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"Only AF_INET and AF_INET6 socket address families are supported"
|
|
35
|
+
)
|
|
36
|
+
port = socket_address[1]
|
|
37
|
+
ip = ipaddress.ip_address(socket_address[0])
|
|
38
|
+
|
|
39
|
+
# Python considers special IP ranges representing IPv4 addresses as being
|
|
40
|
+
# private ranges, such as ::ffff:0:0/96, ::/128.
|
|
41
|
+
# Because of that, we need to check the IPv4 address instead of the IPv6 one.
|
|
42
|
+
# https://github.com/python/cpython/commit/ed391090cc8332406e6225d40877db6ff44a7104
|
|
43
|
+
if ip.version == 6 and (ipv4 := ip.ipv4_mapped) is not None:
|
|
44
|
+
ip = ipv4
|
|
45
|
+
|
|
46
|
+
if allow_loopback and ip.is_loopback:
|
|
47
|
+
return ip, port
|
|
48
|
+
elif ip.is_private:
|
|
49
|
+
logger.warning(
|
|
50
|
+
"Forbidden IP address: %s for hostname %s",
|
|
51
|
+
ip,
|
|
52
|
+
hostname,
|
|
53
|
+
# Extra arguments may clash with logger configuration
|
|
54
|
+
# if it injects extra JSON fields, such as:
|
|
55
|
+
# https://github.com/saleor/saleor/blob/5e7c57dad9e64b09477ebdcee53f0277359bc598/saleor/core/logging.py#L13
|
|
56
|
+
# Because of that, we prefix the extra arguments in order to
|
|
57
|
+
# reduce the chance of clashing with logging configs.
|
|
58
|
+
extra={"dst_ip": ip, "dst_hostname": hostname},
|
|
59
|
+
)
|
|
60
|
+
raise InvalidIPAddress(ip)
|
|
61
|
+
return ip, port
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def filter_host(
|
|
65
|
+
hostname: str, port: int, *, allow_loopback: bool
|
|
66
|
+
) -> str:
|
|
67
|
+
if not hostname:
|
|
68
|
+
raise requests.exceptions.InvalidURL("Invalid URL: missing hostname")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
ip_addr, port = get_ip_address(
|
|
72
|
+
hostname, port, allow_loopback=allow_loopback
|
|
73
|
+
)
|
|
74
|
+
except socket.gaierror as exc:
|
|
75
|
+
raise requests.ConnectionError("Failed to resolve domain") from exc
|
|
76
|
+
except socket.timeout as exc:
|
|
77
|
+
raise requests.ConnectTimeout("Failed to connect to host") from exc
|
|
78
|
+
|
|
79
|
+
ip_addr_str = str(ip_addr) if ip_addr.version != 6 else f"[{ip_addr}]"
|
|
80
|
+
return ip_addr_str
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from requests.adapters import HTTPAdapter
|
|
2
|
+
from requests_hardened.ip_filter import filter_host
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IPFilterAdapter(HTTPAdapter):
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
is_https_proto: bool,
|
|
9
|
+
allow_loopback: bool = False,
|
|
10
|
+
tls_sni_support: bool = True,
|
|
11
|
+
):
|
|
12
|
+
"""
|
|
13
|
+
:param is_https_proto: whether it is HTTPS or insecure HTTP.
|
|
14
|
+
:param allow_loopback: whether to allow loopback IP addresses in IP filtering.
|
|
15
|
+
:param tls_sni_support: whether to add support for TLS SNI (enabled by default,
|
|
16
|
+
and shouldn't be changed unless you are certain).
|
|
17
|
+
"""
|
|
18
|
+
super().__init__()
|
|
19
|
+
self._is_https_proto = is_https_proto
|
|
20
|
+
self._allow_loopback = allow_loopback
|
|
21
|
+
self._tls_sni_support = tls_sni_support
|
|
22
|
+
|
|
23
|
+
def build_connection_pool_key_attributes(self, request, verify, cert=None):
|
|
24
|
+
host_params, pool_kwargs = super().build_connection_pool_key_attributes(
|
|
25
|
+
request, verify, cert
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Copy headers before mutating them as they may be a global variable used by
|
|
29
|
+
# subsequent requests.
|
|
30
|
+
# e.g., https://github.com/lepture/authlib/blob/a7d68b4c3b8a3a7fe0b62943b5228669f2f3dfec/authlib/oauth2/client.py#L205-L206
|
|
31
|
+
if request.headers:
|
|
32
|
+
request.headers = dict(**request.headers)
|
|
33
|
+
else:
|
|
34
|
+
request.headers = {}
|
|
35
|
+
|
|
36
|
+
# Adds the original URL hostname as the 'Host' header.
|
|
37
|
+
original_host = request.headers["Host"] = host_params["host"]
|
|
38
|
+
|
|
39
|
+
# Set the original hostname for certificate validation otherwise
|
|
40
|
+
# urllib3 will try to match the pinned resolved IP address against the
|
|
41
|
+
# certificate.
|
|
42
|
+
#
|
|
43
|
+
# Note: assert_hostname and server_hostname cannot be passed
|
|
44
|
+
# when using insecure HTTP (http://) as it's not a valid argument for
|
|
45
|
+
# `urllib3.connectionpool.HTTPConnectionPool`.
|
|
46
|
+
if self._is_https_proto is True:
|
|
47
|
+
# For non-TLS SNI servers.
|
|
48
|
+
pool_kwargs["assert_hostname"] = original_host # type: ignore[typeddict-unknown-key] # Valid parameter for urllib3.connectionpool.HTTPSConnectionPool
|
|
49
|
+
|
|
50
|
+
# Support TLS servers with SNI callbacks.
|
|
51
|
+
if self._tls_sni_support is True:
|
|
52
|
+
pool_kwargs["server_hostname"] = original_host # type: ignore[typeddict-unknown-key] # Valid parameter for urllib3.connectionpool.HTTPSConnectionPool
|
|
53
|
+
|
|
54
|
+
# Override the connection hostname to the resolved IP address,
|
|
55
|
+
# and reject if it's a private IP.
|
|
56
|
+
host_params["host"] = filter_host(
|
|
57
|
+
original_host,
|
|
58
|
+
host_params["port"],
|
|
59
|
+
allow_loopback=self._allow_loopback,
|
|
60
|
+
)
|
|
61
|
+
return host_params, pool_kwargs
|
|
62
|
+
|
|
63
|
+
def get_connection(self, url, proxies=None):
|
|
64
|
+
# Note: we do not support this method due to being deprecated since May 2024.
|
|
65
|
+
# Only `get_connection_with_tls_context` is supported by our package,
|
|
66
|
+
# due to the deprecated `get_connection` being largely different
|
|
67
|
+
# and is unlikely to still be used by other packages.
|
|
68
|
+
# Additional references:
|
|
69
|
+
# - https://github.com/psf/requests/pull/6655
|
|
70
|
+
# - https://github.com/psf/requests/pull/6710
|
|
71
|
+
# - https://github.com/advisories/GHSA-9wx4-h78v-vm56
|
|
72
|
+
raise NotImplementedError(
|
|
73
|
+
"get_connection is not supported in requests-hardened>=v1.0.0b5\n"
|
|
74
|
+
"Upgrade your 'requests' package to >=2.32.2 and your dependencies "
|
|
75
|
+
"if they rely on `requests.adapters.HTTPAdapter.get_connection`."
|
|
76
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from copy import copy
|
|
2
|
+
|
|
3
|
+
from requests_hardened import HTTPSession
|
|
4
|
+
from requests_hardened.config import Config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Manager:
|
|
8
|
+
__slots__ = ("config",)
|
|
9
|
+
|
|
10
|
+
def __init__(self, config: Config):
|
|
11
|
+
self.config = config
|
|
12
|
+
|
|
13
|
+
def clone(self):
|
|
14
|
+
return Manager(config=copy(self.config))
|
|
15
|
+
|
|
16
|
+
def get_session(self):
|
|
17
|
+
return HTTPSession(self.config)
|
|
18
|
+
|
|
19
|
+
def send_request(self, method: str, url: str, **kwargs):
|
|
20
|
+
with self.get_session() as sess:
|
|
21
|
+
return sess.request(method, url, **kwargs)
|