pybrws 0.1.0__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.
- pybrws/__init__.py +3 -0
- pybrws/__main__.py +3 -0
- pybrws/certs.py +275 -0
- pybrws/cli.py +303 -0
- pybrws/client.py +303 -0
- pybrws/cookies.py +234 -0
- pybrws/data/__init__.py +21 -0
- pybrws/data/chrome-1.hex +61 -0
- pybrws/data/reference-firefox-157-peet.json +47 -0
- pybrws/data/reference-firefox-157-windows-fresh.hex +60 -0
- pybrws/differ.py +138 -0
- pybrws/emitter.py +366 -0
- pybrws/h3.py +341 -0
- pybrws/handshake.py +814 -0
- pybrws/headers.py +171 -0
- pybrws/hexio.py +58 -0
- pybrws/hosts.py +49 -0
- pybrws/http1.py +268 -0
- pybrws/http2.py +761 -0
- pybrws/keyexchange.py +107 -0
- pybrws/keyschedule.py +185 -0
- pybrws/message.py +348 -0
- pybrws/model.py +125 -0
- pybrws/parser.py +123 -0
- pybrws/pool.py +159 -0
- pybrws/profiles/__init__.py +166 -0
- pybrws/profiles/chrome155.py +579 -0
- pybrws/profiles/firefox157.py +318 -0
- pybrws/profiles/firefox157_quic.py +311 -0
- pybrws/proxies.py +277 -0
- pybrws/proxy.py +407 -0
- pybrws/quic.py +454 -0
- pybrws/quic_impersonate.py +510 -0
- pybrws/records.py +161 -0
- pybrws/session.py +305 -0
- pybrws/tickets.py +122 -0
- pybrws/tls12.py +374 -0
- pybrws/tools/__init__.py +7 -0
- pybrws/tools/_selfsigned.py +93 -0
- pybrws/tools/ab_test.py +496 -0
- pybrws/tools/catch_h2.py +382 -0
- pybrws/tools/catch_hello.py +218 -0
- pybrws/tools/catch_quic.py +231 -0
- pybrws/tools/catch_request.py +288 -0
- pybrws/tools/catch_resumed.py +285 -0
- pybrws/tools/detect_waf.py +165 -0
- pybrws/tools/isolate.py +117 -0
- pybrws/tools/ja4.py +205 -0
- pybrws/tools/probe_fingerprint.py +199 -0
- pybrws/tools/read_quic.py +458 -0
- pybrws/tools/verify.py +664 -0
- pybrws/wire.py +27 -0
- pybrws-0.1.0.dist-info/METADATA +86 -0
- pybrws-0.1.0.dist-info/RECORD +58 -0
- pybrws-0.1.0.dist-info/WHEEL +5 -0
- pybrws-0.1.0.dist-info/entry_points.txt +2 -0
- pybrws-0.1.0.dist-info/licenses/LICENSE +21 -0
- pybrws-0.1.0.dist-info/top_level.txt +1 -0
pybrws/__init__.py
ADDED
pybrws/__main__.py
ADDED
pybrws/certs.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Certificate handling: parsing, decompression, chain and signature checks.
|
|
2
|
+
|
|
3
|
+
Path validation is delegated to `cryptography.x509.verification`. Hand-rolling
|
|
4
|
+
it would mean reimplementing name constraints, policy handling and revocation
|
|
5
|
+
semantics, which is the kind of code that is wrong in ways nobody notices
|
|
6
|
+
until it matters.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ipaddress
|
|
12
|
+
import ssl
|
|
13
|
+
import warnings
|
|
14
|
+
|
|
15
|
+
from cryptography import x509
|
|
16
|
+
from cryptography.exceptions import InvalidSignature
|
|
17
|
+
from cryptography.hazmat.primitives import hashes
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
|
|
19
|
+
from cryptography.x509.verification import PolicyBuilder, Store
|
|
20
|
+
|
|
21
|
+
from . import hosts
|
|
22
|
+
from .parser import _Reader
|
|
23
|
+
|
|
24
|
+
# RFC 8879
|
|
25
|
+
COMPRESSION_ZLIB = 1
|
|
26
|
+
COMPRESSION_BROTLI = 2
|
|
27
|
+
COMPRESSION_ZSTD = 3
|
|
28
|
+
|
|
29
|
+
# The string a TLS 1.3 server signs, RFC 8446 section 4.4.3.
|
|
30
|
+
SIGNATURE_CONTEXT = b" " * 64 + b"TLS 1.3, server CertificateVerify" + b"\x00"
|
|
31
|
+
|
|
32
|
+
RSA_PSS = {
|
|
33
|
+
0x0804: hashes.SHA256,
|
|
34
|
+
0x0805: hashes.SHA384,
|
|
35
|
+
0x0806: hashes.SHA512,
|
|
36
|
+
}
|
|
37
|
+
ECDSA = {
|
|
38
|
+
0x0403: hashes.SHA256,
|
|
39
|
+
0x0503: hashes.SHA384,
|
|
40
|
+
0x0603: hashes.SHA512,
|
|
41
|
+
}
|
|
42
|
+
RSA_PKCS1 = {
|
|
43
|
+
0x0401: hashes.SHA256,
|
|
44
|
+
0x0501: hashes.SHA384,
|
|
45
|
+
0x0601: hashes.SHA512,
|
|
46
|
+
}
|
|
47
|
+
ED25519 = 0x0807
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CertificateError(Exception):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decompress_certificate(message: bytes) -> bytes:
|
|
55
|
+
"""Turn an RFC 8879 CompressedCertificate body into a Certificate body.
|
|
56
|
+
|
|
57
|
+
The output is bounded by the declared length *while* decompressing.
|
|
58
|
+
Checking it afterwards would be too late: this message arrives before the
|
|
59
|
+
server has proved anything, and a kilobyte of brotli can expand to a
|
|
60
|
+
gigabyte. RFC 8879 section 4 asks for exactly this bound. The declared
|
|
61
|
+
length is a uint24, so its own ceiling of 2**24 - 1 holds by construction.
|
|
62
|
+
"""
|
|
63
|
+
reader = _Reader(message, "CompressedCertificate")
|
|
64
|
+
algorithm = reader.uint(2)
|
|
65
|
+
expected = reader.uint(3)
|
|
66
|
+
compressed = reader.vector(3)
|
|
67
|
+
# One byte past the declared length is enough to know it was a lie.
|
|
68
|
+
limit = expected + 1
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if algorithm == COMPRESSION_ZLIB:
|
|
72
|
+
import zlib
|
|
73
|
+
|
|
74
|
+
plain = zlib.decompressobj().decompress(compressed, limit)
|
|
75
|
+
elif algorithm == COMPRESSION_BROTLI:
|
|
76
|
+
import brotli
|
|
77
|
+
|
|
78
|
+
# Bounded to within one internal block of the limit, which is
|
|
79
|
+
# enough: the length check below rejects anything past it.
|
|
80
|
+
plain = brotli.Decompressor().process(
|
|
81
|
+
compressed, output_buffer_limit=limit)
|
|
82
|
+
elif algorithm == COMPRESSION_ZSTD:
|
|
83
|
+
try:
|
|
84
|
+
from compression import zstd
|
|
85
|
+
except ImportError:
|
|
86
|
+
import io
|
|
87
|
+
|
|
88
|
+
import zstandard
|
|
89
|
+
|
|
90
|
+
with zstandard.ZstdDecompressor().stream_reader(
|
|
91
|
+
io.BytesIO(compressed)) as source:
|
|
92
|
+
plain = source.read(limit)
|
|
93
|
+
else:
|
|
94
|
+
plain = zstd.ZstdDecompressor().decompress(
|
|
95
|
+
compressed, max_length=limit)
|
|
96
|
+
else:
|
|
97
|
+
raise CertificateError(
|
|
98
|
+
"unknown certificate compression " + str(algorithm))
|
|
99
|
+
except CertificateError:
|
|
100
|
+
raise
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
raise CertificateError(
|
|
103
|
+
"certificate decompression failed: " + str(exc)) from exc
|
|
104
|
+
|
|
105
|
+
if len(plain) != expected:
|
|
106
|
+
raise CertificateError(
|
|
107
|
+
"decompressed to %d bytes, the server said %d" % (len(plain), expected)
|
|
108
|
+
)
|
|
109
|
+
return plain
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_certificate(body: bytes) -> list[bytes]:
|
|
113
|
+
"""Return the DER certificates in a Certificate message body, leaf first."""
|
|
114
|
+
reader = _Reader(body, "Certificate")
|
|
115
|
+
reader.vector(1) # certificate_request_context, empty for a server
|
|
116
|
+
entries = _Reader(reader.vector(3), "certificate_list")
|
|
117
|
+
chain = []
|
|
118
|
+
while entries.remaining:
|
|
119
|
+
chain.append(entries.vector(3))
|
|
120
|
+
entries.vector(2) # per-certificate extensions
|
|
121
|
+
if not chain:
|
|
122
|
+
raise CertificateError("the server sent an empty certificate list")
|
|
123
|
+
return chain
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_anchors(ders) -> list:
|
|
127
|
+
anchors = []
|
|
128
|
+
for der in ders:
|
|
129
|
+
try:
|
|
130
|
+
anchors.append(x509.load_der_x509_certificate(der))
|
|
131
|
+
except Exception:
|
|
132
|
+
# Bundles carry the occasional malformed anchor. One
|
|
133
|
+
# unparseable certificate should not cost the other few hundred.
|
|
134
|
+
continue
|
|
135
|
+
return anchors
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _platform_roots() -> list[bytes]:
|
|
139
|
+
context = ssl.create_default_context()
|
|
140
|
+
return list(context.get_ca_certs(binary_form=True))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _mozilla_roots() -> list[bytes]:
|
|
144
|
+
"""The DER of each certificate in the certifi bundle, which is Mozilla's
|
|
145
|
+
root program. A bundle that cannot be read counts as having none."""
|
|
146
|
+
import certifi
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with open(certifi.where(), encoding="ascii") as bundle:
|
|
150
|
+
text = bundle.read()
|
|
151
|
+
except (OSError, UnicodeDecodeError):
|
|
152
|
+
return []
|
|
153
|
+
begin, end = "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"
|
|
154
|
+
ders = []
|
|
155
|
+
for block in text.split(end)[:-1]:
|
|
156
|
+
# The bundle puts a comment above each certificate.
|
|
157
|
+
_, found, body = block.partition(begin)
|
|
158
|
+
if not found:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
ders.append(ssl.PEM_cert_to_DER_cert(begin + body + end + "\n"))
|
|
162
|
+
except ValueError:
|
|
163
|
+
continue
|
|
164
|
+
return ders
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def system_store() -> Store:
|
|
168
|
+
"""The platform's trust anchors alone, as Python's ssl module sees them.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
CertificateError: if the platform has none.
|
|
172
|
+
"""
|
|
173
|
+
warnings.filterwarnings("ignore", message="Parsed a serial number")
|
|
174
|
+
anchors = _parse_anchors(_platform_roots())
|
|
175
|
+
if not anchors:
|
|
176
|
+
raise CertificateError("no trust anchors available from the system store")
|
|
177
|
+
return Store(anchors)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def default_store() -> Store:
|
|
181
|
+
"""The roots a connection is verified against by default: Mozilla's
|
|
182
|
+
(the certifi bundle) plus whatever the platform adds, an enterprise CA
|
|
183
|
+
for example. That is close to Firefox's mix: Firefox adds only the roots
|
|
184
|
+
an administrator or user installed on the system, not the platform's
|
|
185
|
+
whole root program.
|
|
186
|
+
|
|
187
|
+
A certificate that is in both counts once. A source that cannot be read
|
|
188
|
+
counts as having none.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
CertificateError: if both sources yield no anchors.
|
|
192
|
+
"""
|
|
193
|
+
warnings.filterwarnings("ignore", message="Parsed a serial number")
|
|
194
|
+
seen = set()
|
|
195
|
+
unique = []
|
|
196
|
+
for der in _mozilla_roots() + _platform_roots():
|
|
197
|
+
if der not in seen:
|
|
198
|
+
seen.add(der)
|
|
199
|
+
unique.append(der)
|
|
200
|
+
anchors = _parse_anchors(unique)
|
|
201
|
+
if not anchors:
|
|
202
|
+
raise CertificateError("no trust anchors available from certifi or the system store")
|
|
203
|
+
return Store(anchors)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def verify_chain(chain: list[bytes], hostname: str, store: Store | None = None):
|
|
207
|
+
"""Validate the chain for `hostname`, returning the leaf certificate.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
CertificateError: if the chain does not validate.
|
|
211
|
+
"""
|
|
212
|
+
certificates = [x509.load_der_x509_certificate(der) for der in chain]
|
|
213
|
+
leaf, intermediates = certificates[0], certificates[1:]
|
|
214
|
+
if hosts.is_ip_literal(hostname):
|
|
215
|
+
# An address is matched against an IP address SAN, never a DNS one.
|
|
216
|
+
subject = x509.IPAddress(ipaddress.ip_address(hostname.strip("[]")))
|
|
217
|
+
else:
|
|
218
|
+
subject = x509.DNSName(hostname)
|
|
219
|
+
verifier = (
|
|
220
|
+
PolicyBuilder()
|
|
221
|
+
.store(store if store is not None else default_store())
|
|
222
|
+
.build_server_verifier(subject)
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
verifier.verify(leaf, intermediates)
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
raise CertificateError("certificate chain rejected: " + str(exc)) from exc
|
|
228
|
+
return leaf
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def verify_signature(leaf, scheme: int, signature: bytes, transcript_hash: bytes) -> None:
|
|
232
|
+
"""Check the CertificateVerify signature over the handshake transcript.
|
|
233
|
+
|
|
234
|
+
This is what proves the peer holds the key in the certificate, rather
|
|
235
|
+
than merely having a copy of someone else's chain.
|
|
236
|
+
"""
|
|
237
|
+
signed = SIGNATURE_CONTEXT + transcript_hash
|
|
238
|
+
key = leaf.public_key()
|
|
239
|
+
try:
|
|
240
|
+
if scheme in RSA_PSS:
|
|
241
|
+
algorithm = RSA_PSS[scheme]()
|
|
242
|
+
key.verify(signature, signed,
|
|
243
|
+
padding.PSS(mgf=padding.MGF1(algorithm),
|
|
244
|
+
salt_length=algorithm.digest_size),
|
|
245
|
+
algorithm)
|
|
246
|
+
elif scheme in ECDSA:
|
|
247
|
+
key.verify(signature, signed, ec.ECDSA(ECDSA[scheme]()))
|
|
248
|
+
elif scheme == ED25519:
|
|
249
|
+
key.verify(signature, signed)
|
|
250
|
+
elif scheme in RSA_PKCS1:
|
|
251
|
+
# Not permitted for TLS 1.3 CertificateVerify, but accepting it
|
|
252
|
+
# silently would hide a server doing the wrong thing.
|
|
253
|
+
raise CertificateError(
|
|
254
|
+
"server signed with rsa_pkcs1, which TLS 1.3 forbids here"
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
raise CertificateError("unsupported signature scheme 0x%04x" % scheme)
|
|
258
|
+
except InvalidSignature as exc:
|
|
259
|
+
raise CertificateError("CertificateVerify signature is invalid") from exc
|
|
260
|
+
except CertificateError:
|
|
261
|
+
raise
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
raise CertificateError("CertificateVerify could not be checked: "
|
|
264
|
+
+ str(exc)) from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def expected_key_type(leaf) -> str:
|
|
268
|
+
key = leaf.public_key()
|
|
269
|
+
if isinstance(key, rsa.RSAPublicKey):
|
|
270
|
+
return "rsa"
|
|
271
|
+
if isinstance(key, ec.EllipticCurvePublicKey):
|
|
272
|
+
return "ecdsa"
|
|
273
|
+
if isinstance(key, ed25519.Ed25519PublicKey):
|
|
274
|
+
return "ed25519"
|
|
275
|
+
return type(key).__name__
|
pybrws/cli.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Command line front end, shaped like curl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
from . import profiles
|
|
11
|
+
from . import headers as header_tools
|
|
12
|
+
from .client import (REDIRECT_STATUSES, TooManyRedirects, UnsupportedUrl,
|
|
13
|
+
Unmeasured, redirect_headers, request)
|
|
14
|
+
from .message import DEFAULT_MAX_BODY, ContentEncodingError, Response
|
|
15
|
+
from .certs import CertificateError
|
|
16
|
+
from .h3 import Http3Error
|
|
17
|
+
from .handshake import HandshakeError, TruncatedStream
|
|
18
|
+
from .http1 import Http1Error
|
|
19
|
+
from .http2 import Http2Error
|
|
20
|
+
from .proxies import ProxyError, parse_proxy
|
|
21
|
+
from .quic_impersonate import NotInstalled
|
|
22
|
+
from .records import TlsAlert, UnexpectedRecord
|
|
23
|
+
from .tls12 import Tls12Error
|
|
24
|
+
|
|
25
|
+
# What the library raises for a server, a response, a profile or an input it
|
|
26
|
+
# will not take: reported in one line, as curl does, not as a traceback.
|
|
27
|
+
REPORTED = (UnsupportedUrl, TooManyRedirects, TlsAlert, ProxyError, CertificateError,
|
|
28
|
+
Unmeasured, ContentEncodingError, Http1Error, Http2Error, Http3Error,
|
|
29
|
+
HandshakeError, Tls12Error, UnexpectedRecord, TruncatedStream,
|
|
30
|
+
UnicodeError, profiles.ProfileError, NotInstalled)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# `pybrws <name> ...` runs one of the method's tools; each module has
|
|
34
|
+
# main(argv) -> int. `capture` takes a second word, the kind of capture.
|
|
35
|
+
SUBCOMMANDS = {
|
|
36
|
+
"read-quic": "pybrws.tools.read_quic",
|
|
37
|
+
"ja4": "pybrws.tools.ja4",
|
|
38
|
+
"verify": "pybrws.tools.verify",
|
|
39
|
+
"probe": "pybrws.tools.probe_fingerprint",
|
|
40
|
+
"ab": "pybrws.tools.ab_test",
|
|
41
|
+
"isolate": "pybrws.tools.isolate",
|
|
42
|
+
"waf": "pybrws.tools.detect_waf",
|
|
43
|
+
}
|
|
44
|
+
CAPTURE_KINDS = {
|
|
45
|
+
"hello": "pybrws.tools.catch_hello",
|
|
46
|
+
"request": "pybrws.tools.catch_request",
|
|
47
|
+
"h2": "pybrws.tools.catch_h2",
|
|
48
|
+
"quic": "pybrws.tools.catch_quic",
|
|
49
|
+
"resumed": "pybrws.tools.catch_resumed",
|
|
50
|
+
}
|
|
51
|
+
# The optional dependency to name when an import of it fails.
|
|
52
|
+
EXTRA_FOR = {"httpx": "measure", "aioquic": "http3"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def missing_extra(error: ModuleNotFoundError, what: str):
|
|
56
|
+
"""SystemExit naming the extra to install, or None for any other module."""
|
|
57
|
+
extra = EXTRA_FOR.get((error.name or "").split(".")[0])
|
|
58
|
+
if extra is None:
|
|
59
|
+
return None
|
|
60
|
+
return SystemExit("pybrws %s needs %s: pip install 'pybrws[%s]'"
|
|
61
|
+
% (what, error.name.split(".")[0], extra))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_tool(module_name: str, argv: list[str], what: str) -> int:
|
|
65
|
+
import importlib
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return importlib.import_module(module_name).main(argv)
|
|
69
|
+
except ModuleNotFoundError as error:
|
|
70
|
+
exit_ = missing_extra(error, what)
|
|
71
|
+
if exit_ is None:
|
|
72
|
+
raise
|
|
73
|
+
raise exit_ from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def dispatch(argv: list[str]) -> int | None:
|
|
77
|
+
"""Run a subcommand if `argv` names one; None means it is a fetch."""
|
|
78
|
+
if not argv:
|
|
79
|
+
return None
|
|
80
|
+
if argv[0] in SUBCOMMANDS:
|
|
81
|
+
return run_tool(SUBCOMMANDS[argv[0]], argv[1:], argv[0])
|
|
82
|
+
if argv[0] == "capture":
|
|
83
|
+
kinds = ", ".join(CAPTURE_KINDS)
|
|
84
|
+
if len(argv) < 2 or argv[1] in ("-h", "--help"):
|
|
85
|
+
print("usage: pybrws capture KIND [options]\n\nKIND is one of: " + kinds)
|
|
86
|
+
return 0
|
|
87
|
+
if argv[1] not in CAPTURE_KINDS:
|
|
88
|
+
raise SystemExit("pybrws capture: unknown kind %r; one of: %s"
|
|
89
|
+
% (argv[1], kinds))
|
|
90
|
+
return run_tool(CAPTURE_KINDS[argv[1]], argv[2:], "capture " + argv[1])
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_header(raw: str) -> tuple[str, str]:
|
|
95
|
+
name, separator, value = raw.partition(":")
|
|
96
|
+
if not separator:
|
|
97
|
+
raise argparse.ArgumentTypeError("expected 'Name: value', got " + repr(raw))
|
|
98
|
+
return name.strip(), value.strip()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_profile(name: str, http3: bool):
|
|
102
|
+
"""The profile for the transport actually being used."""
|
|
103
|
+
try:
|
|
104
|
+
return profiles.load(name, transport="quic" if http3 else "tcp")
|
|
105
|
+
except profiles.WrongTransport as error:
|
|
106
|
+
# Only this front end has --http3, so only it can name the flag.
|
|
107
|
+
raise SystemExit(str(error) + ("; add --http3" if error.has == "quic"
|
|
108
|
+
else "; drop --http3"))
|
|
109
|
+
except profiles.ProfileError as error:
|
|
110
|
+
raise SystemExit(str(error))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fetch_http3(args, profile, overrides) -> Response:
|
|
114
|
+
"""One HTTP/3 fetch, following redirects by hand.
|
|
115
|
+
|
|
116
|
+
`pybrws.h3` returns the response it gets and does not follow anything,
|
|
117
|
+
so the loop lives here. Without it `-L` would silently mean nothing on
|
|
118
|
+
this path while meaning something on the other -- the same arm
|
|
119
|
+
answering a different question, which is how a comparison between the
|
|
120
|
+
two transports goes wrong.
|
|
121
|
+
"""
|
|
122
|
+
from . import h3
|
|
123
|
+
|
|
124
|
+
url = args.url
|
|
125
|
+
history: list[Response] = []
|
|
126
|
+
budget = args.max_redirs if args.location else 0
|
|
127
|
+
for hop in range(budget + 1):
|
|
128
|
+
# The method and body asked for: h3 refuses what it has no
|
|
129
|
+
# capture for, where this used to send a GET whatever -X and -d said.
|
|
130
|
+
response = h3.request(url, method=args.request.upper(), data=args.data,
|
|
131
|
+
timeout=args.timeout, headers=overrides or None,
|
|
132
|
+
profile=profile, verify=not args.insecure,
|
|
133
|
+
max_body_size=getattr(args, "max_body_size",
|
|
134
|
+
DEFAULT_MAX_BODY))
|
|
135
|
+
response.url = url
|
|
136
|
+
location = response.header("location")
|
|
137
|
+
# The statuses client.request follows, so both arms agree on what
|
|
138
|
+
# a redirect is; a 304 with a Location header is not one.
|
|
139
|
+
if response.status not in REDIRECT_STATUSES or not location:
|
|
140
|
+
break
|
|
141
|
+
if hop == budget:
|
|
142
|
+
if budget == 0:
|
|
143
|
+
break # --no-location: the redirect is the answer, as over TCP
|
|
144
|
+
raise TooManyRedirects("still redirecting after %d hops, last %r"
|
|
145
|
+
% (budget, urljoin(url, location)))
|
|
146
|
+
history.append(response)
|
|
147
|
+
next_url = urljoin(url, location)
|
|
148
|
+
overrides = redirect_headers(overrides, url, next_url)
|
|
149
|
+
url = next_url
|
|
150
|
+
response.history = history
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def describe_request(profile, args) -> str:
|
|
155
|
+
lines = ["> " + args.url,
|
|
156
|
+
"> transport: " + ("quic/h3" if args.http3 else "tcp"),
|
|
157
|
+
"> profile: " + profile.NAME]
|
|
158
|
+
if args.proxy:
|
|
159
|
+
# str(Proxy) omits the password: -v output gets pasted into issues.
|
|
160
|
+
lines.append("> proxy: " + str(parse_proxy(args.proxy)))
|
|
161
|
+
for name, value in args.header:
|
|
162
|
+
lines.append("> override: " + name + ": " + value)
|
|
163
|
+
return "\n".join(lines)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def render_head(response: Response) -> str:
|
|
167
|
+
version = response.http_version or "h2"
|
|
168
|
+
lines = [version + " " + str(response.status)]
|
|
169
|
+
lines += [name + ": " + value for name, value in response.headers]
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
174
|
+
parser = argparse.ArgumentParser(
|
|
175
|
+
prog="pybrws",
|
|
176
|
+
description="Fetch an https URL with a browser's protocol fingerprint.",
|
|
177
|
+
epilog="subcommands (pybrws <name> --help): capture {%s}, %s"
|
|
178
|
+
% (",".join(CAPTURE_KINDS), ", ".join(SUBCOMMANDS)),
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument("url", help="the https URL to fetch")
|
|
181
|
+
parser.add_argument("-X", "--request", default=None, metavar="METHOD",
|
|
182
|
+
help="HTTP method; defaults to GET, or POST with -d")
|
|
183
|
+
parser.add_argument("-d", "--data", metavar="BODY",
|
|
184
|
+
help="request body; implies POST unless -X says otherwise")
|
|
185
|
+
parser.add_argument("-H", "--header", action="append", default=[],
|
|
186
|
+
type=parse_header, metavar="'Name: value'",
|
|
187
|
+
help="replace or add a request header, repeatable")
|
|
188
|
+
parser.add_argument("-o", "--output", metavar="FILE",
|
|
189
|
+
help="write the body to FILE instead of stdout")
|
|
190
|
+
parser.add_argument("-i", "--include", action="store_true",
|
|
191
|
+
help="print the response head before the body")
|
|
192
|
+
parser.add_argument("-I", "--head-only", action="store_true",
|
|
193
|
+
help="print the response head and not the body")
|
|
194
|
+
# curl needs -L; a browser always follows, and this tool imitates a
|
|
195
|
+
# browser. --no-location restores curl's behaviour.
|
|
196
|
+
parser.add_argument("-L", "--location", action="store_true", default=True,
|
|
197
|
+
help="follow redirects (the default)")
|
|
198
|
+
parser.add_argument("--no-location", dest="location", action="store_false",
|
|
199
|
+
help="return the redirect instead of following it")
|
|
200
|
+
parser.add_argument("--max-redirs", type=int, default=5, metavar="N")
|
|
201
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
202
|
+
help="describe the request on stderr")
|
|
203
|
+
parser.add_argument("-s", "--silent", action="store_true",
|
|
204
|
+
help="suppress the status line on stderr")
|
|
205
|
+
parser.add_argument("-x", "--proxy", metavar="URL",
|
|
206
|
+
help="http://, socks5:// or socks5h://, with optional "
|
|
207
|
+
"user:pass@")
|
|
208
|
+
parser.add_argument("-k", "--insecure", action="store_true",
|
|
209
|
+
help="skip certificate verification, as curl's -k does")
|
|
210
|
+
parser.add_argument("--profile", default=None, metavar="NAME",
|
|
211
|
+
help="firefox157 or chrome155 over TCP, "
|
|
212
|
+
"firefox157_quic with --http3; or a .py "
|
|
213
|
+
"profile file")
|
|
214
|
+
parser.add_argument("--http3", action="store_true",
|
|
215
|
+
help="fetch over HTTP/3 instead of TCP. A separate "
|
|
216
|
+
"profile and a separate code path, not a "
|
|
217
|
+
"negotiation")
|
|
218
|
+
parser.add_argument("--timeout", type=float, default=20.0, metavar="SECONDS")
|
|
219
|
+
parser.add_argument("--max-filesize", type=int, default=None, metavar="BYTES",
|
|
220
|
+
help="the most a response body may be, received or "
|
|
221
|
+
"decoded (default 128 MiB); 0 lifts the cap")
|
|
222
|
+
return parser
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv: list[str] | None = None) -> int:
|
|
226
|
+
if argv is None:
|
|
227
|
+
argv = sys.argv[1:]
|
|
228
|
+
done = dispatch(list(argv))
|
|
229
|
+
if done is not None:
|
|
230
|
+
return done
|
|
231
|
+
args = build_parser().parse_args(argv)
|
|
232
|
+
if args.request is None:
|
|
233
|
+
args.request = "POST" if args.data else "GET"
|
|
234
|
+
if args.max_filesize is None:
|
|
235
|
+
args.max_body_size = DEFAULT_MAX_BODY
|
|
236
|
+
else:
|
|
237
|
+
args.max_body_size = args.max_filesize or None # 0: no cap
|
|
238
|
+
overrides = list(args.header)
|
|
239
|
+
if args.data and not header_tools.has(overrides, "content-type"):
|
|
240
|
+
# curl's default for -d, and a server will otherwise guess.
|
|
241
|
+
overrides.append(("Content-Type", "application/x-www-form-urlencoded"))
|
|
242
|
+
|
|
243
|
+
if args.profile is None:
|
|
244
|
+
args.profile = "firefox157_quic" if args.http3 else "firefox157"
|
|
245
|
+
profile = resolve_profile(args.profile, args.http3)
|
|
246
|
+
if args.http3 and args.proxy:
|
|
247
|
+
raise SystemExit("--proxy does not apply to --http3: QUIC is UDP and "
|
|
248
|
+
"this client has no UDP proxy support")
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
if args.verbose:
|
|
252
|
+
# Inside the try: describing the proxy parses it, and a proxy
|
|
253
|
+
# that does not parse is the ProxyError the request would raise.
|
|
254
|
+
print(describe_request(profile, args), file=sys.stderr)
|
|
255
|
+
if args.http3:
|
|
256
|
+
response = fetch_http3(args, profile, overrides)
|
|
257
|
+
else:
|
|
258
|
+
response = request(
|
|
259
|
+
args.url,
|
|
260
|
+
method=args.request.upper(),
|
|
261
|
+
headers=overrides or None,
|
|
262
|
+
data=args.data,
|
|
263
|
+
profile=profile,
|
|
264
|
+
timeout=args.timeout,
|
|
265
|
+
follow_redirects=args.max_redirs if args.location else 0,
|
|
266
|
+
proxy=args.proxy,
|
|
267
|
+
verify=not args.insecure,
|
|
268
|
+
max_body_size=args.max_body_size,
|
|
269
|
+
)
|
|
270
|
+
except ModuleNotFoundError as exc:
|
|
271
|
+
exit_ = missing_extra(exc, "--http3")
|
|
272
|
+
if exit_ is None:
|
|
273
|
+
raise
|
|
274
|
+
raise exit_ from None
|
|
275
|
+
except REPORTED as exc:
|
|
276
|
+
raise SystemExit(type(exc).__name__ + ": " + str(exc))
|
|
277
|
+
except OSError as exc:
|
|
278
|
+
raise SystemExit("connection failed: " + str(exc))
|
|
279
|
+
|
|
280
|
+
if not args.silent:
|
|
281
|
+
for hop in response.history:
|
|
282
|
+
print("* " + str(hop.status) + " -> " + (hop.header("location") or ""),
|
|
283
|
+
file=sys.stderr)
|
|
284
|
+
print("* " + str(response.status) + " " + (response.http_version or "h2")
|
|
285
|
+
+ " " + str(len(response.body)) + " bytes", file=sys.stderr)
|
|
286
|
+
|
|
287
|
+
if args.include or args.head_only:
|
|
288
|
+
print(render_head(response))
|
|
289
|
+
if not args.head_only:
|
|
290
|
+
print()
|
|
291
|
+
|
|
292
|
+
if not args.head_only:
|
|
293
|
+
if args.output:
|
|
294
|
+
Path(args.output).write_bytes(response.body)
|
|
295
|
+
else:
|
|
296
|
+
sys.stdout.buffer.write(response.body)
|
|
297
|
+
sys.stdout.buffer.flush()
|
|
298
|
+
|
|
299
|
+
return 0 if response.status < 400 else 1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
raise SystemExit(main())
|