python-proxy-headers 0.2.1__tar.gz → 0.2.3__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.
Files changed (18) hide show
  1. {python_proxy_headers-0.2.1/python_proxy_headers.egg-info → python_proxy_headers-0.2.3}/PKG-INFO +1 -1
  2. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/pyproject.toml +1 -1
  3. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/aiohttp_proxy.py +10 -1
  4. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/autoscraper_proxy.py +3 -2
  5. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/cloudscraper_proxy.py +9 -3
  6. python_proxy_headers-0.2.3/python_proxy_headers/header_utils.py +165 -0
  7. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/httpx_proxy.py +17 -4
  8. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/pycurl_proxy.py +4 -1
  9. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/requests_adapter.py +7 -1
  10. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/urllib3_proxy_manager.py +30 -1
  11. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3/python_proxy_headers.egg-info}/PKG-INFO +1 -1
  12. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers.egg-info/SOURCES.txt +1 -0
  13. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/LICENSE +0 -0
  14. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/README.md +0 -0
  15. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers/__init__.py +0 -0
  16. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers.egg-info/dependency_links.txt +0 -0
  17. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/python_proxy_headers.egg-info/top_level.txt +0 -0
  18. {python_proxy_headers-0.2.1 → python_proxy_headers-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-proxy-headers
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Handle custom proxy headers for http & https requests in various python libraries
5
5
  Author-email: ProxyMesh <support@proxymesh.com>
6
6
  Project-URL: Homepage, https://github.com/proxymesh/python-proxy-headers
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-proxy-headers"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  authors = [
9
9
  { name="ProxyMesh", email="support@proxymesh.com" },
10
10
  ]
@@ -6,6 +6,7 @@ from aiohttp.client import ClientSession
6
6
  from aiohttp.helpers import reify
7
7
  from aiohttp import hdrs
8
8
  from multidict import CIMultiDict, CIMultiDictProxy
9
+ from .header_utils import merge_proxy_response_headers
9
10
 
10
11
  class ProxyTCPConnector(TCPConnector):
11
12
  async def _create_proxy_connection(self, req: ClientRequest, traces, timeout):
@@ -143,13 +144,21 @@ class ProxyClientRequest(ClientRequest):
143
144
  return resp
144
145
 
145
146
  class ProxyClientResponse(ClientResponse):
147
+ @reify
148
+ def proxy_headers(self):
149
+ """Headers from the proxy CONNECT response, not merged into origin headers."""
150
+ raw = getattr(self, "_proxy_headers", None)
151
+ if raw:
152
+ return CIMultiDictProxy(CIMultiDict(raw))
153
+ return CIMultiDictProxy(CIMultiDict())
154
+
146
155
  @reify
147
156
  def headers(self):
148
157
  proxy_headers = getattr(self, '_proxy_headers', None)
149
158
 
150
159
  if proxy_headers:
151
160
  headers = CIMultiDict(self._headers)
152
- headers.extend(proxy_headers)
161
+ merge_proxy_response_headers(headers, proxy_headers)
153
162
  return CIMultiDictProxy(headers)
154
163
  else:
155
164
  return self._headers
@@ -35,6 +35,7 @@ except ImportError:
35
35
  "Install it with: pip install autoscraper"
36
36
  )
37
37
 
38
+ from .header_utils import validate_headers
38
39
  from .requests_adapter import ProxySession
39
40
 
40
41
 
@@ -71,7 +72,7 @@ class ProxyAutoScraper(AutoScraper):
71
72
  stack_list: Optional[List] = None
72
73
  ):
73
74
  super().__init__(stack_list=stack_list)
74
- self._proxy_headers = proxy_headers or {}
75
+ self._proxy_headers = validate_headers(proxy_headers)
75
76
  self._session: Optional[ProxySession] = None
76
77
 
77
78
  def _get_session(self) -> ProxySession:
@@ -90,7 +91,7 @@ class ProxyAutoScraper(AutoScraper):
90
91
  Args:
