ulak 0.1.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.
- ulak-0.1.0/LICENSE +21 -0
- ulak-0.1.0/PKG-INFO +116 -0
- ulak-0.1.0/README.md +83 -0
- ulak-0.1.0/pyproject.toml +42 -0
- ulak-0.1.0/setup.cfg +4 -0
- ulak-0.1.0/src/ulak/__init__.py +602 -0
- ulak-0.1.0/src/ulak.egg-info/PKG-INFO +116 -0
- ulak-0.1.0/src/ulak.egg-info/SOURCES.txt +10 -0
- ulak-0.1.0/src/ulak.egg-info/dependency_links.txt +1 -0
- ulak-0.1.0/src/ulak.egg-info/requires.txt +10 -0
- ulak-0.1.0/src/ulak.egg-info/top_level.txt +1 -0
- ulak-0.1.0/tests/test_ulak.py +239 -0
ulak-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Musa SANA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ulak-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ulak
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in HTTP(S) proxy hook: route all of a Python script's traffic through Burp/mitmproxy/ZAP with one import.
|
|
5
|
+
Author-email: Musa SANA <musa.sana@hotmail.com>
|
|
6
|
+
Maintainer-email: Musa SANA <musa.sana@hotmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/musana/ulak
|
|
9
|
+
Project-URL: Repository, https://github.com/musana/ulak
|
|
10
|
+
Project-URL: Issues, https://github.com/musana/ulak/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/musana/ulak/releases
|
|
12
|
+
Keywords: proxy,http,https,burp,mitmproxy,interception,security,monkeypatch,debugging
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: requests; extra == "test"
|
|
25
|
+
Requires-Dist: httpx; extra == "test"
|
|
26
|
+
Requires-Dist: aiohttp; extra == "test"
|
|
27
|
+
Requires-Dist: urllib3; extra == "test"
|
|
28
|
+
Requires-Dist: niquests; extra == "test"
|
|
29
|
+
Requires-Dist: httplib2; extra == "test"
|
|
30
|
+
Requires-Dist: curl_cffi; extra == "test"
|
|
31
|
+
Requires-Dist: pytest; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# ulak
|
|
35
|
+
|
|
36
|
+
Send every HTTP and HTTPS request a Python script makes through your proxy, with a single import, without changing the script.
|
|
37
|
+
|
|
38
|
+
## The problem
|
|
39
|
+
|
|
40
|
+
When you run a script someone else wrote, you often want to see the HTTP traffic it sends. You can inspect it in Burp, mitmproxy, or ZAP, but getting there is painful. Either you set a system wide proxy, or you hunt down every request call in the code and add a proxy argument by hand. And Python has many HTTP libraries, so there is no single place to change.
|
|
41
|
+
|
|
42
|
+
## The solution
|
|
43
|
+
|
|
44
|
+
`ulak` does it in one line. Add it to the top of the script and all outbound traffic goes through the proxy you pick. The name is the Turkish word for a courier who carries a message from one post to the next, which is what this does for the script's requests.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import ulak
|
|
48
|
+
ulak.proxy("127.0.0.1", 8080)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every request after that line is routed through `127.0.0.1:8080`.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install ulak
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
There are no required dependencies. A library is only hooked if the script already uses it.
|
|
60
|
+
|
|
61
|
+
## What it covers
|
|
62
|
+
|
|
63
|
+
Hooked directly, so they go through the proxy even when they ignore the environment:
|
|
64
|
+
|
|
65
|
+
- requests
|
|
66
|
+
- urllib (standard library)
|
|
67
|
+
- urllib3
|
|
68
|
+
- httpx
|
|
69
|
+
- aiohttp
|
|
70
|
+
- httplib2
|
|
71
|
+
- niquests
|
|
72
|
+
- curl_cffi
|
|
73
|
+
- tornado (needs pycurl; without it, tornado is left alone and reported as not proxied)
|
|
74
|
+
|
|
75
|
+
Covered through the proxy environment variables that `ulak` sets:
|
|
76
|
+
|
|
77
|
+
- pycurl and libcurl
|
|
78
|
+
- botocore and boto3, Google API clients
|
|
79
|
+
- any child process started with subprocess, such as curl or wget
|
|
80
|
+
|
|
81
|
+
## How to use
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import ulak
|
|
85
|
+
|
|
86
|
+
ulak.proxy("127.0.0.1", 8080) # verify off by default
|
|
87
|
+
ulak.proxy("127.0.0.1", 8080, verify=True) # keep normal TLS checks
|
|
88
|
+
ulak.proxy("127.0.0.1", 8080, verify="burp-ca.pem") # trust your proxy's CA
|
|
89
|
+
ulak.off() # undo everything
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Only for one block of code:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
with ulak.proxied("127.0.0.1", 8080):
|
|
96
|
+
run_the_thing()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Check the current state:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
ulak.status() # what was patched, skipped, or failed
|
|
103
|
+
ulak.is_active() # True or False
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## A note on TLS
|
|
107
|
+
|
|
108
|
+
A proxy like Burp breaks into TLS with its own certificate authority, which your client does not trust. That is why `verify` is off by default, so HTTPS requests do not fail. This turns off certificate checking, so use it only with a local proxy you control. The cleaner way is to export your proxy's CA certificate and pass `verify="path/to/ca.pem"`, which keeps verification on and trusts the proxy.
|
|
109
|
+
|
|
110
|
+
## Limits
|
|
111
|
+
|
|
112
|
+
`ulak` works at the library level, so it cannot catch traffic that skips those libraries, such as raw socket clients or libraries that carry their own TLS stack in C. When you call `ulak.off()`, every change is reverted and the environment is restored to how it was.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
ulak-0.1.0/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# ulak
|
|
2
|
+
|
|
3
|
+
Send every HTTP and HTTPS request a Python script makes through your proxy, with a single import, without changing the script.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
When you run a script someone else wrote, you often want to see the HTTP traffic it sends. You can inspect it in Burp, mitmproxy, or ZAP, but getting there is painful. Either you set a system wide proxy, or you hunt down every request call in the code and add a proxy argument by hand. And Python has many HTTP libraries, so there is no single place to change.
|
|
8
|
+
|
|
9
|
+
## The solution
|
|
10
|
+
|
|
11
|
+
`ulak` does it in one line. Add it to the top of the script and all outbound traffic goes through the proxy you pick. The name is the Turkish word for a courier who carries a message from one post to the next, which is what this does for the script's requests.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import ulak
|
|
15
|
+
ulak.proxy("127.0.0.1", 8080)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Every request after that line is routed through `127.0.0.1:8080`.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install ulak
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
There are no required dependencies. A library is only hooked if the script already uses it.
|
|
27
|
+
|
|
28
|
+
## What it covers
|
|
29
|
+
|
|
30
|
+
Hooked directly, so they go through the proxy even when they ignore the environment:
|
|
31
|
+
|
|
32
|
+
- requests
|
|
33
|
+
- urllib (standard library)
|
|
34
|
+
- urllib3
|
|
35
|
+
- httpx
|
|
36
|
+
- aiohttp
|
|
37
|
+
- httplib2
|
|
38
|
+
- niquests
|
|
39
|
+
- curl_cffi
|
|
40
|
+
- tornado (needs pycurl; without it, tornado is left alone and reported as not proxied)
|
|
41
|
+
|
|
42
|
+
Covered through the proxy environment variables that `ulak` sets:
|
|
43
|
+
|
|
44
|
+
- pycurl and libcurl
|
|
45
|
+
- botocore and boto3, Google API clients
|
|
46
|
+
- any child process started with subprocess, such as curl or wget
|
|
47
|
+
|
|
48
|
+
## How to use
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import ulak
|
|
52
|
+
|
|
53
|
+
ulak.proxy("127.0.0.1", 8080) # verify off by default
|
|
54
|
+
ulak.proxy("127.0.0.1", 8080, verify=True) # keep normal TLS checks
|
|
55
|
+
ulak.proxy("127.0.0.1", 8080, verify="burp-ca.pem") # trust your proxy's CA
|
|
56
|
+
ulak.off() # undo everything
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Only for one block of code:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
with ulak.proxied("127.0.0.1", 8080):
|
|
63
|
+
run_the_thing()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Check the current state:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
ulak.status() # what was patched, skipped, or failed
|
|
70
|
+
ulak.is_active() # True or False
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## A note on TLS
|
|
74
|
+
|
|
75
|
+
A proxy like Burp breaks into TLS with its own certificate authority, which your client does not trust. That is why `verify` is off by default, so HTTPS requests do not fail. This turns off certificate checking, so use it only with a local proxy you control. The cleaner way is to export your proxy's CA certificate and pass `verify="path/to/ca.pem"`, which keeps verification on and trusts the proxy.
|
|
76
|
+
|
|
77
|
+
## Limits
|
|
78
|
+
|
|
79
|
+
`ulak` works at the library level, so it cannot catch traffic that skips those libraries, such as raw socket clients or libraries that carry their own TLS stack in C. When you call `ulak.off()`, every change is reverted and the environment is restored to how it was.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ulak"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Drop-in HTTP(S) proxy hook: route all of a Python script's traffic through Burp/mitmproxy/ZAP with one import."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Musa SANA", email = "musa.sana@hotmail.com" }]
|
|
14
|
+
maintainers = [{ name = "Musa SANA", email = "musa.sana@hotmail.com" }]
|
|
15
|
+
keywords = ["proxy", "http", "https", "burp", "mitmproxy", "interception", "security", "monkeypatch", "debugging"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Topic :: Internet :: Proxy Servers",
|
|
21
|
+
"Topic :: Software Development :: Testing",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# No required dependencies: every HTTP library is patched only if it is present.
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
# Install the clients you want covered in tests, e.g. pip install ulak[test]
|
|
30
|
+
test = ["requests", "httpx", "aiohttp", "urllib3", "niquests", "httplib2", "curl_cffi", "pytest"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/musana/ulak"
|
|
34
|
+
Repository = "https://github.com/musana/ulak"
|
|
35
|
+
Issues = "https://github.com/musana/ulak/issues"
|
|
36
|
+
Changelog = "https://github.com/musana/ulak/releases"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.dynamic]
|
|
39
|
+
version = { attr = "ulak.__version__" }
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
ulak-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ulak - a drop-in HTTP(S) proxy hook for arbitrary Python scripts.
|
|
3
|
+
|
|
4
|
+
The name comes from the Turkish word for a courier/relay rider: a messenger
|
|
5
|
+
who carries dispatches from one post to the next. This library does the same
|
|
6
|
+
for a script's network traffic: it quietly relays every HTTP/HTTPS request the
|
|
7
|
+
script makes through a proxy of your choice (Burp Suite, mitmproxy, ZAP, your
|
|
8
|
+
own listener), without touching the script's source code.
|
|
9
|
+
|
|
10
|
+
Why
|
|
11
|
+
---
|
|
12
|
+
There is no single built-in switch that reroutes *all* outbound HTTP in a
|
|
13
|
+
Python process: every HTTP library opens its own socket and builds its own
|
|
14
|
+
connection. ``ulak`` therefore works on two layers at once:
|
|
15
|
+
|
|
16
|
+
1. It sets the standard proxy environment variables, which many clients read.
|
|
17
|
+
2. It monkey-patches the entry points of the well-known HTTP libraries, to
|
|
18
|
+
force the proxy even on clients that ignore the environment.
|
|
19
|
+
|
|
20
|
+
Usage
|
|
21
|
+
-----
|
|
22
|
+
Add two lines at the very top of the target script::
|
|
23
|
+
|
|
24
|
+
import ulak
|
|
25
|
+
ulak.proxy("127.0.0.1", 8080)
|
|
26
|
+
|
|
27
|
+
Every request made after that line flows through 127.0.0.1:8080.
|
|
28
|
+
|
|
29
|
+
ulak.proxy("127.0.0.1", 8080) # verify disabled (MITM default)
|
|
30
|
+
ulak.proxy("127.0.0.1", 8080, verify=True) # keep normal TLS verification
|
|
31
|
+
ulak.proxy("127.0.0.1", 8080, verify="burp-ca.pem")# trust the proxy's CA bundle
|
|
32
|
+
ulak.off() # revert everything
|
|
33
|
+
|
|
34
|
+
As a context manager::
|
|
35
|
+
|
|
36
|
+
with ulak.proxied("127.0.0.1", 8080):
|
|
37
|
+
run_the_thing()
|
|
38
|
+
|
|
39
|
+
Coverage
|
|
40
|
+
--------
|
|
41
|
+
Directly patched: requests, urllib (stdlib), urllib3, httpx, aiohttp,
|
|
42
|
+
httplib2, niquests, curl_cffi, tornado. Covered via environment variables:
|
|
43
|
+
pycurl / libcurl, botocore / boto3, google-api clients, and any child process
|
|
44
|
+
spawned with subprocess (e.g. curl, wget) since children inherit the
|
|
45
|
+
environment. See README for the full matrix and the known limits.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import logging
|
|
51
|
+
import os
|
|
52
|
+
import ssl
|
|
53
|
+
import sys
|
|
54
|
+
import warnings
|
|
55
|
+
from typing import Callable, Dict, List, Union
|
|
56
|
+
|
|
57
|
+
__all__ = ["proxy", "off", "proxied", "status", "is_active"]
|
|
58
|
+
__version__ = "0.1.0"
|
|
59
|
+
|
|
60
|
+
Verify = Union[bool, str]
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger("ulak")
|
|
63
|
+
logger.addHandler(logging.NullHandler())
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Internal state
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
_STATE: Dict[str, object] = {
|
|
70
|
+
"active": False,
|
|
71
|
+
"host": None,
|
|
72
|
+
"port": None,
|
|
73
|
+
"url": None,
|
|
74
|
+
"verify": None, # True / False / path to a CA bundle
|
|
75
|
+
"patched": [], # libraries successfully patched
|
|
76
|
+
"skipped": [], # libraries not installed, skipped
|
|
77
|
+
"unsupported": {}, # libraries present but not proxyable here (with reason)
|
|
78
|
+
"errors": {}, # libraries that raised while patching
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Originals, kept so off() can restore them exactly.
|
|
82
|
+
_ORIG: Dict[str, object] = {}
|
|
83
|
+
_SAVED_ENV: Dict[str, Union[str, None]] = {}
|
|
84
|
+
|
|
85
|
+
_VERBOSE = True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _banner(msg: str) -> None:
|
|
89
|
+
logger.info(msg)
|
|
90
|
+
if _VERBOSE:
|
|
91
|
+
print(f"[ulak] {msg}", file=sys.stderr)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Environment variables
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
_PROXY_ENV = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy",
|
|
99
|
+
"ALL_PROXY", "all_proxy")
|
|
100
|
+
_NO_PROXY_ENV = ("NO_PROXY", "no_proxy")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _set_env(proxy_url: str) -> None:
|
|
104
|
+
if not _SAVED_ENV:
|
|
105
|
+
for key in _PROXY_ENV + _NO_PROXY_ENV:
|
|
106
|
+
_SAVED_ENV[key] = os.environ.get(key)
|
|
107
|
+
for key in _PROXY_ENV:
|
|
108
|
+
os.environ[key] = proxy_url
|
|
109
|
+
# A NO_PROXY set by the target script would exempt some hosts; clear it so
|
|
110
|
+
# that everything is routed through the proxy.
|
|
111
|
+
for key in _NO_PROXY_ENV:
|
|
112
|
+
os.environ.pop(key, None)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _restore_env() -> None:
|
|
116
|
+
for key, value in _SAVED_ENV.items():
|
|
117
|
+
if value is None:
|
|
118
|
+
os.environ.pop(key, None)
|
|
119
|
+
else:
|
|
120
|
+
os.environ[key] = value
|
|
121
|
+
_SAVED_ENV.clear()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# TLS helpers
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _make_ssl_context(verify: Verify) -> ssl.SSLContext:
|
|
129
|
+
"""Build an SSLContext matching the requested verification policy."""
|
|
130
|
+
if verify is False:
|
|
131
|
+
ctx = ssl.create_default_context()
|
|
132
|
+
ctx.check_hostname = False
|
|
133
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
134
|
+
return ctx
|
|
135
|
+
if isinstance(verify, str):
|
|
136
|
+
return ssl.create_default_context(cafile=verify)
|
|
137
|
+
return ssl.create_default_context()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _silence_insecure_warnings() -> None:
|
|
141
|
+
if _STATE["verify"] is not False:
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
import urllib3
|
|
145
|
+
urllib3.disable_warnings()
|
|
146
|
+
except Exception: # pragma: no cover - urllib3 may be absent
|
|
147
|
+
pass
|
|
148
|
+
try:
|
|
149
|
+
from urllib3.exceptions import InsecureRequestWarning
|
|
150
|
+
warnings.simplefilter("ignore", InsecureRequestWarning)
|
|
151
|
+
except Exception: # pragma: no cover
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ===========================================================================
|
|
156
|
+
# Patchers. Each returns True when it did something, raises ImportError when
|
|
157
|
+
# its library is absent, and is otherwise wrapped so a failure cannot crash
|
|
158
|
+
# the host script.
|
|
159
|
+
# ===========================================================================
|
|
160
|
+
|
|
161
|
+
def _patch_env() -> bool:
|
|
162
|
+
_set_env(str(_STATE["url"]))
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _patch_ssl() -> bool:
|
|
167
|
+
"""Globally relax/redirect stdlib TLS verification when asked to."""
|
|
168
|
+
verify = _STATE["verify"]
|
|
169
|
+
if verify is True:
|
|
170
|
+
return False
|
|
171
|
+
_ORIG.setdefault("ssl._create_default_https_context",
|
|
172
|
+
ssl._create_default_https_context)
|
|
173
|
+
if verify is False:
|
|
174
|
+
ssl._create_default_https_context = ssl._create_unverified_context
|
|
175
|
+
else:
|
|
176
|
+
cafile = verify
|
|
177
|
+
ssl._create_default_https_context = (
|
|
178
|
+
lambda *a, **k: ssl.create_default_context(cafile=cafile))
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _patch_urllib() -> bool:
|
|
183
|
+
import urllib.request as ur
|
|
184
|
+
|
|
185
|
+
proxy_url = str(_STATE["url"])
|
|
186
|
+
handlers: List[object] = [
|
|
187
|
+
ur.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
|
188
|
+
]
|
|
189
|
+
if _STATE["verify"] is not True:
|
|
190
|
+
handlers.append(ur.HTTPSHandler(context=_make_ssl_context(_STATE["verify"])))
|
|
191
|
+
|
|
192
|
+
_ORIG.setdefault("urllib.opener", ur._opener)
|
|
193
|
+
ur.install_opener(ur.build_opener(*handlers))
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _patch_requests() -> bool:
|
|
198
|
+
import requests # noqa: F401
|
|
199
|
+
from requests.sessions import Session
|
|
200
|
+
|
|
201
|
+
_ORIG.setdefault("requests.Session.request", Session.request)
|
|
202
|
+
orig = _ORIG["requests.Session.request"]
|
|
203
|
+
proxies = {"http": _STATE["url"], "https": _STATE["url"]}
|
|
204
|
+
verify = _STATE["verify"]
|
|
205
|
+
|
|
206
|
+
def request(self, method, url, **kwargs):
|
|
207
|
+
kwargs["proxies"] = proxies
|
|
208
|
+
kwargs["verify"] = verify
|
|
209
|
+
return orig(self, method, url, **kwargs)
|
|
210
|
+
|
|
211
|
+
Session.request = request
|
|
212
|
+
_silence_insecure_warnings()
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _patch_niquests() -> bool:
|
|
217
|
+
"""niquests is a requests-compatible fork with its own module."""
|
|
218
|
+
import niquests
|
|
219
|
+
Session = niquests.Session
|
|
220
|
+
|
|
221
|
+
_ORIG.setdefault("niquests.Session.request", Session.request)
|
|
222
|
+
orig = _ORIG["niquests.Session.request"]
|
|
223
|
+
proxies = {"http": _STATE["url"], "https": _STATE["url"]}
|
|
224
|
+
verify = _STATE["verify"]
|
|
225
|
+
|
|
226
|
+
def request(self, method, url, **kwargs):
|
|
227
|
+
kwargs["proxies"] = proxies
|
|
228
|
+
kwargs["verify"] = verify
|
|
229
|
+
return orig(self, method, url, **kwargs)
|
|
230
|
+
|
|
231
|
+
Session.request = request
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _patch_urllib3() -> bool:
|
|
236
|
+
"""urllib3 ignores the environment and uses its own PoolManager, so we
|
|
237
|
+
swap PoolManager for a ProxyManager bound to the proxy. requests binds its
|
|
238
|
+
own PoolManager reference at import time, so this does not affect it."""
|
|
239
|
+
import urllib3
|
|
240
|
+
|
|
241
|
+
proxy_url = str(_STATE["url"])
|
|
242
|
+
verify = _STATE["verify"]
|
|
243
|
+
|
|
244
|
+
_ORIG.setdefault("urllib3.PoolManager", urllib3.PoolManager)
|
|
245
|
+
base_proxy_manager = urllib3.ProxyManager
|
|
246
|
+
|
|
247
|
+
extra: Dict[str, object] = {}
|
|
248
|
+
if verify is False:
|
|
249
|
+
extra["cert_reqs"] = "CERT_NONE"
|
|
250
|
+
elif isinstance(verify, str):
|
|
251
|
+
extra["ca_certs"] = verify
|
|
252
|
+
|
|
253
|
+
class _ProxiedPoolManager(base_proxy_manager): # type: ignore[valid-type,misc]
|
|
254
|
+
def __init__(self, num_pools=10, headers=None, **conn_kw):
|
|
255
|
+
merged = dict(extra)
|
|
256
|
+
merged.update(conn_kw) # caller-supplied values win
|
|
257
|
+
super().__init__(proxy_url, num_pools=num_pools,
|
|
258
|
+
headers=headers, **merged)
|
|
259
|
+
|
|
260
|
+
urllib3.PoolManager = _ProxiedPoolManager
|
|
261
|
+
_silence_insecure_warnings()
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _patch_httpx() -> bool:
|
|
266
|
+
import inspect
|
|
267
|
+
import httpx
|
|
268
|
+
|
|
269
|
+
proxy_url = str(_STATE["url"])
|
|
270
|
+
verify = _STATE["verify"]
|
|
271
|
+
patched_any = False
|
|
272
|
+
|
|
273
|
+
for cls_name in ("Client", "AsyncClient"):
|
|
274
|
+
cls = getattr(httpx, cls_name, None)
|
|
275
|
+
if cls is None:
|
|
276
|
+
continue
|
|
277
|
+
key = f"httpx.{cls_name}.__init__"
|
|
278
|
+
_ORIG.setdefault(key, cls.__init__)
|
|
279
|
+
orig_init = _ORIG[key]
|
|
280
|
+
params = inspect.signature(orig_init).parameters
|
|
281
|
+
|
|
282
|
+
def make_init(orig_init, params):
|
|
283
|
+
def __init__(self, *args, **kwargs):
|
|
284
|
+
# httpx renamed the argument: "proxies" (<0.26) -> "proxy".
|
|
285
|
+
if "proxy" in params:
|
|
286
|
+
kwargs.setdefault("proxy", proxy_url)
|
|
287
|
+
elif "proxies" in params:
|
|
288
|
+
kwargs.setdefault("proxies", proxy_url)
|
|
289
|
+
if "verify" in params:
|
|
290
|
+
kwargs["verify"] = verify
|
|
291
|
+
return orig_init(self, *args, **kwargs)
|
|
292
|
+
return __init__
|
|
293
|
+
|
|
294
|
+
cls.__init__ = make_init(orig_init, params)
|
|
295
|
+
patched_any = True
|
|
296
|
+
return patched_any
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _patch_aiohttp() -> bool:
|
|
300
|
+
import aiohttp
|
|
301
|
+
|
|
302
|
+
proxy_url = str(_STATE["url"])
|
|
303
|
+
verify = _STATE["verify"]
|
|
304
|
+
cls = aiohttp.ClientSession
|
|
305
|
+
|
|
306
|
+
_ORIG.setdefault("aiohttp.ClientSession._request", cls._request)
|
|
307
|
+
orig = _ORIG["aiohttp.ClientSession._request"]
|
|
308
|
+
|
|
309
|
+
async def _request(self, method, str_or_url, **kwargs):
|
|
310
|
+
kwargs.setdefault("proxy", proxy_url)
|
|
311
|
+
if verify is False:
|
|
312
|
+
kwargs.setdefault("ssl", False)
|
|
313
|
+
elif isinstance(verify, str):
|
|
314
|
+
kwargs.setdefault("ssl", _make_ssl_context(verify))
|
|
315
|
+
return await orig(self, method, str_or_url, **kwargs)
|
|
316
|
+
|
|
317
|
+
cls._request = _request
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _patch_httplib2() -> bool:
|
|
322
|
+
import httplib2
|
|
323
|
+
|
|
324
|
+
host, port = _STATE["host"], _STATE["port"]
|
|
325
|
+
verify = _STATE["verify"]
|
|
326
|
+
|
|
327
|
+
_ORIG.setdefault("httplib2.Http.__init__", httplib2.Http.__init__)
|
|
328
|
+
orig = _ORIG["httplib2.Http.__init__"]
|
|
329
|
+
proxy_info = httplib2.ProxyInfo(httplib2.socks.PROXY_TYPE_HTTP, host, int(port))
|
|
330
|
+
|
|
331
|
+
def __init__(self, *args, **kwargs):
|
|
332
|
+
kwargs["proxy_info"] = proxy_info
|
|
333
|
+
if verify is False:
|
|
334
|
+
kwargs["disable_ssl_certificate_validation"] = True
|
|
335
|
+
elif isinstance(verify, str):
|
|
336
|
+
kwargs["ca_certs"] = verify
|
|
337
|
+
orig(self, *args, **kwargs)
|
|
338
|
+
|
|
339
|
+
httplib2.Http.__init__ = __init__
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _patch_curl_cffi() -> bool:
|
|
344
|
+
"""curl_cffi exposes a requests-like API (sync Session and AsyncSession)."""
|
|
345
|
+
from curl_cffi import requests as cffi
|
|
346
|
+
|
|
347
|
+
proxies = {"http": _STATE["url"], "https": _STATE["url"]}
|
|
348
|
+
verify = _STATE["verify"]
|
|
349
|
+
patched_any = False
|
|
350
|
+
|
|
351
|
+
for cls_name in ("Session", "AsyncSession"):
|
|
352
|
+
cls = getattr(cffi, cls_name, None)
|
|
353
|
+
if cls is None or not hasattr(cls, "request"):
|
|
354
|
+
continue
|
|
355
|
+
key = f"curl_cffi.{cls_name}.request"
|
|
356
|
+
_ORIG.setdefault(key, cls.request)
|
|
357
|
+
orig = _ORIG[key]
|
|
358
|
+
|
|
359
|
+
def make_request(orig):
|
|
360
|
+
def request(self, method, url, **kwargs):
|
|
361
|
+
kwargs["proxies"] = proxies
|
|
362
|
+
kwargs["verify"] = verify
|
|
363
|
+
return orig(self, method, url, **kwargs)
|
|
364
|
+
return request
|
|
365
|
+
|
|
366
|
+
cls.request = make_request(orig)
|
|
367
|
+
patched_any = True
|
|
368
|
+
return patched_any
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _patch_tornado() -> bool:
|
|
372
|
+
"""Route tornado through the proxy.
|
|
373
|
+
|
|
374
|
+
tornado's default (simple) client has no proxy support and raises if you
|
|
375
|
+
pass ``proxy_host``. Only its libcurl-backed client can proxy, so we switch
|
|
376
|
+
tornado to that client (requires pycurl) and inject the proxy fields into
|
|
377
|
+
every HTTPRequest. Without pycurl we cannot proxy tornado without breaking
|
|
378
|
+
it, so we raise NotImplementedError and leave tornado untouched.
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
import pycurl # noqa: F401
|
|
382
|
+
except ImportError:
|
|
383
|
+
raise NotImplementedError(
|
|
384
|
+
"tornado can only be proxied via its libcurl client; install "
|
|
385
|
+
"pycurl to enable tornado interception")
|
|
386
|
+
|
|
387
|
+
import tornado.httpclient as thc
|
|
388
|
+
|
|
389
|
+
host, port = _STATE["host"], _STATE["port"]
|
|
390
|
+
verify = _STATE["verify"]
|
|
391
|
+
|
|
392
|
+
if "tornado.impl" not in _ORIG:
|
|
393
|
+
_ORIG["tornado.impl"] = getattr(thc.AsyncHTTPClient, "_impl_class", None)
|
|
394
|
+
thc.AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient")
|
|
395
|
+
|
|
396
|
+
_ORIG.setdefault("tornado.HTTPRequest.__init__", thc.HTTPRequest.__init__)
|
|
397
|
+
orig = _ORIG["tornado.HTTPRequest.__init__"]
|
|
398
|
+
|
|
399
|
+
def __init__(self, *args, **kwargs):
|
|
400
|
+
kwargs.setdefault("proxy_host", host)
|
|
401
|
+
kwargs.setdefault("proxy_port", int(port))
|
|
402
|
+
if verify is False:
|
|
403
|
+
kwargs.setdefault("validate_cert", False)
|
|
404
|
+
elif isinstance(verify, str):
|
|
405
|
+
kwargs.setdefault("ca_certs", verify)
|
|
406
|
+
orig(self, *args, **kwargs)
|
|
407
|
+
|
|
408
|
+
thc.HTTPRequest.__init__ = __init__
|
|
409
|
+
return True
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# (name, patcher, restorer-key-prefix). Order matters: env and ssl first.
|
|
413
|
+
_PATCHERS: List[tuple] = [
|
|
414
|
+
("env", _patch_env),
|
|
415
|
+
("ssl", _patch_ssl),
|
|
416
|
+
("urllib", _patch_urllib),
|
|
417
|
+
("requests", _patch_requests),
|
|
418
|
+
("niquests", _patch_niquests),
|
|
419
|
+
("urllib3", _patch_urllib3),
|
|
420
|
+
("httpx", _patch_httpx),
|
|
421
|
+
("aiohttp", _patch_aiohttp),
|
|
422
|
+
("httplib2", _patch_httplib2),
|
|
423
|
+
("curl_cffi", _patch_curl_cffi),
|
|
424
|
+
("tornado", _patch_tornado),
|
|
425
|
+
]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
# Restore logic (mirror of the patchers)
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
def _restore_all() -> None:
|
|
433
|
+
if "ssl._create_default_https_context" in _ORIG:
|
|
434
|
+
ssl._create_default_https_context = _ORIG.pop("ssl._create_default_https_context")
|
|
435
|
+
|
|
436
|
+
if "urllib.opener" in _ORIG:
|
|
437
|
+
import urllib.request as ur
|
|
438
|
+
saved = _ORIG.pop("urllib.opener")
|
|
439
|
+
if saved is None:
|
|
440
|
+
ur._opener = None
|
|
441
|
+
else:
|
|
442
|
+
ur.install_opener(saved)
|
|
443
|
+
|
|
444
|
+
if "requests.Session.request" in _ORIG:
|
|
445
|
+
from requests.sessions import Session
|
|
446
|
+
Session.request = _ORIG.pop("requests.Session.request")
|
|
447
|
+
|
|
448
|
+
if "niquests.Session.request" in _ORIG:
|
|
449
|
+
import niquests
|
|
450
|
+
niquests.Session.request = _ORIG.pop("niquests.Session.request")
|
|
451
|
+
|
|
452
|
+
if "urllib3.PoolManager" in _ORIG:
|
|
453
|
+
import urllib3
|
|
454
|
+
urllib3.PoolManager = _ORIG.pop("urllib3.PoolManager")
|
|
455
|
+
|
|
456
|
+
for cls_name in ("Client", "AsyncClient"):
|
|
457
|
+
key = f"httpx.{cls_name}.__init__"
|
|
458
|
+
if key in _ORIG:
|
|
459
|
+
import httpx
|
|
460
|
+
getattr(httpx, cls_name).__init__ = _ORIG.pop(key)
|
|
461
|
+
|
|
462
|
+
if "aiohttp.ClientSession._request" in _ORIG:
|
|
463
|
+
import aiohttp
|
|
464
|
+
aiohttp.ClientSession._request = _ORIG.pop("aiohttp.ClientSession._request")
|
|
465
|
+
|
|
466
|
+
if "httplib2.Http.__init__" in _ORIG:
|
|
467
|
+
import httplib2
|
|
468
|
+
httplib2.Http.__init__ = _ORIG.pop("httplib2.Http.__init__")
|
|
469
|
+
|
|
470
|
+
for cls_name in ("Session", "AsyncSession"):
|
|
471
|
+
key = f"curl_cffi.{cls_name}.request"
|
|
472
|
+
if key in _ORIG:
|
|
473
|
+
from curl_cffi import requests as cffi
|
|
474
|
+
getattr(cffi, cls_name).request = _ORIG.pop(key)
|
|
475
|
+
|
|
476
|
+
if "tornado.HTTPRequest.__init__" in _ORIG:
|
|
477
|
+
import tornado.httpclient as thc
|
|
478
|
+
thc.HTTPRequest.__init__ = _ORIG.pop("tornado.HTTPRequest.__init__")
|
|
479
|
+
if "tornado.impl" in _ORIG:
|
|
480
|
+
import tornado.httpclient as thc
|
|
481
|
+
prior = _ORIG.pop("tornado.impl")
|
|
482
|
+
thc.AsyncHTTPClient.configure(prior)
|
|
483
|
+
|
|
484
|
+
_restore_env()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
# Public API
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def proxy(host: str = "127.0.0.1",
|
|
492
|
+
port: int = 8080,
|
|
493
|
+
*,
|
|
494
|
+
verify: Verify = False,
|
|
495
|
+
scheme: str = "http",
|
|
496
|
+
verbose: bool = True) -> Dict[str, object]:
|
|
497
|
+
"""Route all outbound HTTP/HTTPS traffic of this process through a proxy.
|
|
498
|
+
|
|
499
|
+
Parameters
|
|
500
|
+
----------
|
|
501
|
+
host, port:
|
|
502
|
+
Proxy address. Defaults to 127.0.0.1:8080 (Burp Suite's default).
|
|
503
|
+
verify:
|
|
504
|
+
TLS verification policy.
|
|
505
|
+
``False`` (default) disables verification, which is what an
|
|
506
|
+
intercepting proxy with its own CA normally requires. ``True`` keeps
|
|
507
|
+
normal verification. A string is treated as a path to a CA bundle to
|
|
508
|
+
trust (export your proxy's CA and pass it here).
|
|
509
|
+
scheme:
|
|
510
|
+
Proxy scheme, ``"http"`` by default (Burp/ZAP/mitmproxy are HTTP
|
|
511
|
+
proxies).
|
|
512
|
+
verbose:
|
|
513
|
+
Print a one-line activation banner to stderr.
|
|
514
|
+
|
|
515
|
+
Calling this again re-applies with the new settings (the previous hook is
|
|
516
|
+
reverted first). Returns a status dict describing what was patched.
|
|
517
|
+
"""
|
|
518
|
+
global _VERBOSE
|
|
519
|
+
_VERBOSE = verbose
|
|
520
|
+
|
|
521
|
+
if _STATE["active"]:
|
|
522
|
+
off()
|
|
523
|
+
|
|
524
|
+
proxy_url = f"{scheme}://{host}:{port}"
|
|
525
|
+
_STATE.update({
|
|
526
|
+
"active": True,
|
|
527
|
+
"host": host,
|
|
528
|
+
"port": port,
|
|
529
|
+
"url": proxy_url,
|
|
530
|
+
"verify": verify,
|
|
531
|
+
"patched": [],
|
|
532
|
+
"skipped": [],
|
|
533
|
+
"unsupported": {},
|
|
534
|
+
"errors": {},
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
for name, fn in _PATCHERS:
|
|
538
|
+
try:
|
|
539
|
+
if fn():
|
|
540
|
+
_STATE["patched"].append(name) # type: ignore[union-attr]
|
|
541
|
+
except ImportError:
|
|
542
|
+
_STATE["skipped"].append(name) # type: ignore[union-attr]
|
|
543
|
+
except NotImplementedError as exc:
|
|
544
|
+
_STATE["unsupported"][name] = str(exc) # type: ignore[index]
|
|
545
|
+
except Exception as exc: # noqa: BLE001 - never crash the host script
|
|
546
|
+
_STATE["errors"][name] = repr(exc) # type: ignore[index]
|
|
547
|
+
logger.exception("ulak: patching %s failed", name)
|
|
548
|
+
|
|
549
|
+
_banner(f"routing HTTP/HTTPS through {proxy_url} (verify={verify!r})")
|
|
550
|
+
_banner("patched: " + ", ".join(_STATE["patched"])) # type: ignore[arg-type]
|
|
551
|
+
if _STATE["unsupported"]:
|
|
552
|
+
for lib, reason in _STATE["unsupported"].items(): # type: ignore[attr-defined]
|
|
553
|
+
_banner(f"note: {lib} not proxied ({reason})")
|
|
554
|
+
if _STATE["errors"]:
|
|
555
|
+
_banner(f"errors: {_STATE['errors']}")
|
|
556
|
+
if verify is False:
|
|
557
|
+
_banner("WARNING: TLS verification is DISABLED; use only with a proxy you trust.")
|
|
558
|
+
return status()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def off() -> None:
|
|
562
|
+
"""Revert every patch and restore the original environment variables."""
|
|
563
|
+
if not _STATE["active"]:
|
|
564
|
+
return
|
|
565
|
+
try:
|
|
566
|
+
_restore_all()
|
|
567
|
+
finally:
|
|
568
|
+
_banner("proxy disabled; all hooks reverted.")
|
|
569
|
+
_STATE.update({
|
|
570
|
+
"active": False, "host": None, "port": None, "url": None,
|
|
571
|
+
"verify": None, "patched": [], "skipped": [],
|
|
572
|
+
"unsupported": {}, "errors": {},
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def is_active() -> bool:
|
|
577
|
+
"""Return whether a proxy hook is currently installed."""
|
|
578
|
+
return bool(_STATE["active"])
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def status() -> Dict[str, object]:
|
|
582
|
+
"""Return a copy of the current state."""
|
|
583
|
+
return dict(_STATE)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class proxied:
|
|
587
|
+
"""Context manager: proxy on inside the block, reverted on exit.
|
|
588
|
+
|
|
589
|
+
>>> with proxied("127.0.0.1", 8080):
|
|
590
|
+
... do_requests()
|
|
591
|
+
"""
|
|
592
|
+
|
|
593
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 8080, **kwargs):
|
|
594
|
+
self._args = (host, port)
|
|
595
|
+
self._kwargs = kwargs
|
|
596
|
+
|
|
597
|
+
def __enter__(self) -> Dict[str, object]:
|
|
598
|
+
return proxy(*self._args, **self._kwargs)
|
|
599
|
+
|
|
600
|
+
def __exit__(self, *exc) -> bool:
|
|
601
|
+
off()
|
|
602
|
+
return False
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ulak
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in HTTP(S) proxy hook: route all of a Python script's traffic through Burp/mitmproxy/ZAP with one import.
|
|
5
|
+
Author-email: Musa SANA <musa.sana@hotmail.com>
|
|
6
|
+
Maintainer-email: Musa SANA <musa.sana@hotmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/musana/ulak
|
|
9
|
+
Project-URL: Repository, https://github.com/musana/ulak
|
|
10
|
+
Project-URL: Issues, https://github.com/musana/ulak/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/musana/ulak/releases
|
|
12
|
+
Keywords: proxy,http,https,burp,mitmproxy,interception,security,monkeypatch,debugging
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: requests; extra == "test"
|
|
25
|
+
Requires-Dist: httpx; extra == "test"
|
|
26
|
+
Requires-Dist: aiohttp; extra == "test"
|
|
27
|
+
Requires-Dist: urllib3; extra == "test"
|
|
28
|
+
Requires-Dist: niquests; extra == "test"
|
|
29
|
+
Requires-Dist: httplib2; extra == "test"
|
|
30
|
+
Requires-Dist: curl_cffi; extra == "test"
|
|
31
|
+
Requires-Dist: pytest; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# ulak
|
|
35
|
+
|
|
36
|
+
Send every HTTP and HTTPS request a Python script makes through your proxy, with a single import, without changing the script.
|
|
37
|
+
|
|
38
|
+
## The problem
|
|
39
|
+
|
|
40
|
+
When you run a script someone else wrote, you often want to see the HTTP traffic it sends. You can inspect it in Burp, mitmproxy, or ZAP, but getting there is painful. Either you set a system wide proxy, or you hunt down every request call in the code and add a proxy argument by hand. And Python has many HTTP libraries, so there is no single place to change.
|
|
41
|
+
|
|
42
|
+
## The solution
|
|
43
|
+
|
|
44
|
+
`ulak` does it in one line. Add it to the top of the script and all outbound traffic goes through the proxy you pick. The name is the Turkish word for a courier who carries a message from one post to the next, which is what this does for the script's requests.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import ulak
|
|
48
|
+
ulak.proxy("127.0.0.1", 8080)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every request after that line is routed through `127.0.0.1:8080`.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install ulak
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
There are no required dependencies. A library is only hooked if the script already uses it.
|
|
60
|
+
|
|
61
|
+
## What it covers
|
|
62
|
+
|
|
63
|
+
Hooked directly, so they go through the proxy even when they ignore the environment:
|
|
64
|
+
|
|
65
|
+
- requests
|
|
66
|
+
- urllib (standard library)
|
|
67
|
+
- urllib3
|
|
68
|
+
- httpx
|
|
69
|
+
- aiohttp
|
|
70
|
+
- httplib2
|
|
71
|
+
- niquests
|
|
72
|
+
- curl_cffi
|
|
73
|
+
- tornado (needs pycurl; without it, tornado is left alone and reported as not proxied)
|
|
74
|
+
|
|
75
|
+
Covered through the proxy environment variables that `ulak` sets:
|
|
76
|
+
|
|
77
|
+
- pycurl and libcurl
|
|
78
|
+
- botocore and boto3, Google API clients
|
|
79
|
+
- any child process started with subprocess, such as curl or wget
|
|
80
|
+
|
|
81
|
+
## How to use
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import ulak
|
|
85
|
+
|
|
86
|
+
ulak.proxy("127.0.0.1", 8080) # verify off by default
|
|
87
|
+
ulak.proxy("127.0.0.1", 8080, verify=True) # keep normal TLS checks
|
|
88
|
+
ulak.proxy("127.0.0.1", 8080, verify="burp-ca.pem") # trust your proxy's CA
|
|
89
|
+
ulak.off() # undo everything
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Only for one block of code:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
with ulak.proxied("127.0.0.1", 8080):
|
|
96
|
+
run_the_thing()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Check the current state:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
ulak.status() # what was patched, skipped, or failed
|
|
103
|
+
ulak.is_active() # True or False
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## A note on TLS
|
|
107
|
+
|
|
108
|
+
A proxy like Burp breaks into TLS with its own certificate authority, which your client does not trust. That is why `verify` is off by default, so HTTPS requests do not fail. This turns off certificate checking, so use it only with a local proxy you control. The cleaner way is to export your proxy's CA certificate and pass `verify="path/to/ca.pem"`, which keeps verification on and trusts the proxy.
|
|
109
|
+
|
|
110
|
+
## Limits
|
|
111
|
+
|
|
112
|
+
`ulak` works at the library level, so it cannot catch traffic that skips those libraries, such as raw socket clients or libraries that carry their own TLS stack in C. When you call `ulak.off()`, every change is reverted and the environment is restored to how it was.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ulak
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Tests for ulak.
|
|
2
|
+
|
|
3
|
+
A tiny in-process transparent proxy is started on an ephemeral port. Each
|
|
4
|
+
supported HTTP client is pointed at it and we assert the proxy actually saw
|
|
5
|
+
the connection. Clients that are not installed are skipped. Network access to
|
|
6
|
+
example.com is required for the live-traffic tests.
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
import importlib.util
|
|
10
|
+
import select
|
|
11
|
+
import socket
|
|
12
|
+
import ssl
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
import ulak
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --------------------------------------------------------------------------
|
|
21
|
+
# In-process transparent proxy (HTTP absolute-form + HTTPS CONNECT tunnel)
|
|
22
|
+
# --------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
class TunnelProxy:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.seen = []
|
|
27
|
+
self._srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
28
|
+
self._srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
29
|
+
self._srv.bind(("127.0.0.1", 0))
|
|
30
|
+
self._srv.listen(50)
|
|
31
|
+
self.host, self.port = self._srv.getsockname()
|
|
32
|
+
self._stop = False
|
|
33
|
+
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
34
|
+
self._thread.start()
|
|
35
|
+
|
|
36
|
+
def _serve(self):
|
|
37
|
+
while not self._stop:
|
|
38
|
+
try:
|
|
39
|
+
self._srv.settimeout(0.5)
|
|
40
|
+
client, _ = self._srv.accept()
|
|
41
|
+
except socket.timeout:
|
|
42
|
+
continue
|
|
43
|
+
except OSError:
|
|
44
|
+
break
|
|
45
|
+
threading.Thread(target=self._handle, args=(client,), daemon=True).start()
|
|
46
|
+
|
|
47
|
+
def _handle(self, client):
|
|
48
|
+
try:
|
|
49
|
+
req = b""
|
|
50
|
+
while b"\r\n\r\n" not in req:
|
|
51
|
+
chunk = client.recv(4096)
|
|
52
|
+
if not chunk:
|
|
53
|
+
return
|
|
54
|
+
req += chunk
|
|
55
|
+
method, target, _ = req.split(b"\r\n", 1)[0].decode("latin1").split(" ", 2)
|
|
56
|
+
self.seen.append((method, target))
|
|
57
|
+
if method == "CONNECT":
|
|
58
|
+
host, port = target.split(":")
|
|
59
|
+
upstream = socket.create_connection((host, int(port)))
|
|
60
|
+
client.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
|
61
|
+
self._pipe(client, upstream)
|
|
62
|
+
else:
|
|
63
|
+
from urllib.parse import urlparse
|
|
64
|
+
u = urlparse(target)
|
|
65
|
+
upstream = socket.create_connection((u.hostname, u.port or 80))
|
|
66
|
+
upstream.sendall(req)
|
|
67
|
+
self._pipe(client, upstream)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
finally:
|
|
71
|
+
client.close()
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _pipe(a, b):
|
|
75
|
+
try:
|
|
76
|
+
while True:
|
|
77
|
+
r, _, _ = select.select([a, b], [], [], 15)
|
|
78
|
+
if not r:
|
|
79
|
+
return
|
|
80
|
+
for s in r:
|
|
81
|
+
data = s.recv(65536)
|
|
82
|
+
if not data:
|
|
83
|
+
return
|
|
84
|
+
(b if s is a else a).sendall(data)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def close(self):
|
|
89
|
+
self._stop = True
|
|
90
|
+
try:
|
|
91
|
+
self._srv.close()
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def proxy_server():
|
|
98
|
+
srv = TunnelProxy()
|
|
99
|
+
yield srv
|
|
100
|
+
srv.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.fixture
|
|
104
|
+
def hooked(proxy_server):
|
|
105
|
+
ulak.proxy(proxy_server.host, proxy_server.port, verify=False, verbose=False)
|
|
106
|
+
yield proxy_server
|
|
107
|
+
ulak.off()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _installed(name):
|
|
111
|
+
return importlib.util.find_spec(name) is not None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
HTTPS = "https://example.com/"
|
|
115
|
+
HTTP = "http://example.com/"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------
|
|
119
|
+
# Live-traffic coverage, one test per client (skipped if not installed)
|
|
120
|
+
# --------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
@pytest.mark.skipif(not _installed("requests"), reason="requests not installed")
|
|
123
|
+
def test_requests(hooked):
|
|
124
|
+
import requests
|
|
125
|
+
assert requests.get(HTTPS, timeout=20).status_code == 200
|
|
126
|
+
assert requests.get(HTTP, timeout=20).status_code == 200
|
|
127
|
+
assert any("example.com" in t for _, t in hooked.seen)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_urllib(hooked):
|
|
131
|
+
import urllib.request
|
|
132
|
+
with urllib.request.urlopen(HTTPS, timeout=20) as r:
|
|
133
|
+
assert r.status == 200
|
|
134
|
+
assert hooked.seen
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.skipif(not _installed("urllib3"), reason="urllib3 not installed")
|
|
138
|
+
def test_urllib3(hooked):
|
|
139
|
+
import urllib3
|
|
140
|
+
assert urllib3.PoolManager().request("GET", HTTPS, timeout=20).status == 200
|
|
141
|
+
assert hooked.seen
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.skipif(not _installed("httpx"), reason="httpx not installed")
|
|
145
|
+
def test_httpx(hooked):
|
|
146
|
+
import httpx
|
|
147
|
+
assert httpx.get(HTTPS, timeout=20).status_code == 200
|
|
148
|
+
assert hooked.seen
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.skipif(not _installed("aiohttp"), reason="aiohttp not installed")
|
|
152
|
+
def test_aiohttp(hooked):
|
|
153
|
+
import aiohttp
|
|
154
|
+
|
|
155
|
+
async def go():
|
|
156
|
+
async with aiohttp.ClientSession() as s:
|
|
157
|
+
async with s.get(HTTPS, timeout=aiohttp.ClientTimeout(total=20)) as r:
|
|
158
|
+
return r.status
|
|
159
|
+
|
|
160
|
+
assert asyncio.run(go()) == 200
|
|
161
|
+
assert hooked.seen
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.skipif(not _installed("niquests"), reason="niquests not installed")
|
|
165
|
+
def test_niquests(hooked):
|
|
166
|
+
import niquests
|
|
167
|
+
assert niquests.get(HTTPS, timeout=20).status_code == 200
|
|
168
|
+
assert hooked.seen
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.skipif(not _installed("httplib2"), reason="httplib2 not installed")
|
|
172
|
+
def test_httplib2(hooked):
|
|
173
|
+
import httplib2
|
|
174
|
+
resp, _ = httplib2.Http().request(HTTPS, "GET")
|
|
175
|
+
assert resp.status == 200
|
|
176
|
+
assert hooked.seen
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.skipif(not _installed("curl_cffi"), reason="curl_cffi not installed")
|
|
180
|
+
def test_curl_cffi(hooked):
|
|
181
|
+
from curl_cffi import requests as cffi
|
|
182
|
+
assert cffi.get(HTTPS, timeout=20).status_code == 200
|
|
183
|
+
assert hooked.seen
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --------------------------------------------------------------------------
|
|
187
|
+
# State / lifecycle
|
|
188
|
+
# --------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def test_revert_restores_everything(proxy_server):
|
|
191
|
+
import ssl as _ssl
|
|
192
|
+
import urllib.request as ur
|
|
193
|
+
|
|
194
|
+
before = {}
|
|
195
|
+
if _installed("requests"):
|
|
196
|
+
from requests.sessions import Session
|
|
197
|
+
before["requests"] = Session.request
|
|
198
|
+
if _installed("urllib3"):
|
|
199
|
+
import urllib3
|
|
200
|
+
before["urllib3"] = urllib3.PoolManager
|
|
201
|
+
before["ssl"] = _ssl._create_default_https_context
|
|
202
|
+
before["opener"] = ur._opener
|
|
203
|
+
|
|
204
|
+
ulak.proxy(proxy_server.host, proxy_server.port, verify=False, verbose=False)
|
|
205
|
+
assert ulak.is_active()
|
|
206
|
+
ulak.off()
|
|
207
|
+
assert not ulak.is_active()
|
|
208
|
+
|
|
209
|
+
if "requests" in before:
|
|
210
|
+
from requests.sessions import Session
|
|
211
|
+
assert Session.request is before["requests"]
|
|
212
|
+
if "urllib3" in before:
|
|
213
|
+
import urllib3
|
|
214
|
+
assert urllib3.PoolManager is before["urllib3"]
|
|
215
|
+
assert _ssl._create_default_https_context is before["ssl"]
|
|
216
|
+
assert ur._opener is before["opener"]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_double_off_is_safe():
|
|
220
|
+
ulak.off()
|
|
221
|
+
ulak.off()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_context_manager(proxy_server):
|
|
225
|
+
import os
|
|
226
|
+
with ulak.proxied(proxy_server.host, proxy_server.port, verbose=False):
|
|
227
|
+
assert ulak.is_active()
|
|
228
|
+
assert os.environ.get("HTTP_PROXY")
|
|
229
|
+
assert not ulak.is_active()
|
|
230
|
+
assert os.environ.get("HTTP_PROXY") is None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_status_reports_patched(proxy_server):
|
|
234
|
+
st = ulak.proxy(proxy_server.host, proxy_server.port, verbose=False)
|
|
235
|
+
try:
|
|
236
|
+
assert "env" in st["patched"]
|
|
237
|
+
assert st["url"].endswith(str(proxy_server.port))
|
|
238
|
+
finally:
|
|
239
|
+
ulak.off()
|