hexproxy 0.2.2__py3-none-any.whl
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.
- hexproxy/__init__.py +7 -0
- hexproxy/__main__.py +5 -0
- hexproxy/app.py +192 -0
- hexproxy/bodyview.py +435 -0
- hexproxy/certs.py +222 -0
- hexproxy/clipboard.py +89 -0
- hexproxy/extensions.py +739 -0
- hexproxy/mcp.py +2114 -0
- hexproxy/models.py +72 -0
- hexproxy/preferences.py +131 -0
- hexproxy/proxy.py +1178 -0
- hexproxy/store.py +1001 -0
- hexproxy/themes.py +274 -0
- hexproxy/tui.py +8796 -0
- hexproxy-0.2.2.dist-info/METADATA +556 -0
- hexproxy-0.2.2.dist-info/RECORD +20 -0
- hexproxy-0.2.2.dist-info/WHEEL +5 -0
- hexproxy-0.2.2.dist-info/entry_points.txt +2 -0
- hexproxy-0.2.2.dist-info/licenses/LICENSE +37 -0
- hexproxy-0.2.2.dist-info/top_level.txt +1 -0
hexproxy/proxy.py
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import errno
|
|
6
|
+
import re
|
|
7
|
+
import select
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from .bodyview import normalize_http_body
|
|
15
|
+
from .certs import CertificateAuthority, default_certificate_dir
|
|
16
|
+
from .extensions import HookContext, PluginManager
|
|
17
|
+
from .models import HeaderList, MatchReplaceRule, RequestData, ResponseData
|
|
18
|
+
from .store import TrafficStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MAX_HEADER_BYTES = 1024 * 1024
|
|
22
|
+
LOCAL_PROXY_HOSTS = {"hexproxy", "hexproxy.local", "localhost", "127.0.0.1"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class ParsedRequest:
|
|
27
|
+
method: str
|
|
28
|
+
target: str
|
|
29
|
+
version: str
|
|
30
|
+
headers: HeaderList
|
|
31
|
+
body: bytes
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class ParsedResponse:
|
|
36
|
+
version: str
|
|
37
|
+
status_code: int
|
|
38
|
+
reason: str
|
|
39
|
+
headers: HeaderList
|
|
40
|
+
body: bytes
|
|
41
|
+
raw: bytes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class UpstreamTarget:
|
|
46
|
+
host: str
|
|
47
|
+
port: int
|
|
48
|
+
path: str
|
|
49
|
+
tls: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BufferedSocketReader:
|
|
53
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
54
|
+
self.sock = sock
|
|
55
|
+
self.buffer = bytearray()
|
|
56
|
+
|
|
57
|
+
def readuntil(self, marker: bytes) -> bytes:
|
|
58
|
+
while True:
|
|
59
|
+
index = self.buffer.find(marker)
|
|
60
|
+
if index >= 0:
|
|
61
|
+
end = index + len(marker)
|
|
62
|
+
chunk = bytes(self.buffer[:end])
|
|
63
|
+
del self.buffer[:end]
|
|
64
|
+
return chunk
|
|
65
|
+
|
|
66
|
+
data = self.sock.recv(65536)
|
|
67
|
+
if not data:
|
|
68
|
+
raise asyncio.IncompleteReadError(partial=bytes(self.buffer), expected=None)
|
|
69
|
+
self.buffer.extend(data)
|
|
70
|
+
|
|
71
|
+
def readexactly(self, length: int) -> bytes:
|
|
72
|
+
while len(self.buffer) < length:
|
|
73
|
+
data = self.sock.recv(65536)
|
|
74
|
+
if not data:
|
|
75
|
+
raise asyncio.IncompleteReadError(partial=bytes(self.buffer), expected=length)
|
|
76
|
+
self.buffer.extend(data)
|
|
77
|
+
|
|
78
|
+
chunk = bytes(self.buffer[:length])
|
|
79
|
+
del self.buffer[:length]
|
|
80
|
+
return chunk
|
|
81
|
+
|
|
82
|
+
def read(self) -> bytes:
|
|
83
|
+
chunks = [bytes(self.buffer)] if self.buffer else []
|
|
84
|
+
self.buffer.clear()
|
|
85
|
+
while True:
|
|
86
|
+
data = self.sock.recv(65536)
|
|
87
|
+
if not data:
|
|
88
|
+
return b"".join(chunks)
|
|
89
|
+
chunks.append(data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_request_text(raw_request: str) -> ParsedRequest:
|
|
93
|
+
normalized = raw_request.replace("\r\n", "\n").replace("\r", "\n")
|
|
94
|
+
if "\n\n" not in normalized:
|
|
95
|
+
raise ValueError("request is missing the blank line between headers and body")
|
|
96
|
+
|
|
97
|
+
head, body_text = normalized.split("\n\n", 1)
|
|
98
|
+
lines = head.split("\n")
|
|
99
|
+
if not lines or not lines[0].strip():
|
|
100
|
+
raise ValueError("request line is missing")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
method, target, version = lines[0].split(" ", 2)
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise ValueError(f"invalid request line: {lines[0]!r}") from exc
|
|
106
|
+
|
|
107
|
+
headers = HttpProxyServer._parse_headers(lines[1:])
|
|
108
|
+
return ParsedRequest(
|
|
109
|
+
method=method,
|
|
110
|
+
target=target,
|
|
111
|
+
version=version,
|
|
112
|
+
headers=headers,
|
|
113
|
+
body=body_text.encode("iso-8859-1"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render_request_text(request: ParsedRequest) -> str:
|
|
118
|
+
lines = [f"{request.method} {request.target} {request.version}"]
|
|
119
|
+
lines.extend(f"{name}: {value}" for name, value in request.headers)
|
|
120
|
+
head = "\n".join(lines)
|
|
121
|
+
body = request.body.decode("iso-8859-1", errors="replace")
|
|
122
|
+
return f"{head}\n\n{body}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_response_text(raw_response: str) -> ParsedResponse:
|
|
126
|
+
normalized = raw_response.replace("\r\n", "\n").replace("\r", "\n")
|
|
127
|
+
if "\n\n" not in normalized:
|
|
128
|
+
raise ValueError("response is missing the blank line between headers and body")
|
|
129
|
+
|
|
130
|
+
head, body_text = normalized.split("\n\n", 1)
|
|
131
|
+
lines = head.split("\n")
|
|
132
|
+
if not lines or not lines[0].strip():
|
|
133
|
+
raise ValueError("status line is missing")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
version, status_code, reason = lines[0].split(" ", 2)
|
|
137
|
+
except ValueError:
|
|
138
|
+
try:
|
|
139
|
+
version, status_code = lines[0].split(" ", 1)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
raise ValueError(f"invalid status line: {lines[0]!r}") from exc
|
|
142
|
+
reason = ""
|
|
143
|
+
|
|
144
|
+
headers = HttpProxyServer._parse_headers(lines[1:])
|
|
145
|
+
response = ParsedResponse(
|
|
146
|
+
version=version,
|
|
147
|
+
status_code=int(status_code),
|
|
148
|
+
reason=reason,
|
|
149
|
+
headers=headers,
|
|
150
|
+
body=body_text.encode("iso-8859-1"),
|
|
151
|
+
raw=b"",
|
|
152
|
+
)
|
|
153
|
+
response.raw = render_response_bytes(response)
|
|
154
|
+
return response
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_response_text(response: ParsedResponse) -> str:
|
|
158
|
+
status_line = f"{response.version} {response.status_code}"
|
|
159
|
+
if response.reason:
|
|
160
|
+
status_line = f"{status_line} {response.reason}"
|
|
161
|
+
lines = [status_line]
|
|
162
|
+
lines.extend(f"{name}: {value}" for name, value in response.headers)
|
|
163
|
+
head = "\n".join(lines)
|
|
164
|
+
body = response.body.decode("iso-8859-1", errors="replace")
|
|
165
|
+
return f"{head}\n\n{body}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_response_bytes(response: ParsedResponse) -> bytes:
|
|
169
|
+
headers: list[tuple[str, str]] = []
|
|
170
|
+
has_content_length = False
|
|
171
|
+
chunked = False
|
|
172
|
+
|
|
173
|
+
for name, value in response.headers:
|
|
174
|
+
lower_name = name.lower()
|
|
175
|
+
if lower_name == "content-length":
|
|
176
|
+
has_content_length = True
|
|
177
|
+
continue
|
|
178
|
+
if lower_name == "transfer-encoding" and "chunked" in value.lower():
|
|
179
|
+
chunked = True
|
|
180
|
+
headers.append((name, value))
|
|
181
|
+
|
|
182
|
+
if not chunked and (response.body or has_content_length):
|
|
183
|
+
headers.append(("Content-Length", str(len(response.body))))
|
|
184
|
+
|
|
185
|
+
status_line = f"{response.version} {response.status_code}"
|
|
186
|
+
if response.reason:
|
|
187
|
+
status_line = f"{status_line} {response.reason}"
|
|
188
|
+
lines = [status_line]
|
|
189
|
+
lines.extend(f"{name}: {value}" for name, value in headers)
|
|
190
|
+
return "\r\n".join(lines).encode("iso-8859-1") + b"\r\n\r\n" + response.body
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class HttpProxyServer:
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
store: TrafficStore,
|
|
197
|
+
listen_host: str = "127.0.0.1",
|
|
198
|
+
listen_port: int = 8080,
|
|
199
|
+
plugins: PluginManager | None = None,
|
|
200
|
+
certificate_authority: CertificateAuthority | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
self.store = store
|
|
203
|
+
self.listen_host = listen_host
|
|
204
|
+
self.listen_port = listen_port
|
|
205
|
+
self.plugins = plugins or PluginManager()
|
|
206
|
+
self.certificate_authority = certificate_authority or CertificateAuthority(default_certificate_dir())
|
|
207
|
+
self._server: asyncio.base_events.Server | None = None
|
|
208
|
+
self.startup_notice = ""
|
|
209
|
+
self._state_lock = threading.Lock()
|
|
210
|
+
self._client_writers: set[asyncio.StreamWriter] = set()
|
|
211
|
+
self._mitm_client_sockets: set[socket.socket] = set()
|
|
212
|
+
|
|
213
|
+
async def start(self) -> None:
|
|
214
|
+
requested_port = self.listen_port
|
|
215
|
+
candidate_ports = [0] if requested_port == 0 else [*range(requested_port, requested_port + 10), 0]
|
|
216
|
+
last_error: OSError | None = None
|
|
217
|
+
|
|
218
|
+
for candidate_port in candidate_ports:
|
|
219
|
+
try:
|
|
220
|
+
self._server = await asyncio.start_server(self._handle_client, self.listen_host, candidate_port)
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
last_error = exc
|
|
223
|
+
if exc.errno != errno.EADDRINUSE or requested_port == 0:
|
|
224
|
+
raise
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
socket = self._server.sockets[0]
|
|
228
|
+
self.listen_host, self.listen_port = socket.getsockname()[:2]
|
|
229
|
+
self.startup_notice = ""
|
|
230
|
+
if requested_port != 0 and self.listen_port != requested_port:
|
|
231
|
+
self.startup_notice = (
|
|
232
|
+
f"Port {requested_port} was busy. HexProxy is listening on {self.listen_host}:{self.listen_port}."
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
if last_error is not None and last_error.errno == errno.EADDRINUSE:
|
|
237
|
+
tried = ", ".join("auto" if port == 0 else str(port) for port in candidate_ports)
|
|
238
|
+
raise RuntimeError(
|
|
239
|
+
f"unable to bind {self.listen_host}; tried ports {tried} and all are already in use"
|
|
240
|
+
) from last_error
|
|
241
|
+
if last_error is not None:
|
|
242
|
+
raise last_error
|
|
243
|
+
raise RuntimeError("failed to start proxy server")
|
|
244
|
+
|
|
245
|
+
async def serve_forever(self) -> None:
|
|
246
|
+
if self._server is None:
|
|
247
|
+
raise RuntimeError("proxy server not started")
|
|
248
|
+
async with self._server:
|
|
249
|
+
await self._server.serve_forever()
|
|
250
|
+
|
|
251
|
+
async def stop(self) -> None:
|
|
252
|
+
self.store.release_pending_interceptions()
|
|
253
|
+
server = self._server
|
|
254
|
+
self._server = None
|
|
255
|
+
if server is not None:
|
|
256
|
+
server.close()
|
|
257
|
+
await server.wait_closed()
|
|
258
|
+
|
|
259
|
+
with self._state_lock:
|
|
260
|
+
client_writers = list(self._client_writers)
|
|
261
|
+
mitm_sockets = list(self._mitm_client_sockets)
|
|
262
|
+
|
|
263
|
+
for writer in client_writers:
|
|
264
|
+
writer.close()
|
|
265
|
+
if client_writers:
|
|
266
|
+
await asyncio.gather(*(writer.wait_closed() for writer in client_writers), return_exceptions=True)
|
|
267
|
+
|
|
268
|
+
for sock in mitm_sockets:
|
|
269
|
+
try:
|
|
270
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
271
|
+
except OSError:
|
|
272
|
+
pass
|
|
273
|
+
try:
|
|
274
|
+
sock.close()
|
|
275
|
+
except OSError:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
279
|
+
self._register_client_writer(writer)
|
|
280
|
+
peername = writer.get_extra_info("peername")
|
|
281
|
+
client_addr = self._format_peer(peername)
|
|
282
|
+
entry_id = self.store.create_entry(client_addr)
|
|
283
|
+
context = HookContext(
|
|
284
|
+
entry_id=entry_id,
|
|
285
|
+
client_addr=client_addr,
|
|
286
|
+
store=self.store,
|
|
287
|
+
plugin_manager=self.plugins,
|
|
288
|
+
)
|
|
289
|
+
response_sent = False
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
request = await self._read_request(reader)
|
|
293
|
+
local_response = self._build_local_response(request)
|
|
294
|
+
if local_response is not None:
|
|
295
|
+
local_target = UpstreamTarget(host="hexproxy", port=80, path=self._request_path(request))
|
|
296
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, local_target))
|
|
297
|
+
writer.write(local_response.raw)
|
|
298
|
+
await writer.drain()
|
|
299
|
+
self.store.mutate(entry_id, lambda entry: self._record_response(entry, local_response, local_target))
|
|
300
|
+
response_sent = True
|
|
301
|
+
return
|
|
302
|
+
if request.method.upper() == "CONNECT":
|
|
303
|
+
response_sent = True
|
|
304
|
+
await self._handle_connect_tunnel(
|
|
305
|
+
reader=reader,
|
|
306
|
+
writer=writer,
|
|
307
|
+
entry_id=entry_id,
|
|
308
|
+
context=context,
|
|
309
|
+
connect_request=request,
|
|
310
|
+
)
|
|
311
|
+
return
|
|
312
|
+
await self._forward_exchange(
|
|
313
|
+
client_reader=reader,
|
|
314
|
+
client_writer=writer,
|
|
315
|
+
request=request,
|
|
316
|
+
entry_id=entry_id,
|
|
317
|
+
context=context,
|
|
318
|
+
)
|
|
319
|
+
response_sent = True
|
|
320
|
+
except asyncio.IncompleteReadError as exc:
|
|
321
|
+
message = self._describe_incomplete_read(exc)
|
|
322
|
+
self.store.mutate(entry_id, lambda entry: self._record_error(entry, message))
|
|
323
|
+
if not response_sent and not self._looks_like_tls_handshake(exc.partial):
|
|
324
|
+
await self._write_simple_response(writer, 400, "Bad Request", b"Malformed HTTP message.\n")
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
self.plugins.on_error(context, exc)
|
|
327
|
+
self.plugins.persist_hook_context(context)
|
|
328
|
+
self.store.mutate(entry_id, lambda entry: self._record_error(entry, str(exc)))
|
|
329
|
+
if not response_sent:
|
|
330
|
+
await self._write_simple_response(writer, 502, "Bad Gateway", b"Upstream request failed.\n")
|
|
331
|
+
finally:
|
|
332
|
+
self._unregister_client_writer(writer)
|
|
333
|
+
writer.close()
|
|
334
|
+
await writer.wait_closed()
|
|
335
|
+
self.store.complete(entry_id)
|
|
336
|
+
|
|
337
|
+
async def _read_request(self, reader: asyncio.StreamReader) -> ParsedRequest:
|
|
338
|
+
head = await self._read_head(reader)
|
|
339
|
+
lines = head.decode("iso-8859-1").split("\r\n")
|
|
340
|
+
headers = self._parse_headers(lines[1:])
|
|
341
|
+
body = await self._read_body(reader, headers)
|
|
342
|
+
return parse_request_text((head + b"\r\n\r\n" + body).decode("iso-8859-1"))
|
|
343
|
+
|
|
344
|
+
async def _read_response(self, reader: asyncio.StreamReader, request: ParsedRequest | None = None) -> ParsedResponse:
|
|
345
|
+
head = await self._read_head(reader)
|
|
346
|
+
lines = head.decode("iso-8859-1").split("\r\n")
|
|
347
|
+
status_line = lines[0]
|
|
348
|
+
try:
|
|
349
|
+
version, status_code, reason = status_line.split(" ", 2)
|
|
350
|
+
except ValueError:
|
|
351
|
+
version, status_code = status_line.split(" ", 1)
|
|
352
|
+
reason = ""
|
|
353
|
+
|
|
354
|
+
headers = self._parse_headers(lines[1:])
|
|
355
|
+
parsed_status_code = int(status_code)
|
|
356
|
+
if self._response_has_body(parsed_status_code, headers, request):
|
|
357
|
+
body = await self._read_body(reader, headers, is_response=True)
|
|
358
|
+
else:
|
|
359
|
+
body = b""
|
|
360
|
+
return ParsedResponse(
|
|
361
|
+
version=version,
|
|
362
|
+
status_code=parsed_status_code,
|
|
363
|
+
reason=reason,
|
|
364
|
+
headers=headers,
|
|
365
|
+
body=body,
|
|
366
|
+
raw=head + b"\r\n\r\n" + body,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
async def _read_head(self, reader: asyncio.StreamReader) -> bytes:
|
|
370
|
+
try:
|
|
371
|
+
raw = await reader.readuntil(b"\r\n\r\n")
|
|
372
|
+
except asyncio.LimitOverrunError as exc:
|
|
373
|
+
raise ValueError("header section too large") from exc
|
|
374
|
+
if len(raw) > MAX_HEADER_BYTES:
|
|
375
|
+
raise ValueError("header section too large")
|
|
376
|
+
return raw[:-4]
|
|
377
|
+
|
|
378
|
+
async def _read_body(self, reader: asyncio.StreamReader, headers: HeaderList, is_response: bool = False) -> bytes:
|
|
379
|
+
header_map = {name.lower(): value for name, value in headers}
|
|
380
|
+
transfer_encoding = header_map.get("transfer-encoding", "").lower()
|
|
381
|
+
|
|
382
|
+
if "chunked" in transfer_encoding:
|
|
383
|
+
return await self._read_chunked_body(reader)
|
|
384
|
+
|
|
385
|
+
content_length = header_map.get("content-length")
|
|
386
|
+
if content_length is not None:
|
|
387
|
+
length = int(content_length)
|
|
388
|
+
if length == 0:
|
|
389
|
+
return b""
|
|
390
|
+
return await reader.readexactly(length)
|
|
391
|
+
|
|
392
|
+
if is_response:
|
|
393
|
+
return await reader.read()
|
|
394
|
+
|
|
395
|
+
return b""
|
|
396
|
+
|
|
397
|
+
async def _read_chunked_body(self, reader: asyncio.StreamReader) -> bytes:
|
|
398
|
+
chunks: list[bytes] = []
|
|
399
|
+
while True:
|
|
400
|
+
line = await reader.readuntil(b"\r\n")
|
|
401
|
+
chunks.append(line)
|
|
402
|
+
chunk_size = int(line.split(b";", 1)[0].strip(), 16)
|
|
403
|
+
if chunk_size == 0:
|
|
404
|
+
trailer = await reader.readuntil(b"\r\n")
|
|
405
|
+
chunks.append(trailer)
|
|
406
|
+
break
|
|
407
|
+
chunk = await reader.readexactly(chunk_size + 2)
|
|
408
|
+
chunks.append(chunk)
|
|
409
|
+
return b"".join(chunks)
|
|
410
|
+
|
|
411
|
+
def _resolve_target(self, request: ParsedRequest) -> UpstreamTarget:
|
|
412
|
+
if request.method.upper() == "CONNECT":
|
|
413
|
+
return self._resolve_connect_target(request)
|
|
414
|
+
|
|
415
|
+
lowered_target = request.target.lower()
|
|
416
|
+
if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
|
|
417
|
+
parsed = urlsplit(request.target)
|
|
418
|
+
host = parsed.hostname
|
|
419
|
+
if not host:
|
|
420
|
+
raise ValueError("request target does not include a host")
|
|
421
|
+
tls = parsed.scheme.lower() in {"https", "wss"}
|
|
422
|
+
port = parsed.port or (443 if tls else 80)
|
|
423
|
+
path = self._origin_form(parsed.path, parsed.query)
|
|
424
|
+
return UpstreamTarget(host=host, port=port, path=path, tls=tls)
|
|
425
|
+
|
|
426
|
+
host_header = self._find_header(request.headers, "Host")
|
|
427
|
+
if not host_header:
|
|
428
|
+
raise ValueError("missing Host header")
|
|
429
|
+
if ":" in host_header:
|
|
430
|
+
host, port_text = host_header.rsplit(":", 1)
|
|
431
|
+
port = int(port_text)
|
|
432
|
+
else:
|
|
433
|
+
host = host_header
|
|
434
|
+
port = 80
|
|
435
|
+
return UpstreamTarget(host=host, port=port, path=request.target or "/")
|
|
436
|
+
|
|
437
|
+
def _resolve_connect_target(self, request: ParsedRequest) -> UpstreamTarget:
|
|
438
|
+
if ":" not in request.target:
|
|
439
|
+
raise ValueError("CONNECT target must be host:port")
|
|
440
|
+
host, port_text = request.target.rsplit(":", 1)
|
|
441
|
+
if not host:
|
|
442
|
+
raise ValueError("CONNECT target host is missing")
|
|
443
|
+
return UpstreamTarget(host=host, port=int(port_text), path="/", tls=True)
|
|
444
|
+
|
|
445
|
+
def _build_upstream_request(self, request: ParsedRequest, target: UpstreamTarget) -> bytes:
|
|
446
|
+
headers: list[tuple[str, str]] = []
|
|
447
|
+
websocket_upgrade = self._is_websocket_request(request)
|
|
448
|
+
skip = {"proxy-connection", "content-length", "accept-encoding"}
|
|
449
|
+
if not websocket_upgrade:
|
|
450
|
+
skip.add("connection")
|
|
451
|
+
host_header_written = False
|
|
452
|
+
has_content_length = False
|
|
453
|
+
chunked = False
|
|
454
|
+
default_port = 443 if target.tls else 80
|
|
455
|
+
|
|
456
|
+
for name, value in request.headers:
|
|
457
|
+
lower_name = name.lower()
|
|
458
|
+
if lower_name in skip:
|
|
459
|
+
if lower_name == "content-length":
|
|
460
|
+
has_content_length = True
|
|
461
|
+
continue
|
|
462
|
+
if lower_name == "transfer-encoding" and "chunked" in value.lower():
|
|
463
|
+
chunked = True
|
|
464
|
+
if lower_name == "host":
|
|
465
|
+
host_header_written = True
|
|
466
|
+
value = target.host if target.port == default_port else f"{target.host}:{target.port}"
|
|
467
|
+
headers.append((name, value))
|
|
468
|
+
|
|
469
|
+
if not host_header_written:
|
|
470
|
+
host_value = target.host if target.port == default_port else f"{target.host}:{target.port}"
|
|
471
|
+
headers.append(("Host", host_value))
|
|
472
|
+
if not websocket_upgrade:
|
|
473
|
+
headers.append(("Accept-Encoding", "identity"))
|
|
474
|
+
if not chunked and (request.body or has_content_length):
|
|
475
|
+
headers.append(("Content-Length", str(len(request.body))))
|
|
476
|
+
if not websocket_upgrade:
|
|
477
|
+
headers.append(("Connection", "close"))
|
|
478
|
+
|
|
479
|
+
lines = [f"{request.method} {target.path} {request.version}"]
|
|
480
|
+
lines.extend(f"{name}: {value}" for name, value in headers)
|
|
481
|
+
head = "\r\n".join(lines).encode("iso-8859-1") + b"\r\n\r\n"
|
|
482
|
+
return head + request.body
|
|
483
|
+
|
|
484
|
+
async def _forward_exchange(
|
|
485
|
+
self,
|
|
486
|
+
client_reader: asyncio.StreamReader,
|
|
487
|
+
client_writer: asyncio.StreamWriter,
|
|
488
|
+
request: ParsedRequest,
|
|
489
|
+
entry_id: int,
|
|
490
|
+
context: HookContext,
|
|
491
|
+
fixed_target: UpstreamTarget | None = None,
|
|
492
|
+
) -> None:
|
|
493
|
+
target = fixed_target or self._resolve_target(request)
|
|
494
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, target))
|
|
495
|
+
|
|
496
|
+
if self.store.begin_interception(entry_id, "request", render_request_text(request), host=target.host):
|
|
497
|
+
interception = await asyncio.to_thread(self.store.wait_for_interception, entry_id)
|
|
498
|
+
if interception.decision == "drop":
|
|
499
|
+
await self._write_simple_response(client_writer, 403, "Forbidden", b"Request dropped by interceptor.\n")
|
|
500
|
+
return
|
|
501
|
+
request = parse_request_text(interception.raw_text)
|
|
502
|
+
if fixed_target is not None:
|
|
503
|
+
target = self._target_for_fixed_tunnel(request, fixed_target)
|
|
504
|
+
else:
|
|
505
|
+
target = self._resolve_target(request)
|
|
506
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, target))
|
|
507
|
+
|
|
508
|
+
request = self.plugins.before_request_forward(context, request)
|
|
509
|
+
request = self._apply_match_replace_to_request(request)
|
|
510
|
+
if fixed_target is not None:
|
|
511
|
+
target = self._target_for_fixed_tunnel(request, fixed_target)
|
|
512
|
+
else:
|
|
513
|
+
target = self._resolve_target(request)
|
|
514
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, target))
|
|
515
|
+
|
|
516
|
+
upstream_reader, upstream_writer = await self._open_upstream_connection(target)
|
|
517
|
+
try:
|
|
518
|
+
upstream_request = self._build_upstream_request(request, target)
|
|
519
|
+
upstream_writer.write(upstream_request)
|
|
520
|
+
await upstream_writer.drain()
|
|
521
|
+
|
|
522
|
+
response = await self._read_response(upstream_reader, request=request)
|
|
523
|
+
self.plugins.on_response_received(context, request, response)
|
|
524
|
+
self.plugins.persist_hook_context(context)
|
|
525
|
+
response = self._apply_match_replace_to_response(response)
|
|
526
|
+
self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, target))
|
|
527
|
+
|
|
528
|
+
editable_response = self._response_for_interception(response)
|
|
529
|
+
if self.store.begin_interception(entry_id, "response", render_response_text(editable_response), host=target.host):
|
|
530
|
+
interception = await asyncio.to_thread(self.store.wait_for_interception, entry_id)
|
|
531
|
+
if interception.decision == "drop":
|
|
532
|
+
dropped_response = self._build_static_response(
|
|
533
|
+
status_code=502,
|
|
534
|
+
reason="Bad Gateway",
|
|
535
|
+
headers=[("Content-Type", "text/plain; charset=utf-8")],
|
|
536
|
+
body=b"Response dropped by interceptor.\n",
|
|
537
|
+
)
|
|
538
|
+
client_writer.write(dropped_response.raw)
|
|
539
|
+
await client_writer.drain()
|
|
540
|
+
self.store.mutate(entry_id, lambda entry: self._record_error(entry, "response dropped by interceptor"))
|
|
541
|
+
return
|
|
542
|
+
response = parse_response_text(interception.raw_text)
|
|
543
|
+
self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, target))
|
|
544
|
+
|
|
545
|
+
client_writer.write(response.raw)
|
|
546
|
+
await client_writer.drain()
|
|
547
|
+
if self._is_websocket_upgrade(request, response):
|
|
548
|
+
self.store.mutate(entry_id, lambda entry: self._mark_streaming(entry))
|
|
549
|
+
await self._relay_bidirectional(client_reader, client_writer, upstream_reader, upstream_writer)
|
|
550
|
+
self.store.mutate(entry_id, lambda entry: self._mark_complete(entry))
|
|
551
|
+
finally:
|
|
552
|
+
upstream_writer.close()
|
|
553
|
+
await upstream_writer.wait_closed()
|
|
554
|
+
|
|
555
|
+
async def _handle_connect_tunnel(
|
|
556
|
+
self,
|
|
557
|
+
reader: asyncio.StreamReader,
|
|
558
|
+
writer: asyncio.StreamWriter,
|
|
559
|
+
entry_id: int,
|
|
560
|
+
context: HookContext,
|
|
561
|
+
connect_request: ParsedRequest,
|
|
562
|
+
) -> None:
|
|
563
|
+
target = self._resolve_connect_target(connect_request)
|
|
564
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, connect_request, target))
|
|
565
|
+
await self._write_connect_established(writer)
|
|
566
|
+
response = self._build_static_response(
|
|
567
|
+
status_code=200,
|
|
568
|
+
reason="Connection Established",
|
|
569
|
+
headers=[("Connection", "keep-alive")],
|
|
570
|
+
body=b"",
|
|
571
|
+
)
|
|
572
|
+
self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, target))
|
|
573
|
+
self.store.mutate(entry_id, lambda entry: self._mark_streaming(entry))
|
|
574
|
+
|
|
575
|
+
transport = getattr(writer, "_transport", None)
|
|
576
|
+
raw_socket = writer.get_extra_info("socket")
|
|
577
|
+
if raw_socket is None or transport is None:
|
|
578
|
+
raise RuntimeError("unable to access the underlying CONNECT socket")
|
|
579
|
+
transport.pause_reading()
|
|
580
|
+
client_socket = raw_socket.dup()
|
|
581
|
+
client_socket.setblocking(True)
|
|
582
|
+
self._register_mitm_socket(client_socket)
|
|
583
|
+
|
|
584
|
+
try:
|
|
585
|
+
await asyncio.to_thread(self._run_connect_mitm_session, client_socket, target, context.client_addr)
|
|
586
|
+
finally:
|
|
587
|
+
self._unregister_mitm_socket(client_socket)
|
|
588
|
+
self.store.mutate(entry_id, lambda entry: self._mark_complete(entry))
|
|
589
|
+
|
|
590
|
+
async def _open_upstream_connection(self, target: UpstreamTarget) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
591
|
+
if target.tls:
|
|
592
|
+
ssl_context = ssl._create_unverified_context()
|
|
593
|
+
return await asyncio.open_connection(target.host, target.port, ssl=ssl_context, server_hostname=target.host)
|
|
594
|
+
return await asyncio.open_connection(target.host, target.port)
|
|
595
|
+
|
|
596
|
+
async def replay_request(self, raw_request: str) -> str:
|
|
597
|
+
request = parse_request_text(raw_request)
|
|
598
|
+
if request.method.upper() == "CONNECT":
|
|
599
|
+
raise ValueError("repeater does not support CONNECT requests")
|
|
600
|
+
if self._is_websocket_request(request):
|
|
601
|
+
raise ValueError("repeater does not support WebSocket upgrade requests")
|
|
602
|
+
|
|
603
|
+
target = self._resolve_target(request)
|
|
604
|
+
upstream_reader, upstream_writer = await self._open_upstream_connection(target)
|
|
605
|
+
try:
|
|
606
|
+
upstream_writer.write(self._build_upstream_request(request, target))
|
|
607
|
+
await upstream_writer.drain()
|
|
608
|
+
response = await self._read_response(upstream_reader, request=request)
|
|
609
|
+
return render_response_text(self._response_for_interception(response))
|
|
610
|
+
finally:
|
|
611
|
+
upstream_writer.close()
|
|
612
|
+
await upstream_writer.wait_closed()
|
|
613
|
+
|
|
614
|
+
async def _relay_bidirectional(
|
|
615
|
+
self,
|
|
616
|
+
client_reader: asyncio.StreamReader,
|
|
617
|
+
client_writer: asyncio.StreamWriter,
|
|
618
|
+
upstream_reader: asyncio.StreamReader,
|
|
619
|
+
upstream_writer: asyncio.StreamWriter,
|
|
620
|
+
) -> None:
|
|
621
|
+
async def _pipe(source: asyncio.StreamReader, destination: asyncio.StreamWriter) -> None:
|
|
622
|
+
while True:
|
|
623
|
+
chunk = await source.read(65536)
|
|
624
|
+
if not chunk:
|
|
625
|
+
break
|
|
626
|
+
destination.write(chunk)
|
|
627
|
+
await destination.drain()
|
|
628
|
+
try:
|
|
629
|
+
destination.write_eof()
|
|
630
|
+
except (AttributeError, OSError, RuntimeError):
|
|
631
|
+
pass
|
|
632
|
+
|
|
633
|
+
await asyncio.gather(
|
|
634
|
+
_pipe(client_reader, upstream_writer),
|
|
635
|
+
_pipe(upstream_reader, client_writer),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def _run_connect_mitm_session(
|
|
639
|
+
self,
|
|
640
|
+
client_socket: socket.socket,
|
|
641
|
+
connect_target: UpstreamTarget,
|
|
642
|
+
client_addr: str,
|
|
643
|
+
) -> None:
|
|
644
|
+
try:
|
|
645
|
+
try:
|
|
646
|
+
client_tls = self._wrap_client_tls_socket(client_socket, connect_target.host)
|
|
647
|
+
except ssl.SSLError as exc:
|
|
648
|
+
if self._is_client_certificate_rejection(exc):
|
|
649
|
+
raise RuntimeError(
|
|
650
|
+
"client rejected the HexProxy TLS certificate; re-import the current HexProxy CA"
|
|
651
|
+
) from exc
|
|
652
|
+
raise
|
|
653
|
+
client_reader = BufferedSocketReader(client_tls)
|
|
654
|
+
|
|
655
|
+
while True:
|
|
656
|
+
try:
|
|
657
|
+
request = self._read_request_from_socket(client_reader)
|
|
658
|
+
except asyncio.IncompleteReadError as exc:
|
|
659
|
+
if not exc.partial:
|
|
660
|
+
return
|
|
661
|
+
raise
|
|
662
|
+
|
|
663
|
+
entry_id = self.store.create_entry(client_addr)
|
|
664
|
+
context = HookContext(
|
|
665
|
+
entry_id=entry_id,
|
|
666
|
+
client_addr=client_addr,
|
|
667
|
+
store=self.store,
|
|
668
|
+
plugin_manager=self.plugins,
|
|
669
|
+
)
|
|
670
|
+
fixed_target = UpstreamTarget(
|
|
671
|
+
host=connect_target.host,
|
|
672
|
+
port=connect_target.port,
|
|
673
|
+
path=self._request_path(request),
|
|
674
|
+
tls=True,
|
|
675
|
+
)
|
|
676
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, fixed_target))
|
|
677
|
+
|
|
678
|
+
if self.store.begin_interception(
|
|
679
|
+
entry_id,
|
|
680
|
+
"request",
|
|
681
|
+
render_request_text(request),
|
|
682
|
+
host=fixed_target.host,
|
|
683
|
+
):
|
|
684
|
+
interception = self.store.wait_for_interception(entry_id)
|
|
685
|
+
if interception.decision == "drop":
|
|
686
|
+
response = self._build_static_response(
|
|
687
|
+
status_code=403,
|
|
688
|
+
reason="Forbidden",
|
|
689
|
+
headers=[("Content-Type", "text/plain; charset=utf-8")],
|
|
690
|
+
body=b"Request dropped by interceptor.\n",
|
|
691
|
+
)
|
|
692
|
+
client_tls.sendall(response.raw)
|
|
693
|
+
self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, fixed_target))
|
|
694
|
+
self.store.complete(entry_id)
|
|
695
|
+
continue
|
|
696
|
+
request = parse_request_text(interception.raw_text)
|
|
697
|
+
fixed_target = self._target_for_fixed_tunnel(request, fixed_target)
|
|
698
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, fixed_target))
|
|
699
|
+
|
|
700
|
+
request = self.plugins.before_request_forward(context, request)
|
|
701
|
+
request = self._apply_match_replace_to_request(request)
|
|
702
|
+
fixed_target = self._target_for_fixed_tunnel(request, fixed_target)
|
|
703
|
+
self.store.mutate(entry_id, lambda entry: self._record_request(entry, request, fixed_target))
|
|
704
|
+
|
|
705
|
+
with self._open_sync_upstream_tls_socket(connect_target.host, connect_target.port) as upstream_tls:
|
|
706
|
+
upstream_reader = BufferedSocketReader(upstream_tls)
|
|
707
|
+
upstream_tls.sendall(self._build_upstream_request(request, fixed_target))
|
|
708
|
+
response = self._read_response_from_socket(upstream_reader, request=request)
|
|
709
|
+
self.plugins.on_response_received(context, request, response)
|
|
710
|
+
self.plugins.persist_hook_context(context)
|
|
711
|
+
response = self._apply_match_replace_to_response(response)
|
|
712
|
+
self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, fixed_target))
|
|
713
|
+
|
|
714
|
+
editable_response = self._response_for_interception(response)
|
|
715
|
+
if self.store.begin_interception(
|
|
716
|
+
entry_id,
|
|
717
|
+
"response",
|
|
718
|
+
render_response_text(editable_response),
|
|
719
|
+
host=fixed_target.host,
|
|
720
|
+
):
|
|
721
|
+
interception = self.store.wait_for_interception(entry_id)
|
|
722
|
+
if interception.decision == "drop":
|
|
723
|
+
dropped_response = self._build_static_response(
|
|
724
|
+
status_code=502,
|
|
725
|
+
reason="Bad Gateway",
|
|
726
|
+
headers=[("Content-Type", "text/plain; charset=utf-8")],
|
|
727
|
+
body=b"Response dropped by interceptor.\n",
|
|
728
|
+
)
|
|
729
|
+
client_tls.sendall(dropped_response.raw)
|
|
730
|
+
self.store.mutate(
|
|
731
|
+
entry_id,
|
|
732
|
+
lambda entry: self._record_error(entry, "response dropped by interceptor"),
|
|
733
|
+
)
|
|
734
|
+
self.store.complete(entry_id)
|
|
735
|
+
continue
|
|
736
|
+
response = parse_response_text(interception.raw_text)
|
|
737
|
+
self.store.mutate(entry_id, lambda entry: self._record_response(entry, response, fixed_target))
|
|
738
|
+
|
|
739
|
+
client_tls.sendall(response.raw)
|
|
740
|
+
|
|
741
|
+
if self._is_websocket_upgrade(request, response):
|
|
742
|
+
self.store.mutate(entry_id, lambda entry: self._mark_streaming(entry))
|
|
743
|
+
self._relay_socket_bidirectional(client_tls, upstream_tls)
|
|
744
|
+
self.store.mutate(entry_id, lambda entry: self._mark_complete(entry))
|
|
745
|
+
self.store.complete(entry_id)
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
self.store.complete(entry_id)
|
|
749
|
+
finally:
|
|
750
|
+
client_socket.close()
|
|
751
|
+
|
|
752
|
+
@staticmethod
|
|
753
|
+
def _is_client_certificate_rejection(exc: ssl.SSLError) -> bool:
|
|
754
|
+
parts = [str(exc), *(str(part) for part in exc.args)]
|
|
755
|
+
message = " ".join(parts).lower().replace("_", " ")
|
|
756
|
+
return "bad certificate" in message or "unknown ca" in message or "certificate unknown" in message
|
|
757
|
+
|
|
758
|
+
def _register_client_writer(self, writer: asyncio.StreamWriter) -> None:
|
|
759
|
+
with self._state_lock:
|
|
760
|
+
self._client_writers.add(writer)
|
|
761
|
+
|
|
762
|
+
def _unregister_client_writer(self, writer: asyncio.StreamWriter) -> None:
|
|
763
|
+
with self._state_lock:
|
|
764
|
+
self._client_writers.discard(writer)
|
|
765
|
+
|
|
766
|
+
def _register_mitm_socket(self, sock: socket.socket) -> None:
|
|
767
|
+
with self._state_lock:
|
|
768
|
+
self._mitm_client_sockets.add(sock)
|
|
769
|
+
|
|
770
|
+
def _unregister_mitm_socket(self, sock: socket.socket) -> None:
|
|
771
|
+
with self._state_lock:
|
|
772
|
+
self._mitm_client_sockets.discard(sock)
|
|
773
|
+
|
|
774
|
+
def _wrap_client_tls_socket(self, client_socket: socket.socket, host: str) -> ssl.SSLSocket:
|
|
775
|
+
cert_path, key_path = self.certificate_authority.issue_server_cert(host)
|
|
776
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
777
|
+
context.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path))
|
|
778
|
+
context.set_alpn_protocols(["http/1.1"])
|
|
779
|
+
return context.wrap_socket(client_socket, server_side=True)
|
|
780
|
+
|
|
781
|
+
def _open_sync_upstream_tls_socket(self, host: str, port: int) -> ssl.SSLSocket:
|
|
782
|
+
raw_socket = socket.create_connection((host, port))
|
|
783
|
+
context = ssl.create_default_context()
|
|
784
|
+
return context.wrap_socket(raw_socket, server_hostname=host)
|
|
785
|
+
|
|
786
|
+
def _read_request_from_socket(self, reader: BufferedSocketReader) -> ParsedRequest:
|
|
787
|
+
head = reader.readuntil(b"\r\n\r\n")
|
|
788
|
+
lines = head[:-4].decode("iso-8859-1").split("\r\n")
|
|
789
|
+
headers = self._parse_headers(lines[1:])
|
|
790
|
+
body = self._read_body_from_socket(reader, headers)
|
|
791
|
+
return parse_request_text((head[:-4] + b"\r\n\r\n" + body).decode("iso-8859-1"))
|
|
792
|
+
|
|
793
|
+
def _read_response_from_socket(
|
|
794
|
+
self,
|
|
795
|
+
reader: BufferedSocketReader,
|
|
796
|
+
request: ParsedRequest | None = None,
|
|
797
|
+
) -> ParsedResponse:
|
|
798
|
+
head = reader.readuntil(b"\r\n\r\n")
|
|
799
|
+
lines = head[:-4].decode("iso-8859-1").split("\r\n")
|
|
800
|
+
status_line = lines[0]
|
|
801
|
+
try:
|
|
802
|
+
version, status_code, reason = status_line.split(" ", 2)
|
|
803
|
+
except ValueError:
|
|
804
|
+
version, status_code = status_line.split(" ", 1)
|
|
805
|
+
reason = ""
|
|
806
|
+
|
|
807
|
+
headers = self._parse_headers(lines[1:])
|
|
808
|
+
parsed_status_code = int(status_code)
|
|
809
|
+
if self._response_has_body(parsed_status_code, headers, request):
|
|
810
|
+
body = self._read_body_from_socket(reader, headers, is_response=True)
|
|
811
|
+
else:
|
|
812
|
+
body = b""
|
|
813
|
+
return ParsedResponse(
|
|
814
|
+
version=version,
|
|
815
|
+
status_code=parsed_status_code,
|
|
816
|
+
reason=reason,
|
|
817
|
+
headers=headers,
|
|
818
|
+
body=body,
|
|
819
|
+
raw=head[:-4] + b"\r\n\r\n" + body,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
def _read_body_from_socket(
|
|
823
|
+
self,
|
|
824
|
+
reader: BufferedSocketReader,
|
|
825
|
+
headers: HeaderList,
|
|
826
|
+
is_response: bool = False,
|
|
827
|
+
) -> bytes:
|
|
828
|
+
header_map = {name.lower(): value for name, value in headers}
|
|
829
|
+
transfer_encoding = header_map.get("transfer-encoding", "").lower()
|
|
830
|
+
|
|
831
|
+
if "chunked" in transfer_encoding:
|
|
832
|
+
return self._read_chunked_body_from_socket(reader)
|
|
833
|
+
|
|
834
|
+
content_length = header_map.get("content-length")
|
|
835
|
+
if content_length is not None:
|
|
836
|
+
length = int(content_length)
|
|
837
|
+
if length == 0:
|
|
838
|
+
return b""
|
|
839
|
+
return reader.readexactly(length)
|
|
840
|
+
|
|
841
|
+
if is_response:
|
|
842
|
+
return reader.read()
|
|
843
|
+
return b""
|
|
844
|
+
|
|
845
|
+
def _read_chunked_body_from_socket(self, reader: BufferedSocketReader) -> bytes:
|
|
846
|
+
chunks: list[bytes] = []
|
|
847
|
+
while True:
|
|
848
|
+
line = reader.readuntil(b"\r\n")
|
|
849
|
+
chunks.append(line)
|
|
850
|
+
chunk_size = int(line.split(b";", 1)[0].strip(), 16)
|
|
851
|
+
if chunk_size == 0:
|
|
852
|
+
trailer = reader.readuntil(b"\r\n")
|
|
853
|
+
chunks.append(trailer)
|
|
854
|
+
break
|
|
855
|
+
chunk = reader.readexactly(chunk_size + 2)
|
|
856
|
+
chunks.append(chunk)
|
|
857
|
+
return b"".join(chunks)
|
|
858
|
+
|
|
859
|
+
def _relay_socket_bidirectional(self, client_socket: socket.socket, upstream_socket: socket.socket) -> None:
|
|
860
|
+
sockets = [client_socket, upstream_socket]
|
|
861
|
+
peer_map = {
|
|
862
|
+
client_socket.fileno(): upstream_socket,
|
|
863
|
+
upstream_socket.fileno(): client_socket,
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
while True:
|
|
867
|
+
readable, _, _ = select.select(sockets, [], [])
|
|
868
|
+
for current in readable:
|
|
869
|
+
chunk = current.recv(65536)
|
|
870
|
+
if not chunk:
|
|
871
|
+
return
|
|
872
|
+
peer_map[current.fileno()].sendall(chunk)
|
|
873
|
+
|
|
874
|
+
def _target_for_fixed_tunnel(self, request: ParsedRequest, fixed_target: UpstreamTarget) -> UpstreamTarget:
|
|
875
|
+
path = request.target or "/"
|
|
876
|
+
lowered_target = request.target.lower()
|
|
877
|
+
if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
|
|
878
|
+
path = self._resolve_target(request).path
|
|
879
|
+
return UpstreamTarget(host=fixed_target.host, port=fixed_target.port, path=path, tls=fixed_target.tls)
|
|
880
|
+
|
|
881
|
+
def _response_for_interception(self, response: ParsedResponse) -> ParsedResponse:
|
|
882
|
+
normalized_body, _, fully_decoded = normalize_http_body(response.headers, response.body)
|
|
883
|
+
filtered_headers = [
|
|
884
|
+
(name, value)
|
|
885
|
+
for name, value in response.headers
|
|
886
|
+
if name.lower() not in {"transfer-encoding", "content-encoding", "content-length"}
|
|
887
|
+
]
|
|
888
|
+
body_changed = normalized_body != response.body
|
|
889
|
+
headers_changed = len(filtered_headers) != len(response.headers)
|
|
890
|
+
if not fully_decoded or (not body_changed and not headers_changed):
|
|
891
|
+
return response
|
|
892
|
+
|
|
893
|
+
editable = ParsedResponse(
|
|
894
|
+
version=response.version,
|
|
895
|
+
status_code=response.status_code,
|
|
896
|
+
reason=response.reason,
|
|
897
|
+
headers=filtered_headers,
|
|
898
|
+
body=normalized_body,
|
|
899
|
+
raw=b"",
|
|
900
|
+
)
|
|
901
|
+
editable.raw = render_response_bytes(editable)
|
|
902
|
+
return editable
|
|
903
|
+
|
|
904
|
+
def _record_request(self, entry, request: ParsedRequest, target: UpstreamTarget) -> None:
|
|
905
|
+
entry.request = RequestData(
|
|
906
|
+
method=request.method,
|
|
907
|
+
target=request.target,
|
|
908
|
+
version=request.version,
|
|
909
|
+
headers=list(request.headers),
|
|
910
|
+
body=request.body,
|
|
911
|
+
host=target.host,
|
|
912
|
+
port=target.port,
|
|
913
|
+
path=target.path,
|
|
914
|
+
)
|
|
915
|
+
entry.upstream_addr = f"{target.host}:{target.port}"
|
|
916
|
+
entry.state = "forwarding"
|
|
917
|
+
|
|
918
|
+
def _record_response(self, entry, response: ParsedResponse, target: UpstreamTarget) -> None:
|
|
919
|
+
visible_response = self._response_for_interception(response)
|
|
920
|
+
entry.response = ResponseData(
|
|
921
|
+
version=visible_response.version,
|
|
922
|
+
status_code=visible_response.status_code,
|
|
923
|
+
reason=visible_response.reason,
|
|
924
|
+
headers=list(visible_response.headers),
|
|
925
|
+
body=visible_response.body,
|
|
926
|
+
)
|
|
927
|
+
entry.upstream_addr = f"{target.host}:{target.port}"
|
|
928
|
+
entry.state = "complete"
|
|
929
|
+
|
|
930
|
+
def _record_error(self, entry, message: str) -> None:
|
|
931
|
+
entry.error = message
|
|
932
|
+
entry.state = "error"
|
|
933
|
+
|
|
934
|
+
def _mark_streaming(self, entry) -> None:
|
|
935
|
+
entry.state = "streaming"
|
|
936
|
+
|
|
937
|
+
def _mark_complete(self, entry) -> None:
|
|
938
|
+
if entry.state not in {"error", "dropped"}:
|
|
939
|
+
entry.state = "complete"
|
|
940
|
+
|
|
941
|
+
def _apply_match_replace_to_request(self, request: ParsedRequest) -> ParsedRequest:
|
|
942
|
+
updated = self._apply_match_replace_rules_to_text(render_request_text(request), "request")
|
|
943
|
+
if updated == render_request_text(request):
|
|
944
|
+
return request
|
|
945
|
+
return parse_request_text(updated)
|
|
946
|
+
|
|
947
|
+
def _build_local_response(self, request: ParsedRequest) -> ParsedResponse | None:
|
|
948
|
+
if request.method.upper() == "CONNECT":
|
|
949
|
+
return None
|
|
950
|
+
host = self._request_host(request)
|
|
951
|
+
if host not in LOCAL_PROXY_HOSTS and not self._is_proxy_self_host(host):
|
|
952
|
+
return None
|
|
953
|
+
|
|
954
|
+
path = self._request_path(request)
|
|
955
|
+
if path in {"", "/"}:
|
|
956
|
+
body = self._local_index_body().encode("utf-8")
|
|
957
|
+
return self._build_static_response(
|
|
958
|
+
status_code=200,
|
|
959
|
+
reason="OK",
|
|
960
|
+
headers=[
|
|
961
|
+
("Content-Type", "text/html; charset=utf-8"),
|
|
962
|
+
],
|
|
963
|
+
body=body,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
if path in {"/cert", "/cert/", "/cert/hexproxy-ca.crt", "/hexproxy-ca.crt"}:
|
|
967
|
+
cert_path = self.certificate_authority.ensure_ready()
|
|
968
|
+
body = cert_path.read_bytes()
|
|
969
|
+
return self._build_static_response(
|
|
970
|
+
status_code=200,
|
|
971
|
+
reason="OK",
|
|
972
|
+
headers=[
|
|
973
|
+
("Content-Type", "application/x-x509-ca-cert"),
|
|
974
|
+
("Content-Disposition", 'attachment; filename="hexproxy-ca.crt"'),
|
|
975
|
+
],
|
|
976
|
+
body=body,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
body = b"HexProxy local resource not found.\n"
|
|
980
|
+
return self._build_static_response(
|
|
981
|
+
status_code=404,
|
|
982
|
+
reason="Not Found",
|
|
983
|
+
headers=[("Content-Type", "text/plain; charset=utf-8")],
|
|
984
|
+
body=body,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
def _build_static_response(
|
|
988
|
+
self,
|
|
989
|
+
status_code: int,
|
|
990
|
+
reason: str,
|
|
991
|
+
headers: HeaderList,
|
|
992
|
+
body: bytes,
|
|
993
|
+
) -> ParsedResponse:
|
|
994
|
+
response = ParsedResponse(
|
|
995
|
+
version="HTTP/1.1",
|
|
996
|
+
status_code=status_code,
|
|
997
|
+
reason=reason,
|
|
998
|
+
headers=list(headers),
|
|
999
|
+
body=body,
|
|
1000
|
+
raw=b"",
|
|
1001
|
+
)
|
|
1002
|
+
response.raw = render_response_bytes(response)
|
|
1003
|
+
return response
|
|
1004
|
+
|
|
1005
|
+
def _local_index_body(self) -> str:
|
|
1006
|
+
cert_path = self.certificate_authority.cert_path()
|
|
1007
|
+
return (
|
|
1008
|
+
"<!doctype html>"
|
|
1009
|
+
"<html><head><meta charset='utf-8'><title>HexProxy CA</title></head>"
|
|
1010
|
+
"<body>"
|
|
1011
|
+
"<h1>HexProxy Certificate Authority</h1>"
|
|
1012
|
+
"<p>Download and trust the local CA certificate to inspect HTTPS traffic.</p>"
|
|
1013
|
+
"<p><a id='cert-link' href='/cert'>Download hexproxy-ca.crt</a></p>"
|
|
1014
|
+
"<p id='cert-url'></p>"
|
|
1015
|
+
f"<p>Certificate path: {cert_path}</p>"
|
|
1016
|
+
"<script>"
|
|
1017
|
+
"const link = document.getElementById('cert-link');"
|
|
1018
|
+
"const urlText = document.getElementById('cert-url');"
|
|
1019
|
+
"const certUrl = new URL('/cert', window.location.href);"
|
|
1020
|
+
"link.href = certUrl.href;"
|
|
1021
|
+
"urlText.textContent = `Certificate URL: ${certUrl.href}`;"
|
|
1022
|
+
"</script>"
|
|
1023
|
+
"</body></html>"
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
def _apply_match_replace_to_response(self, response: ParsedResponse) -> ParsedResponse:
|
|
1027
|
+
editable = self._response_for_interception(response)
|
|
1028
|
+
original_text = render_response_text(editable)
|
|
1029
|
+
updated = self._apply_match_replace_rules_to_text(original_text, "response")
|
|
1030
|
+
if updated == original_text:
|
|
1031
|
+
return response
|
|
1032
|
+
return parse_response_text(updated)
|
|
1033
|
+
|
|
1034
|
+
def _apply_match_replace_rules_to_text(self, text: str, scope: str) -> str:
|
|
1035
|
+
updated = text
|
|
1036
|
+
for rule in self.store.match_replace_rules():
|
|
1037
|
+
if not self._rule_applies(rule, scope):
|
|
1038
|
+
continue
|
|
1039
|
+
if rule.mode == "regex":
|
|
1040
|
+
updated = re.sub(rule.match, rule.replace, updated)
|
|
1041
|
+
continue
|
|
1042
|
+
updated = updated.replace(rule.match, rule.replace)
|
|
1043
|
+
return updated
|
|
1044
|
+
|
|
1045
|
+
@staticmethod
|
|
1046
|
+
def _rule_applies(rule: MatchReplaceRule, scope: str) -> bool:
|
|
1047
|
+
if not rule.enabled:
|
|
1048
|
+
return False
|
|
1049
|
+
return rule.scope in {scope, "both"}
|
|
1050
|
+
|
|
1051
|
+
async def _write_simple_response(
|
|
1052
|
+
self,
|
|
1053
|
+
writer: asyncio.StreamWriter,
|
|
1054
|
+
status_code: int,
|
|
1055
|
+
reason: str,
|
|
1056
|
+
body: bytes,
|
|
1057
|
+
) -> None:
|
|
1058
|
+
response = (
|
|
1059
|
+
f"HTTP/1.1 {status_code} {reason}\r\n"
|
|
1060
|
+
f"Content-Length: {len(body)}\r\n"
|
|
1061
|
+
"Content-Type: text/plain; charset=utf-8\r\n"
|
|
1062
|
+
"Connection: close\r\n"
|
|
1063
|
+
"\r\n"
|
|
1064
|
+
).encode("iso-8859-1") + body
|
|
1065
|
+
writer.write(response)
|
|
1066
|
+
await writer.drain()
|
|
1067
|
+
|
|
1068
|
+
async def _write_connect_established(self, writer: asyncio.StreamWriter) -> None:
|
|
1069
|
+
writer.write(b"HTTP/1.1 200 Connection Established\r\nConnection: keep-alive\r\n\r\n")
|
|
1070
|
+
await writer.drain()
|
|
1071
|
+
|
|
1072
|
+
@staticmethod
|
|
1073
|
+
def _parse_headers(raw_lines: Iterable[str]) -> HeaderList:
|
|
1074
|
+
headers: HeaderList = []
|
|
1075
|
+
for line in raw_lines:
|
|
1076
|
+
if not line:
|
|
1077
|
+
continue
|
|
1078
|
+
if ":" not in line:
|
|
1079
|
+
raise ValueError(f"invalid header line: {line!r}")
|
|
1080
|
+
name, value = line.split(":", 1)
|
|
1081
|
+
headers.append((name.strip(), value.lstrip()))
|
|
1082
|
+
return headers
|
|
1083
|
+
|
|
1084
|
+
@staticmethod
|
|
1085
|
+
def _find_header(headers: HeaderList, name: str) -> str | None:
|
|
1086
|
+
needle = name.lower()
|
|
1087
|
+
for header_name, header_value in headers:
|
|
1088
|
+
if header_name.lower() == needle:
|
|
1089
|
+
return header_value
|
|
1090
|
+
return None
|
|
1091
|
+
|
|
1092
|
+
@staticmethod
|
|
1093
|
+
def _origin_form(path: str, query: str) -> str:
|
|
1094
|
+
base = path or "/"
|
|
1095
|
+
if query:
|
|
1096
|
+
return f"{base}?{query}"
|
|
1097
|
+
return base
|
|
1098
|
+
|
|
1099
|
+
def _request_host(self, request: ParsedRequest) -> str:
|
|
1100
|
+
lowered_target = request.target.lower()
|
|
1101
|
+
if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
|
|
1102
|
+
parsed = urlsplit(request.target)
|
|
1103
|
+
return (parsed.hostname or "").lower()
|
|
1104
|
+
|
|
1105
|
+
host_header = self._find_header(request.headers, "Host") or ""
|
|
1106
|
+
if host_header.startswith("[") and "]" in host_header:
|
|
1107
|
+
return host_header[1 : host_header.index("]")].lower()
|
|
1108
|
+
if ":" in host_header:
|
|
1109
|
+
return host_header.rsplit(":", 1)[0].lower()
|
|
1110
|
+
return host_header.lower()
|
|
1111
|
+
|
|
1112
|
+
def _request_path(self, request: ParsedRequest) -> str:
|
|
1113
|
+
lowered_target = request.target.lower()
|
|
1114
|
+
if lowered_target.startswith(("http://", "https://", "ws://", "wss://")):
|
|
1115
|
+
parsed = urlsplit(request.target)
|
|
1116
|
+
return self._origin_form(parsed.path, parsed.query)
|
|
1117
|
+
return request.target or "/"
|
|
1118
|
+
|
|
1119
|
+
def _is_proxy_self_host(self, host: str) -> bool:
|
|
1120
|
+
if not host:
|
|
1121
|
+
return False
|
|
1122
|
+
normalized_host = host.lower()
|
|
1123
|
+
return normalized_host in {self.listen_host.lower(), "0.0.0.0"} or (
|
|
1124
|
+
self.listen_host in {"127.0.0.1", "0.0.0.0"} and normalized_host == "127.0.0.1"
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
def _response_has_body(
|
|
1128
|
+
self,
|
|
1129
|
+
status_code: int,
|
|
1130
|
+
headers: HeaderList,
|
|
1131
|
+
request: ParsedRequest | None,
|
|
1132
|
+
) -> bool:
|
|
1133
|
+
if request is not None and request.method.upper() == "HEAD":
|
|
1134
|
+
return False
|
|
1135
|
+
if 100 <= status_code < 200 or status_code in {101, 204, 304}:
|
|
1136
|
+
return False
|
|
1137
|
+
if self._find_header(headers, "Upgrade") is not None and status_code == 101:
|
|
1138
|
+
return False
|
|
1139
|
+
return True
|
|
1140
|
+
|
|
1141
|
+
def _is_websocket_upgrade(self, request: ParsedRequest, response: ParsedResponse) -> bool:
|
|
1142
|
+
if response.status_code != 101:
|
|
1143
|
+
return False
|
|
1144
|
+
request_upgrade = self._find_header(request.headers, "Upgrade")
|
|
1145
|
+
response_upgrade = self._find_header(response.headers, "Upgrade")
|
|
1146
|
+
if request_upgrade is None or response_upgrade is None:
|
|
1147
|
+
return False
|
|
1148
|
+
return request_upgrade.lower() == "websocket" and response_upgrade.lower() == "websocket"
|
|
1149
|
+
|
|
1150
|
+
def _is_websocket_request(self, request: ParsedRequest) -> bool:
|
|
1151
|
+
upgrade = self._find_header(request.headers, "Upgrade")
|
|
1152
|
+
if upgrade is None:
|
|
1153
|
+
return False
|
|
1154
|
+
return upgrade.lower() == "websocket"
|
|
1155
|
+
|
|
1156
|
+
def _describe_incomplete_read(self, exc: asyncio.IncompleteReadError) -> str:
|
|
1157
|
+
partial = bytes(exc.partial)
|
|
1158
|
+
if self._looks_like_tls_handshake(partial):
|
|
1159
|
+
return (
|
|
1160
|
+
"client started a TLS handshake directly. Configure the client to use HexProxy as an HTTP proxy, "
|
|
1161
|
+
"not an HTTPS proxy."
|
|
1162
|
+
)
|
|
1163
|
+
if partial.startswith(b"PRI * HTTP/2.0"):
|
|
1164
|
+
return "client started an HTTP/2 connection directly. HexProxy expects HTTP/1.1 proxy requests."
|
|
1165
|
+
if not partial:
|
|
1166
|
+
return "client closed the connection before sending a complete request"
|
|
1167
|
+
return f"incomplete read: expected {exc.expected}, received {len(partial)}"
|
|
1168
|
+
|
|
1169
|
+
@staticmethod
|
|
1170
|
+
def _looks_like_tls_handshake(partial: bytes) -> bool:
|
|
1171
|
+
return len(partial) >= 3 and partial[0] == 0x16 and partial[1] == 0x03
|
|
1172
|
+
|
|
1173
|
+
@staticmethod
|
|
1174
|
+
def _format_peer(peername) -> str:
|
|
1175
|
+
if not peername:
|
|
1176
|
+
return "-"
|
|
1177
|
+
host, port = peername[:2]
|
|
1178
|
+
return f"{host}:{port}"
|