91
92
  proxy_headers: New proxy headers to use
92
93
  """
93
- self._proxy_headers = proxy_headers
94
+ self._proxy_headers = validate_headers(proxy_headers)
94
95
  if self._session is not None:
95
96
  self._session.close()
96
97
  self._session = None
@@ -27,6 +27,7 @@ except ImportError:
27
27
  "Install it with: pip install cloudscraper"
28
28
  )
29
29
 
30
+ from .header_utils import validate_headers
30
31
  from .urllib3_proxy_manager import proxy_from_url
31
32
 
32
33
 
@@ -41,8 +42,13 @@ class CipherSuiteProxyHeaderAdapter(CipherSuiteAdapter):
41
42
  """
42
43
 
43
44
  def __init__(self, proxy_headers: Optional[Dict[str, str]] = None, **kwargs):
44
- self._proxy_headers = proxy_headers or {}
45
+ self._proxy_headers = validate_headers(proxy_headers)
45
46
  super().__init__(**kwargs)
47
+
48
+ def build_response(self, req, resp):
49
+ response = super().build_response(req, resp)
50
+ response.proxy_headers = getattr(resp, "proxy_headers", {}) or {}
51
+ return response
46
52
 
47
53
  def proxy_manager_for(self, proxy, **proxy_kwargs):
48
54
  """
@@ -103,7 +109,7 @@ class ProxyCloudScraper(cloudscraper.CloudScraper):
103
109
  """
104
110
 
105
111
  def __init__(self, proxy_headers: Optional[Dict[str, str]] = None, **kwargs):
106
- self._proxy_headers = proxy_headers or {}
112
+ self._proxy_headers = validate_headers(proxy_headers)
107
113
 
108
114
  # Call parent init
109
115
  super().__init__(**kwargs)
@@ -142,7 +148,7 @@ class ProxyCloudScraper(cloudscraper.CloudScraper):
142
148
  Args:
143
149
  proxy_headers: New proxy headers to use
