ja4proxy 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.
- ja4proxy/__init__.py +9 -0
- ja4proxy/certs.py +180 -0
- ja4proxy/cli.py +154 -0
- ja4proxy/engine/bridge.py +192 -0
- ja4proxy/engine/loader.py +162 -0
- ja4proxy/parser.py +138 -0
- ja4proxy/profiles.py +184 -0
- ja4proxy/server.py +683 -0
- ja4proxy-0.1.0.dist-info/METADATA +688 -0
- ja4proxy-0.1.0.dist-info/RECORD +14 -0
- ja4proxy-0.1.0.dist-info/WHEEL +5 -0
- ja4proxy-0.1.0.dist-info/entry_points.txt +2 -0
- ja4proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- ja4proxy-0.1.0.dist-info/top_level.txt +1 -0
ja4proxy/parser.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ja4proxy.profiles import normalize_profile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Directive:
|
|
11
|
+
"""Parsed execution directives for an intercepted request."""
|
|
12
|
+
|
|
13
|
+
profile: str | None = None
|
|
14
|
+
session_id: str | None = None
|
|
15
|
+
upstream_proxy: str | None = None
|
|
16
|
+
timeout: int | None = None
|
|
17
|
+
insecure_skip_verify: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_credentials(username: str) -> Directive:
|
|
21
|
+
"""
|
|
22
|
+
Parses directives from username tags without truncating hyphens in URLs.
|
|
23
|
+
Supports formats:
|
|
24
|
+
user-profile_safari16-proxy_http://user:p-a-s-s@res-gate.io:8000-session_xyz
|
|
25
|
+
user-chrome133
|
|
26
|
+
"""
|
|
27
|
+
directive = Directive()
|
|
28
|
+
if not username:
|
|
29
|
+
return directive
|
|
30
|
+
|
|
31
|
+
tag_pattern = re.compile(r"(?:^|[-])(profile_|session_|proxy_|timeout_|insecure_)")
|
|
32
|
+
matches = list(tag_pattern.finditer(username))
|
|
33
|
+
|
|
34
|
+
if matches:
|
|
35
|
+
for i, m in enumerate(matches):
|
|
36
|
+
key = m.group(1).rstrip("_")
|
|
37
|
+
val_start = m.end()
|
|
38
|
+
if i + 1 < len(matches):
|
|
39
|
+
val_end = matches[i + 1].start()
|
|
40
|
+
val = username[val_start:val_end]
|
|
41
|
+
else:
|
|
42
|
+
val = username[val_start:]
|
|
43
|
+
if val.endswith("-"):
|
|
44
|
+
val = val[:-1]
|
|
45
|
+
|
|
46
|
+
if key == "profile":
|
|
47
|
+
directive.profile = normalize_profile(val)
|
|
48
|
+
elif key == "session":
|
|
49
|
+
directive.session_id = val
|
|
50
|
+
elif key == "proxy":
|
|
51
|
+
directive.upstream_proxy = val
|
|
52
|
+
elif key == "timeout":
|
|
53
|
+
try:
|
|
54
|
+
directive.timeout = int(val)
|
|
55
|
+
except ValueError:
|
|
56
|
+
pass
|
|
57
|
+
elif key == "insecure":
|
|
58
|
+
directive.insecure_skip_verify = val.lower() in ("1", "true", "yes")
|
|
59
|
+
else:
|
|
60
|
+
# Fallback for simple shorthand like user-chrome133
|
|
61
|
+
parts = username.split("-")
|
|
62
|
+
for part in parts:
|
|
63
|
+
if any(
|
|
64
|
+
part.startswith(b) for b in ("chrome", "safari", "firefox", "opera")
|
|
65
|
+
):
|
|
66
|
+
directive.profile = normalize_profile(part)
|
|
67
|
+
|
|
68
|
+
return directive
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract_directives(
|
|
72
|
+
headers: dict[str, str], username: str | None = None
|
|
73
|
+
) -> tuple[Directive, dict[str, str]]:
|
|
74
|
+
"""
|
|
75
|
+
Extracts proxy directives from headers and credentials,
|
|
76
|
+
and returns (Directive, sanitized_headers).
|
|
77
|
+
Header directives take precedence over username tags.
|
|
78
|
+
"""
|
|
79
|
+
directive = parse_credentials(username or "")
|
|
80
|
+
|
|
81
|
+
lower_map: dict[str, str] = {k.lower(): k for k in headers.keys()}
|
|
82
|
+
|
|
83
|
+
# Extract auth header if username was not passed explicitly
|
|
84
|
+
if not username and "proxy-authorization" in lower_map:
|
|
85
|
+
val = headers[lower_map["proxy-authorization"]].strip()
|
|
86
|
+
if val.lower().startswith("basic "):
|
|
87
|
+
try:
|
|
88
|
+
decoded = base64.b64decode(val[6:].strip()).decode("utf-8")
|
|
89
|
+
user = decoded.split(":", 1)[0]
|
|
90
|
+
dir_from_auth = parse_credentials(user)
|
|
91
|
+
if dir_from_auth.profile is not None:
|
|
92
|
+
directive.profile = dir_from_auth.profile
|
|
93
|
+
if dir_from_auth.session_id is not None:
|
|
94
|
+
directive.session_id = dir_from_auth.session_id
|
|
95
|
+
if dir_from_auth.upstream_proxy is not None:
|
|
96
|
+
directive.upstream_proxy = dir_from_auth.upstream_proxy
|
|
97
|
+
if dir_from_auth.timeout is not None:
|
|
98
|
+
directive.timeout = dir_from_auth.timeout
|
|
99
|
+
if dir_from_auth.insecure_skip_verify:
|
|
100
|
+
directive.insecure_skip_verify = True
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Request headers override any credential directive
|
|
105
|
+
if "x-ja4-profile" in lower_map:
|
|
106
|
+
directive.profile = normalize_profile(headers[lower_map["x-ja4-profile"]])
|
|
107
|
+
|
|
108
|
+
if "x-ja4-session" in lower_map:
|
|
109
|
+
directive.session_id = headers[lower_map["x-ja4-session"]]
|
|
110
|
+
|
|
111
|
+
if "x-ja4-upstream-proxy" in lower_map:
|
|
112
|
+
directive.upstream_proxy = headers[lower_map["x-ja4-upstream-proxy"]]
|
|
113
|
+
|
|
114
|
+
if "x-ja4-timeout" in lower_map:
|
|
115
|
+
try:
|
|
116
|
+
directive.timeout = int(headers[lower_map["x-ja4-timeout"]])
|
|
117
|
+
except ValueError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
if "x-ja4-insecure" in lower_map:
|
|
121
|
+
directive.insecure_skip_verify = headers[lower_map["x-ja4-insecure"]].lower() in (
|
|
122
|
+
"1",
|
|
123
|
+
"true",
|
|
124
|
+
"yes",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Build clean sanitized headers (strip proxy directives and internal controls)
|
|
128
|
+
sanitized: dict[str, str] = {}
|
|
129
|
+
drop_prefixes = ("x-ja4-",)
|
|
130
|
+
drop_headers = {"proxy-authorization", "proxy-connection"}
|
|
131
|
+
|
|
132
|
+
for k, v in headers.items():
|
|
133
|
+
lc = k.lower()
|
|
134
|
+
if lc in drop_headers or any(lc.startswith(p) for p in drop_prefixes):
|
|
135
|
+
continue
|
|
136
|
+
sanitized[k] = v
|
|
137
|
+
|
|
138
|
+
return directive, sanitized
|
ja4proxy/profiles.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger("ja4proxy")
|
|
5
|
+
|
|
6
|
+
# Bogdanfinn/tls-client v1.16.0 supported identifiers
|
|
7
|
+
SUPPORTED_PROFILES: list[str] = [
|
|
8
|
+
"chrome_133",
|
|
9
|
+
"chrome_131",
|
|
10
|
+
"chrome_124",
|
|
11
|
+
"chrome_120",
|
|
12
|
+
"safari_16_0",
|
|
13
|
+
"safari_15_6_1",
|
|
14
|
+
"safari_ios_16_0",
|
|
15
|
+
"safari_ios_17_0",
|
|
16
|
+
"safari_ios_18_0",
|
|
17
|
+
"firefox_133",
|
|
18
|
+
"firefox_132",
|
|
19
|
+
"firefox_120",
|
|
20
|
+
"opera_90",
|
|
21
|
+
"opera_89",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
DEFAULT_PROFILE = "chrome_133"
|
|
25
|
+
|
|
26
|
+
# Map common user shorthand and version aliases to real native engine profiles
|
|
27
|
+
PROFILE_ALIASES: dict[str, str] = {
|
|
28
|
+
"chrome": "chrome_133",
|
|
29
|
+
"chrome-latest": "chrome_133",
|
|
30
|
+
"chrome133": "chrome_133",
|
|
31
|
+
"chrome_133": "chrome_133",
|
|
32
|
+
"chrome131": "chrome_131",
|
|
33
|
+
"chrome_131": "chrome_131",
|
|
34
|
+
"chrome130": "chrome_131",
|
|
35
|
+
"chrome_130": "chrome_131",
|
|
36
|
+
"chrome124": "chrome_124",
|
|
37
|
+
"chrome_124": "chrome_124",
|
|
38
|
+
"chrome120": "chrome_120",
|
|
39
|
+
"chrome_120": "chrome_120",
|
|
40
|
+
"safari": "safari_16_0",
|
|
41
|
+
"safari-latest": "safari_16_0",
|
|
42
|
+
"safari18": "safari_16_0",
|
|
43
|
+
"safari_18": "safari_16_0",
|
|
44
|
+
"safari_18_0": "safari_16_0",
|
|
45
|
+
"safari17": "safari_16_0",
|
|
46
|
+
"safari_17": "safari_16_0",
|
|
47
|
+
"safari_17_0": "safari_16_0",
|
|
48
|
+
"safari16": "safari_16_0",
|
|
49
|
+
"safari_16": "safari_16_0",
|
|
50
|
+
"safari_16_0": "safari_16_0",
|
|
51
|
+
"safari15": "safari_15_6_1",
|
|
52
|
+
"safari_15_6_1": "safari_15_6_1",
|
|
53
|
+
"firefox": "firefox_133",
|
|
54
|
+
"firefox-latest": "firefox_133",
|
|
55
|
+
"firefox133": "firefox_133",
|
|
56
|
+
"firefox_133": "firefox_133",
|
|
57
|
+
"firefox132": "firefox_132",
|
|
58
|
+
"firefox_132": "firefox_132",
|
|
59
|
+
"firefox120": "firefox_120",
|
|
60
|
+
"firefox_120": "firefox_120",
|
|
61
|
+
"opera": "opera_90",
|
|
62
|
+
"opera90": "opera_90",
|
|
63
|
+
"opera_90": "opera_90",
|
|
64
|
+
"opera89": "opera_89",
|
|
65
|
+
"opera_89": "opera_89",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_profile(name: str | None) -> str:
|
|
70
|
+
"""Normalizes alias or custom profile name to a supported native client identifier."""
|
|
71
|
+
if not name:
|
|
72
|
+
return DEFAULT_PROFILE
|
|
73
|
+
raw = name.strip().lower()
|
|
74
|
+
cleaned = raw.replace("-", "_")
|
|
75
|
+
compact = raw.replace("-", "").replace("_", "")
|
|
76
|
+
|
|
77
|
+
if raw in PROFILE_ALIASES:
|
|
78
|
+
return PROFILE_ALIASES[raw]
|
|
79
|
+
if cleaned in PROFILE_ALIASES:
|
|
80
|
+
return PROFILE_ALIASES[cleaned]
|
|
81
|
+
if compact in PROFILE_ALIASES:
|
|
82
|
+
return PROFILE_ALIASES[compact]
|
|
83
|
+
|
|
84
|
+
for p in SUPPORTED_PROFILES:
|
|
85
|
+
if p.lower() == cleaned or p.lower().replace("_", "") == compact:
|
|
86
|
+
return p
|
|
87
|
+
|
|
88
|
+
logger.warning(
|
|
89
|
+
f"Profile '{name}' is not supported by native engine; falling back to '{DEFAULT_PROFILE}'"
|
|
90
|
+
)
|
|
91
|
+
return DEFAULT_PROFILE
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_default_headers(profile: str) -> dict[str, str]:
|
|
95
|
+
"""Generates canonical browser headers aligned with the profile's cryptographic engine."""
|
|
96
|
+
prof = normalize_profile(profile)
|
|
97
|
+
|
|
98
|
+
if prof.startswith("chrome"):
|
|
99
|
+
major_ver = "133"
|
|
100
|
+
full_ver = "133.0.0.0"
|
|
101
|
+
if "131" in prof:
|
|
102
|
+
major_ver = "131"
|
|
103
|
+
full_ver = "131.0.0.0"
|
|
104
|
+
elif "124" in prof:
|
|
105
|
+
major_ver = "124"
|
|
106
|
+
full_ver = "124.0.0.0"
|
|
107
|
+
elif "120" in prof:
|
|
108
|
+
major_ver = "120"
|
|
109
|
+
full_ver = "120.0.0.0"
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
"sec-ch-ua": f'"Chromium";v="{major_ver}", "Not(A:Brand";v="99", "Google Chrome";v="{major_ver}"',
|
|
113
|
+
"sec-ch-ua-mobile": "?0",
|
|
114
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
115
|
+
"upgrade-insecure-requests": "1",
|
|
116
|
+
"user-agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{full_ver} Safari/537.36",
|
|
117
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
118
|
+
"sec-fetch-site": "none",
|
|
119
|
+
"sec-fetch-mode": "navigate",
|
|
120
|
+
"sec-fetch-user": "?1",
|
|
121
|
+
"sec-fetch-dest": "document",
|
|
122
|
+
"accept-language": "en-US,en;q=0.9",
|
|
123
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if prof.startswith("safari"):
|
|
127
|
+
if "15" in prof:
|
|
128
|
+
return {
|
|
129
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15",
|
|
130
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
131
|
+
"accept-language": "en-US,en;q=0.9",
|
|
132
|
+
"accept-encoding": "gzip, deflate, br",
|
|
133
|
+
"sec-fetch-dest": "document",
|
|
134
|
+
"sec-fetch-mode": "navigate",
|
|
135
|
+
"sec-fetch-site": "none",
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15",
|
|
139
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
140
|
+
"accept-language": "en-US,en;q=0.9",
|
|
141
|
+
"accept-encoding": "gzip, deflate, br",
|
|
142
|
+
"sec-fetch-dest": "document",
|
|
143
|
+
"sec-fetch-mode": "navigate",
|
|
144
|
+
"sec-fetch-site": "none",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if prof.startswith("firefox"):
|
|
148
|
+
ff_ver = "133.0"
|
|
149
|
+
if "132" in prof:
|
|
150
|
+
ff_ver = "132.0"
|
|
151
|
+
elif "120" in prof:
|
|
152
|
+
ff_ver = "120.0"
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"user-agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:{ff_ver}) Gecko/20100101 Firefox/{ff_ver}",
|
|
156
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
157
|
+
"accept-language": "en-US,en;q=0.5",
|
|
158
|
+
"accept-encoding": "gzip, deflate, br, zstd",
|
|
159
|
+
"upgrade-insecure-requests": "1",
|
|
160
|
+
"sec-fetch-dest": "document",
|
|
161
|
+
"sec-fetch-mode": "navigate",
|
|
162
|
+
"sec-fetch-site": "none",
|
|
163
|
+
"sec-fetch-user": "?1",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if prof.startswith("opera"):
|
|
167
|
+
opr_ver = "90.0.4480.84"
|
|
168
|
+
chr_ver = "104.0.5112.102"
|
|
169
|
+
if "89" in prof:
|
|
170
|
+
opr_ver = "89.0.4447.83"
|
|
171
|
+
chr_ver = "103.0.5060.134"
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"user-agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{chr_ver} Safari/537.36 OPR/{opr_ver}",
|
|
175
|
+
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
176
|
+
"accept-language": "en-US,en;q=0.9",
|
|
177
|
+
"accept-encoding": "gzip, deflate, br",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
|
182
|
+
"accept": "*/*",
|
|
183
|
+
"accept-language": "en-US,en;q=0.9",
|
|
184
|
+
}
|