http_mimic 0.5.1 → 0.5.5
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +76 -33
- data/http_mimic.gemspec +1 -1
- data/lib/http_mimic/command_builder.rb +9 -3
- data/lib/http_mimic/configuration.rb +19 -1
- data/lib/http_mimic/module_methods.rb +28 -0
- data/lib/http_mimic/obscura.rb +21 -2
- data/lib/http_mimic/proxy_pool.rb +224 -0
- data/lib/http_mimic/request.rb +176 -36
- data/lib/http_mimic/response.rb +22 -3
- data/lib/http_mimic/response_parser.rb +6 -0
- data/lib/http_mimic/spa_detector.rb +1 -1
- data/lib/http_mimic/version.rb +2 -1
- data/lib/http_mimic/waf/akamai_solver.rb +252 -67
- data/lib/http_mimic/waf/browser_context.js +1297 -0
- data/lib/http_mimic.rb +1 -0
- metadata +3 -1
data/lib/http_mimic/request.rb
CHANGED
|
@@ -16,6 +16,16 @@ module HttpMimic
|
|
|
16
16
|
def perform
|
|
17
17
|
mode = (options[:mode] || config.mode || :auto).to_sym
|
|
18
18
|
|
|
19
|
+
# Automatic Free Proxy Pool integration
|
|
20
|
+
should_auto_proxy = options.fetch(:auto_proxy, config.auto_proxy)
|
|
21
|
+
if should_auto_proxy && !options[:proxy]
|
|
22
|
+
selected_proxy = ProxyPool.get
|
|
23
|
+
if selected_proxy
|
|
24
|
+
options[:proxy] = selected_proxy
|
|
25
|
+
log_debug("[HttpMimic::ProxyPool] Assigned proxy from pool: #{selected_proxy}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
# Delegate directly to Obscura headless SPA renderer if requested
|
|
20
30
|
if options[:render] == :spa || options[:render] == :obscura || mode == :spa || mode == :obscura
|
|
21
31
|
return Obscura.render(url, options)
|
|
@@ -38,8 +48,10 @@ module HttpMimic
|
|
|
38
48
|
end
|
|
39
49
|
|
|
40
50
|
attempts = []
|
|
41
|
-
profiles_to_try = determine_profiles(mode, auto_fallback)
|
|
42
|
-
|
|
51
|
+
profiles_to_try = determine_profiles(mode, auto_fallback, options[:profile])
|
|
52
|
+
waf_solve_attempts = 0
|
|
53
|
+
best_response = nil
|
|
54
|
+
proxy_retries_left = should_auto_proxy ? (options[:proxy_retries] || config.proxy_retries || 3) : 0
|
|
43
55
|
|
|
44
56
|
response = nil
|
|
45
57
|
final_status = nil
|
|
@@ -47,6 +59,12 @@ module HttpMimic
|
|
|
47
59
|
final_command = nil
|
|
48
60
|
|
|
49
61
|
profiles_to_try.each_with_index do |profile, index|
|
|
62
|
+
# Never fall back to plain curl if a WAF challenge has already been detected/attempted
|
|
63
|
+
if profile == :curl && waf_solve_attempts > 0
|
|
64
|
+
log_debug("[HttpMimic] Skipping plain :curl fallback for protected WAF target.")
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
50
68
|
current_opts = options.merge(profile: profile)
|
|
51
69
|
|
|
52
70
|
builder = CommandBuilder.new(method, url, current_opts, config)
|
|
@@ -69,6 +87,26 @@ module HttpMimic
|
|
|
69
87
|
request_url: final_url
|
|
70
88
|
).parse
|
|
71
89
|
|
|
90
|
+
# Handle proxy failure retry when auto_proxy is enabled
|
|
91
|
+
is_proxy_failure = should_auto_proxy && current_opts[:proxy] && proxy_failure?(status, stderr, response)
|
|
92
|
+
if is_proxy_failure
|
|
93
|
+
ProxyPool.mark_dead(current_opts[:proxy])
|
|
94
|
+
if proxy_retries_left > 0
|
|
95
|
+
proxy_retries_left -= 1
|
|
96
|
+
new_proxy = ProxyPool.get
|
|
97
|
+
log_debug("[HttpMimic::ProxyPool] Proxy #{current_opts[:proxy]} failed (exit #{status&.exitstatus}, code #{response&.code}, empty body: #{response&.body.to_s.empty?}). Retrying with new proxy #{new_proxy} (#{proxy_retries_left} retries left)...")
|
|
98
|
+
options[:proxy] = new_proxy
|
|
99
|
+
redo
|
|
100
|
+
else
|
|
101
|
+
log_debug("[HttpMimic::ProxyPool] Proxy retries exhausted (#{current_opts[:proxy]} failed). Aborting profile fallback.")
|
|
102
|
+
break
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if should_auto_proxy && current_opts[:proxy] && response.success? && !is_proxy_failure
|
|
107
|
+
ProxyPool.mark_alive(current_opts[:proxy])
|
|
108
|
+
end
|
|
109
|
+
|
|
72
110
|
response.mode_used = profile
|
|
73
111
|
response.fallback_triggered = (index > 0)
|
|
74
112
|
|
|
@@ -86,6 +124,21 @@ module HttpMimic
|
|
|
86
124
|
final_stderr = stderr
|
|
87
125
|
final_command = full_command
|
|
88
126
|
|
|
127
|
+
# Track best response so a 200 isn't wiped out by a failed fallback.
|
|
128
|
+
# Only accept responses from successful curl executions that are not proxy failures or WAF challenges.
|
|
129
|
+
if response && (status.nil? || status.exitstatus == 0) && !is_proxy_failure
|
|
130
|
+
is_challenge = Waf::Detector.challenge_page?(response)
|
|
131
|
+
if !is_challenge
|
|
132
|
+
if best_response.nil?
|
|
133
|
+
best_response = response
|
|
134
|
+
elsif response.success? && !best_response.success?
|
|
135
|
+
best_response = response
|
|
136
|
+
elsif response.code == 200 && best_response.code != 200
|
|
137
|
+
best_response = response
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
89
142
|
# Forward any received cookies to subsequent attempts
|
|
90
143
|
if response.cookies && !response.cookies.empty?
|
|
91
144
|
existing_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
|
|
@@ -93,21 +146,25 @@ module HttpMimic
|
|
|
93
146
|
end
|
|
94
147
|
|
|
95
148
|
auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
|
|
96
|
-
is_blocked = (status.exitstatus != 0) || retry_statuses.include?(response.code) || Waf::Detector.challenge_page?(response)
|
|
149
|
+
is_blocked = (status.exitstatus != 0) || is_proxy_failure || retry_statuses.include?(response.code) || Waf::Detector.challenge_page?(response)
|
|
97
150
|
|
|
98
|
-
# Only attempt WAF resolution
|
|
99
|
-
if is_blocked && auto_solve_waf &&
|
|
151
|
+
# Only attempt WAF resolution if origin server returned challenge (not a proxy error)
|
|
152
|
+
if is_blocked && !is_proxy_failure && auto_solve_waf && waf_solve_attempts < 2 && method.to_s.upcase == 'GET'
|
|
100
153
|
waf_type = Waf::Detector.detect(response)
|
|
101
154
|
if waf_type
|
|
102
|
-
|
|
155
|
+
waf_solve_attempts += 1
|
|
103
156
|
log_debug("[HttpMimic] Detected #{waf_type.to_s.capitalize} WAF challenge. Attempting to solve with QuickJS...")
|
|
104
157
|
solved_resp = Waf.solve(url, response, current_opts)
|
|
105
158
|
if solved_resp
|
|
106
159
|
response = solved_resp
|
|
107
|
-
is_blocked = (response.code != 0 && retry_statuses.include?(response.code))
|
|
160
|
+
is_blocked = (response.code != 0 && retry_statuses.include?(response.code)) || Waf::Detector.challenge_page?(response)
|
|
108
161
|
if response.cookies && !response.cookies.empty?
|
|
109
162
|
options[:cookies] = (options[:cookies] || {}).merge(response.cookies.to_h)
|
|
110
163
|
end
|
|
164
|
+
is_challenge_res = Waf::Detector.challenge_page?(response)
|
|
165
|
+
if !is_challenge_res && (response.success? || (response.code == 200 && best_response&.code != 200))
|
|
166
|
+
best_response = response
|
|
167
|
+
end
|
|
111
168
|
end
|
|
112
169
|
end
|
|
113
170
|
end
|
|
@@ -119,33 +176,74 @@ module HttpMimic
|
|
|
119
176
|
log_debug("[HttpMimic] Attempt #{index + 1} with #{profile} resulted in status #{response.code}. Triggering smart fallback to next profile...")
|
|
120
177
|
end
|
|
121
178
|
|
|
122
|
-
#
|
|
179
|
+
# Preserve best response if the last attempt resulted in a regression (e.g. 403 / error after getting 200)
|
|
180
|
+
if best_response && !Waf::Detector.challenge_page?(best_response) && (response.nil? || response.error? || (final_status && final_status.exitstatus != 0) || Waf::Detector.challenge_page?(response))
|
|
181
|
+
if best_response.success? || (best_response.code == 200 && response&.code != 200)
|
|
182
|
+
response = best_response
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Automatic SPA Detection & Obscura rendering fallback (strictly requires auto_render_spa: true)
|
|
123
187
|
auto_render_spa = options.fetch(:auto_render_spa, config.auto_render_spa)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
response = rendered_resp
|
|
138
|
-
end
|
|
139
|
-
rescue StandardError => e
|
|
140
|
-
log_debug("[HttpMimic] Automatic Obscura SPA render failed (#{e.message}), keeping Tier 1 response.")
|
|
188
|
+
should_render_spa = auto_render_spa && response && response.success? && SpaDetector.spa?(response)
|
|
189
|
+
should_render_cpt = auto_render_spa && response && Waf::Detector.challenge_page?(response)
|
|
190
|
+
|
|
191
|
+
if (should_render_spa || should_render_cpt) && method.to_s.upcase == 'GET'
|
|
192
|
+
target_reason = should_render_cpt ? 'WAF challenge page' : 'unhydrated SPA shell'
|
|
193
|
+
log_debug("[HttpMimic] Detected #{target_reason} on #{url}. Automatically rendering with Obscura...")
|
|
194
|
+
begin
|
|
195
|
+
spa_opts = options.dup
|
|
196
|
+
# Forward all validated cookies from Tier 1 (Mode 2: Two-Phase Pipeline)
|
|
197
|
+
if response.cookies && !response.cookies.empty?
|
|
198
|
+
tier1_cookies = response.cookies.to_h
|
|
199
|
+
existing_cookies = spa_opts[:cookies].is_a?(Hash) ? spa_opts[:cookies] : {}
|
|
200
|
+
spa_opts[:cookies] = existing_cookies.merge(tier1_cookies)
|
|
141
201
|
end
|
|
202
|
+
spa_opts[:wait_until] ||= 'load' if should_render_cpt
|
|
203
|
+
rendered_resp = Obscura.render(url, spa_opts)
|
|
204
|
+
if rendered_resp && rendered_resp.success?
|
|
205
|
+
response = rendered_resp
|
|
206
|
+
end
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
log_debug("[HttpMimic] Automatic Obscura render failed (#{e.message}), keeping Tier 1 response.")
|
|
142
209
|
end
|
|
143
210
|
end
|
|
144
211
|
|
|
145
212
|
# Persist cookies back to store if enabled
|
|
146
|
-
if should_persist_cookie && host
|
|
147
|
-
|
|
148
|
-
|
|
213
|
+
if should_persist_cookie && host
|
|
214
|
+
is_success = response && (response.success? || response.redirect?) && !Waf::Detector.challenge_page?(response) && (final_status.nil? || final_status.exitstatus == 0)
|
|
215
|
+
verification_failed = !is_success && response && (
|
|
216
|
+
[401, 403].include?(response.code) ||
|
|
217
|
+
retry_statuses.include?(response.code) ||
|
|
218
|
+
Waf::Detector.challenge_page?(response)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
persist_on_failure = options.fetch(:persist_on_failure, config.persist_on_failure)
|
|
222
|
+
clear_on_failure = options.fetch(:clear_on_failure, config.clear_on_failure)
|
|
223
|
+
|
|
224
|
+
if is_success || persist_on_failure
|
|
225
|
+
if response && response.cookies && !response.cookies.empty?
|
|
226
|
+
CookieStore.save(host, response.cookies)
|
|
227
|
+
log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
|
|
228
|
+
end
|
|
229
|
+
elsif clear_on_failure && verification_failed
|
|
230
|
+
CookieStore.clear(host)
|
|
231
|
+
log_debug("[HttpMimic::CookieStore] Verification failed for #{host} (status: #{response&.code}). Cleared stored cookies.")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# If the final response was an intercepted dummy 200 from a broken/intercepting proxy, override code
|
|
236
|
+
if response && should_auto_proxy && proxy_failure?(final_status, final_stderr, response)
|
|
237
|
+
if response.code == 200 && response.body.to_s.empty?
|
|
238
|
+
response.instance_variable_set(:@code, 0)
|
|
239
|
+
response.instance_variable_set(:@status_message, 'Proxy Interception Failure (Empty Body)')
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# If the final response is an un-bypassed WAF challenge page, annotate status message so it's clear
|
|
244
|
+
if response && response.challenge_page? && (response.status_message.to_s.empty? || response.status_message == 'OK')
|
|
245
|
+
type_str = response.challenge_type ? response.challenge_type.to_s.capitalize : 'WAF'
|
|
246
|
+
response.instance_variable_set(:@status_message, "Challenge Required (#{type_str})")
|
|
149
247
|
end
|
|
150
248
|
|
|
151
249
|
handle_errors(final_status, final_stderr, final_command, response)
|
|
@@ -154,10 +252,17 @@ module HttpMimic
|
|
|
154
252
|
|
|
155
253
|
private
|
|
156
254
|
|
|
157
|
-
def determine_profiles(mode, auto_fallback)
|
|
255
|
+
def determine_profiles(mode, auto_fallback, explicit_profile = nil)
|
|
256
|
+
if explicit_profile
|
|
257
|
+
p_sym = explicit_profile.to_sym
|
|
258
|
+
return [p_sym] unless auto_fallback
|
|
259
|
+
defaults = [:impersonate, :android, :ios, :firefox, :safari, :curl]
|
|
260
|
+
return ([p_sym] + defaults).uniq
|
|
261
|
+
end
|
|
262
|
+
|
|
158
263
|
case mode
|
|
159
264
|
when :curl, :curl_first
|
|
160
|
-
auto_fallback ? [:curl, :impersonate, :android, :ios] : [:curl]
|
|
265
|
+
auto_fallback ? [:curl, :impersonate, :android, :ios, :firefox] : [:curl]
|
|
161
266
|
when :curl_only
|
|
162
267
|
[:curl]
|
|
163
268
|
when :impersonate_only
|
|
@@ -168,19 +273,54 @@ module HttpMimic
|
|
|
168
273
|
[:android]
|
|
169
274
|
when :ios_only
|
|
170
275
|
[:ios]
|
|
276
|
+
when :firefox_only
|
|
277
|
+
[:firefox]
|
|
278
|
+
when :safari_only
|
|
279
|
+
[:safari]
|
|
171
280
|
when :mobile_first
|
|
172
|
-
auto_fallback ? [:android, :ios, :impersonate, :
|
|
281
|
+
auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:mobile]
|
|
173
282
|
when :android_first
|
|
174
|
-
auto_fallback ? [:android, :ios, :impersonate, :
|
|
283
|
+
auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:android]
|
|
175
284
|
when :ios_first
|
|
176
|
-
auto_fallback ? [:ios, :android, :impersonate, :
|
|
285
|
+
auto_fallback ? [:ios, :android, :impersonate, :firefox] : [:ios]
|
|
177
286
|
when :impersonate_first
|
|
178
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
287
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
179
288
|
when :auto, :smart
|
|
180
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
289
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
181
290
|
else
|
|
182
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
291
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def proxy_failure?(status, stderr, response)
|
|
296
|
+
# 1. Non-zero exit status from curl when using proxy (connection failed, SSL error, empty reply, etc.)
|
|
297
|
+
return true if status.nil? || status.exitstatus != 0
|
|
298
|
+
|
|
299
|
+
# 2. Origin server did not return a valid response (e.g. only CONNECT tunnel block or code 0)
|
|
300
|
+
return true if response.nil? || response.code == 0
|
|
301
|
+
|
|
302
|
+
# 3. Known proxy-level error codes
|
|
303
|
+
return true if [407, 502, 503, 504].include?(response.code)
|
|
304
|
+
|
|
305
|
+
# 4. Proxy fake 200 OK interception detection:
|
|
306
|
+
# A GET request that returns 200 with empty body is almost always a proxy interception / dummy response
|
|
307
|
+
if method.to_s.upcase == 'GET' && response.code == 200 && response.body.to_s.empty?
|
|
308
|
+
server_hdr = response.headers['server'].to_s.downcase
|
|
309
|
+
via_hdr = response.headers['via'].to_s.downcase
|
|
310
|
+
content_len = response.headers['content-length']
|
|
311
|
+
|
|
312
|
+
is_proxy_marker = server_hdr.include?('proxy') ||
|
|
313
|
+
server_hdr.include?('squid') ||
|
|
314
|
+
server_hdr.include?('console') ||
|
|
315
|
+
server_hdr.include?('privoxy') ||
|
|
316
|
+
server_hdr.include?('tinyproxy') ||
|
|
317
|
+
via_hdr.include?('proxy') ||
|
|
318
|
+
content_len == '0'
|
|
319
|
+
|
|
320
|
+
return true if is_proxy_marker
|
|
183
321
|
end
|
|
322
|
+
|
|
323
|
+
false
|
|
184
324
|
end
|
|
185
325
|
|
|
186
326
|
def execute_open3(command_array, stdin_data)
|
data/lib/http_mimic/response.rb
CHANGED
|
@@ -46,8 +46,26 @@ module HttpMimic
|
|
|
46
46
|
!!@fallback_triggered
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
def challenge_page?
|
|
50
|
+
if defined?(HttpMimic::Waf::Detector)
|
|
51
|
+
@challenge_page ||= HttpMimic::Waf::Detector.challenge_page?(self)
|
|
52
|
+
else
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
alias challenge? challenge_page?
|
|
57
|
+
alias blocked? challenge_page?
|
|
58
|
+
|
|
59
|
+
def challenge_type
|
|
60
|
+
if defined?(HttpMimic::Waf::Detector)
|
|
61
|
+
@challenge_type ||= HttpMimic::Waf::Detector.detect(self)
|
|
62
|
+
else
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
49
67
|
def success?
|
|
50
|
-
code >= 200 && code < 300
|
|
68
|
+
exit_code == 0 && (code >= 200 && code < 300) && !challenge_page?
|
|
51
69
|
end
|
|
52
70
|
alias ok? success?
|
|
53
71
|
|
|
@@ -64,7 +82,7 @@ module HttpMimic
|
|
|
64
82
|
end
|
|
65
83
|
|
|
66
84
|
def error?
|
|
67
|
-
client_error? || server_error?
|
|
85
|
+
exit_code != 0 || client_error? || server_error? || challenge_page?
|
|
68
86
|
end
|
|
69
87
|
|
|
70
88
|
def binary?
|
|
@@ -159,7 +177,8 @@ module HttpMimic
|
|
|
159
177
|
end
|
|
160
178
|
|
|
161
179
|
def inspect
|
|
162
|
-
"
|
|
180
|
+
challenge_info = challenge_page? ? " @challenge=true (#{challenge_type || 'WAF'})" : ""
|
|
181
|
+
"#<#{self.class.name}:0x#{object_id.to_s(16)} @code=#{code}#{challenge_info} @status_message=#{status_message.inspect} @headers=#{headers.to_h.inspect} @parsed_response=#{parsed_response.inspect}>"
|
|
163
182
|
end
|
|
164
183
|
|
|
165
184
|
private
|
|
@@ -6,6 +6,7 @@ module HttpMimic
|
|
|
6
6
|
class ResponseParser
|
|
7
7
|
HTTP_STATUS_LINE_REGEX = /\AHTTP\/(?<version>[\d\.]+)\s+(?<code>\d{3})(?:\s+(?<message>.*))?/i
|
|
8
8
|
HTTP_STATUS_LINE_B = /\AHTTP\/(?:[\d\.]+)\s+\d{3}/i
|
|
9
|
+
PROXY_CONNECT_REGEX = /\AHTTP\/[\d\.]+\s+200\s+Connection\s+established/i
|
|
9
10
|
BINARY_MIME_KEYWORDS = %w[image/ audio/ video/ pdf octet-stream zip gzip tar compressed stream font wasm].freeze
|
|
10
11
|
|
|
11
12
|
attr_reader :raw_output, :exit_status, :stderr, :command, :request_url
|
|
@@ -22,6 +23,11 @@ module HttpMimic
|
|
|
22
23
|
# Split header blocks and body in binary-safe manner
|
|
23
24
|
header_blocks, raw_body = split_headers_and_body(@raw_output)
|
|
24
25
|
|
|
26
|
+
# Strip proxy CONNECT tunnel handshake blocks (e.g. "HTTP/1.1 200 Connection established")
|
|
27
|
+
while header_blocks.first && header_blocks.first =~ PROXY_CONNECT_REGEX
|
|
28
|
+
header_blocks.shift
|
|
29
|
+
end
|
|
30
|
+
|
|
25
31
|
final_header_block = header_blocks.last || ''
|
|
26
32
|
history_header_blocks = header_blocks.size > 1 ? header_blocks[0...-1] : []
|
|
27
33
|
|
|
@@ -25,7 +25,7 @@ module HttpMimic
|
|
|
25
25
|
# @param response [HttpMimic::Response]
|
|
26
26
|
# @return [Boolean]
|
|
27
27
|
def spa?(response)
|
|
28
|
-
return false unless response && response.
|
|
28
|
+
return false unless response && (response.code >= 200 && response.code < 300)
|
|
29
29
|
return false if response.respond_to?(:binary?) && response.binary?
|
|
30
30
|
|
|
31
31
|
content_type = response.headers['content-type'].to_s.downcase
|