144
150
  """
145
- self._proxy_headers = proxy_headers
151
+ self._proxy_headers = validate_headers(proxy_headers)
146
152
 
147
153
  # Remount adapters with new headers
148
154
  self.mount(
@@ -0,0 +1,165 @@
1
+ """Validation and safe merging for proxy CONNECT headers.
2
+
3
+ CONNECT requests are raw HTTP and must not interpolate CR, LF, or NUL.
4
+ CONNECT response headers are not origin HTTPS headers: hop-by-hop and
5
+ security-sensitive names are not copied onto the origin response, and
6
+ existing origin headers are never overwritten.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple, Union
12
+
13
+ RawHeaders = Union[Mapping[Any, Any], Sequence[Tuple[Any, Any]], None]
14
+
15
+ # Never copy these from a CONNECT response onto the origin response.
16
+ BLOCKED_PROXY_RESPONSE_HEADERS = frozenset({
17
+ "age",
18
+ "authorization",
19
+ "cache-control",
20
+ "clear-site-data",
21
+ "connection",
22
+ "content-disposition",
23
+ "content-encoding",
24
+ "content-language",
25
+ "content-length",
26
+ "content-location",
27
+ "content-range",
28
+ "content-security-policy",
29
+ "content-security-policy-report-only",
30
+ "content-type",
31
+ "cookie",
32
+ "date",
33
+ "etag",
34
+ "expires",
35
+ "host",
36
+ "keep-alive",
37
+ "last-modified",
38
+ "link",
39
+ "location",
40
+ "pragma",
41
+ "proxy-agent",
42
+ "proxy-authenticate",
43
+ "proxy-authorization",
44
+ "proxy-connection",
45
+ "refresh",
46
+ "server",
47
+ "set-cookie",
48
+ "set-cookie2",
49
+ "strict-transport-security",
50
+ "te",
51
+ "trailer",
52
+ "transfer-encoding",
53
+ "upgrade",
54
+ "vary",
55
+ "via",
56
+ "warning",
57
+ "www-authenticate",
58
+ "x-content-type-options",
59
+ "x-frame-options",
60
+ "x-xss-protection",
61
+ })
62
+
63
+
64
+ def _as_str(value: Any) -> str:
65
+ if isinstance(value, bytes):
66
+ return value.decode("latin-1")
67
+ return str(value)
68
+
69
+
70
+ def validate_header_name(name: Any) -> str:
71
+ """Return a header name, or raise ValueError if it is not a single token."""
72
+ name_s = _as_str(name)
73
+ if not name_s:
74
+ raise ValueError("Header name must not be empty")
75
+ for char in name_s:
76
+ code = ord(char)
77
+ if char in "()<>@,;:\\\"/[]?={} \t:" or code <= 32 or code == 127:
78
+ raise ValueError(f"Invalid header name {name_s!r}")
79
+ return name_s
80
+
81
+
82
+ def validate_header_value(value: Any) -> str:
83
+ """Return a header value, or raise ValueError if it contains CR, LF, or NUL."""
84
+ value_s = _as_str(value)
85
+ if any(char in value_s for char in "\r\n\x00"):
86
+ raise ValueError(f"Invalid header value {value_s!r}")
87
+ return value_s
88
+
89
+
90
+ def validate_headers(headers: Optional[Mapping[Any, Any]]) -> Dict[str, str]:
91
+ """Validate a mapping of CONNECT request headers.
92
+
93
+ Raises:
94
+ ValueError: If any name or value contains CR, LF, NUL, or other
95
+ characters illegal in a single CONNECT header line.
96
+ """
97
+ if not headers:
98
+ return {}
99
+ validated: Dict[str, str] = {}
100
+ for name, value in headers.items():
101
+ validated[validate_header_name(name)] = validate_header_value(value)
102
+ return validated
103
+
104
+
105
+ def _header_items(headers: RawHeaders) -> List[Tuple[Any, Any]]:
106
+ if not headers:
107
+ return []
108
+ if isinstance(headers, (list, tuple)):
109
+ return list(headers)
110
+ if hasattr(headers, "items"):
111
+ return list(headers.items())
112
+ return []
113
+
114
+
115
+ def origin_has_header(origin_headers: RawHeaders, name: str) -> bool:
116
+ """Return True if origin_headers already contains ``name`` (case-insensitive)."""
117
+ lowered = _as_str(name).lower()
118
+ for key, _value in _header_items(origin_headers):
119
+ if _as_str(key).lower() == lowered:
120
+ return True
121
+ return False
122
+
123
+
124
+ def is_mergeable_proxy_header(name: Any) -> bool:
125
+ """Return True if a CONNECT response header may be copied onto origin headers."""
126
+ lowered = _as_str(name).lower()
127
+ if lowered in BLOCKED_PROXY_RESPONSE_HEADERS:
128
+ return False
129
+ if lowered.startswith("access-control-"):
130
+ return False
131
+ return True
132
+
133
+
134
+ def snapshot_headers(headers: RawHeaders) -> Dict[str, str]:
135
+ """Copy headers into a plain dict of strings."""
136
+ snapshot: Dict[str, str] = {}
137
+ for name, value in _header_items(headers):
138
+ snapshot[_as_str(name)] = _as_str(value)
139
+ return snapshot
140
+
141
+
142
+ def merge_proxy_response_headers(
143
+ origin_headers: MutableMapping[Any, Any],
144
+ proxy_headers: RawHeaders,
145
+ ) -> None:
146
+ """Copy safe CONNECT headers onto origin headers without overwriting."""
147
+ for name, value in _header_items(proxy_headers):
148
+ name_s = _as_str(name)
149
+ if not is_mergeable_proxy_header(name_s):
150
+ continue
151
+ if origin_has_header(origin_headers, name_s):
152
+ continue
153
+ origin_headers[name_s] = _as_str(value)
154
+
155
+
156
+ def filter_connect_headers(
157
+ origin_headers: RawHeaders,
158
+ connect_headers: RawHeaders,
159
+ ) -> List[Tuple[Any, Any]]:
160
+ """Return CONNECT header pairs that are safe to merge into origin headers."""
161
+ extra: List[Tuple[Any, Any]] = []
162
+ for name, value in _header_items(connect_headers):
163
+ if is_mergeable_proxy_header(name) and not origin_has_header(origin_headers, name):
164
+ extra.append((name, value))
165
+ return extra
@@ -9,6 +9,7 @@ from httpcore._ssl import default_ssl_context
9
9
  from httpcore._trace import Trace
10
10
  from httpx import AsyncHTTPTransport, HTTPTransport, Client
11
11
  from httpx._config import DEFAULT_LIMITS, DEFAULT_TIMEOUT_CONFIG, Proxy, create_ssl_context
12
+ from .header_utils import filter_connect_headers
12
13
 
13
14
  class ProxyTunnelHTTPConnection(TunnelHTTPConnection):
14
15
  # Unfortunately the only way to get connect_response.headers into the Response
@@ -90,10 +91,16 @@ class ProxyTunnelHTTPConnection(TunnelHTTPConnection):
90
91
  keepalive_expiry=self._keepalive_expiry,
91
92
  )
92
93
 
94
+ self._connect_response_headers = connect_response.headers
93
95
  self._connected = True
94
- # this is the only modification
96
+ # Merge safe CONNECT headers without overwriting origin values
95
97
  response = self._connection.handle_request(request)
96
- response.headers = merge_headers(response.headers, connect_response.headers)
98
+ response.headers = merge_headers(
99
+ response.headers,
100
+ filter_connect_headers(
101
+ response.headers, getattr(self, "_connect_response_headers", ())
102
+ ),
103
+ )
97
104
  return response
98
105
 
99
106
  class AsyncProxyTunnelHTTPConnection(AsyncTunnelHTTPConnection):
@@ -174,10 +181,16 @@ class AsyncProxyTunnelHTTPConnection(AsyncTunnelHTTPConnection):
174
181
  keepalive_expiry=self._keepalive_expiry,
175
182
  )
176
183
 
184
+ self._connect_response_headers = connect_response.headers
177
185
  self._connected = True
178
- # this is the only modification
186
+ # Merge safe CONNECT headers without overwriting origin values
179
187
  response = await self._connection.handle_async_request(request)
180
- response.headers = merge_headers(response.headers, connect_response.headers)
188
+ response.headers = merge_headers(
189
+ response.headers,
190
+ filter_connect_headers(
191
+ response.headers, getattr(self, "_connect_response_headers", ())
192
+ ),
193
+ )
181
194
  return response
182
195
 
183
196
  class HTTPProxyHeaders(HTTPProxy):
@@ -38,6 +38,8 @@ from io import BytesIO
38
38
  from dataclasses import dataclass, field
39
39
  from typing import Dict, List, Optional, Tuple
40
40
 
41
+ from .header_utils import validate_headers
42
+
41
43
  try:
42
44
  import pycurl
43
45
  except ImportError:
@@ -67,7 +69,8 @@ def set_proxy_headers(curl, headers: Dict[str, str]) -> None:
67
69
  """
68
70
  if not headers:
69
71
  return
70
-
72
+
73
+ headers = validate_headers(headers)
71
74
  header_list = [f"{k}: {v}" for k, v in headers.items()]
72
75
 
73
76
  # Set CURLOPT_PROXYHEADER
@@ -1,11 +1,17 @@
1
1
  from requests.adapters import HTTPAdapter
2
2
  from requests.sessions import Session
3
+ from .header_utils import validate_headers
3
4
  from .urllib3_proxy_manager import proxy_from_url
4
5
 
5
6
  class HTTPProxyHeaderAdapter(HTTPAdapter):
6
7
  def __init__(self, proxy_headers=None):
7
8
  super().__init__()
8
- self._proxy_headers = proxy_headers or {}
9
+ self._proxy_headers = validate_headers(proxy_headers)
10
+
11
+ def build_response(self, req, resp):
12
+ response = super().build_response(req, resp)
13
+ response.proxy_headers = getattr(resp, "proxy_headers", {}) or {}
14
+ return response
9
15
 
10
16
  def proxy_manager_for(self, proxy, **proxy_kwargs):
11
17
  """Return urllib3 ProxyManager for the given proxy.
@@ -3,6 +3,16 @@ from http.client import _read_headers
3
3
  from urllib3.connection import HTTPSConnection
4
4
  from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
5
5
  from urllib3.poolmanager import ProxyManager
6
+ from urllib3.util.request import make_headers
7
+ from urllib3.util.url import parse_url
8
+
9
+ from .header_utils import (
10
+ merge_proxy_response_headers,
11
+ snapshot_headers,
12
+ validate_header_name,
13
+ validate_header_value,
14
+ validate_headers,
15
+ )
6
16
 
7
17
  if sys.version_info < (3, 12, 0):
8
18
  #####################################
@@ -75,6 +85,8 @@ class HTTPSProxyConnection(HTTPSConnection):
75
85
  self._http_vsn_str.encode("ascii"))
76
86
  headers = [connect]
77
87
  for header, value in self._tunnel_headers.items():
88
+ validate_header_name(header)
89
+ validate_header_value(value)
78
90
  headers.append(f"{header}: {value}\r\n".encode("latin-1"))
79
91
  headers.append(b"\r\n")
80
92
  # Making a single send() call instead of one per line encourages
@@ -123,11 +135,28 @@ class HTTPSProxyConnectionPool(HTTPSConnectionPool):
123
135
 
124
136
  def urlopen(self, *args, **kwargs):
125
137
  response = super().urlopen(*args, **kwargs)
126
- response.headers.update(self._proxy_response_headers)
138
+ proxy_headers = getattr(self, "_proxy_response_headers", None)
139
+ response.proxy_headers = snapshot_headers(proxy_headers)
140
+ merge_proxy_response_headers(response.headers, proxy_headers)
127
141
  return response
128
142
 
129
143
  class ProxyHeaderManager(ProxyManager):
130
144
  def __init__(self, *args, **kwargs):
145
+ # urllib3.ProxyManager does not add Proxy-Authorization from user:pass in the
146
+ # proxy URL; requests does via HTTPAdapter.proxy_headers(). Merge URL auth here
147
+ # so direct proxy_from_url() matches requests and authenticates CONNECT.
148
+ proxy_url = kwargs.get("proxy_url")
149
+ if proxy_url is None and args:
150
+ proxy_url = args[0]
151
+ proxy_headers = kwargs.get("proxy_headers")
152
+ merged = validate_headers(dict(proxy_headers or {}))
153
+ if isinstance(proxy_url, str):
154
+ parsed = parse_url(proxy_url)
155
+ if parsed.auth and not any(
156
+ k.lower() == "proxy-authorization" for k in merged
157
+ ):
158
+ merged.update(make_headers(proxy_basic_auth=parsed.auth))
159
+ kwargs["proxy_headers"] = merged
131
160
  super().__init__(*args, **kwargs)
132
161
  self.pool_classes_by_scheme = {"http": HTTPConnectionPool, "https": HTTPSProxyConnectionPool}
133
162
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-proxy-headers
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Handle custom proxy headers for http & https requests in various python libraries
5
5
  Author-email: ProxyMesh <support@proxymesh.com>
6
6
  Project-URL: Homepage, https://github.com/proxymesh/python-proxy-headers
@@ -5,6 +5,7 @@ python_proxy_headers/__init__.py
5
5
  python_proxy_headers/aiohttp_proxy.py
6
6
  python_proxy_headers/autoscraper_proxy.py
7
7
  python_proxy_headers/cloudscraper_proxy.py
8
+ python_proxy_headers/header_utils.py
8
9
  python_proxy_headers/httpx_proxy.py
9
10
  python_proxy_headers/pycurl_proxy.py
10
11
  python_proxy_headers/requests_adapter.py