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.
@@ -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
- waf_solve_attempted = false
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 once per request to avoid unnecessary latency on subsequent fallbacks
99
- if is_blocked && auto_solve_waf && !waf_solve_attempted && method.to_s.upcase == 'GET'
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
- waf_solve_attempted = true
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
- # Automatic SPA Detection & Obscura rendering fallback
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
- if auto_render_spa && response && response.success? && method.to_s.upcase == 'GET'
125
- if SpaDetector.spa?(response)
126
- log_debug("[HttpMimic] Detected unhydrated SPA shell on #{url}. Automatically rendering with Obscura...")
127
- begin
128
- spa_opts = options.dup
129
- # Forward all validated cookies from Tier 1 (Mode 2: Two-Phase Pipeline)
130
- if response.cookies && !response.cookies.empty?
131
- tier1_cookies = response.cookies.to_h
132
- existing_cookies = spa_opts[:cookies].is_a?(Hash) ? spa_opts[:cookies] : {}
133
- spa_opts[:cookies] = existing_cookies.merge(tier1_cookies)
134
- end
135
- rendered_resp = Obscura.render(url, spa_opts)
136
- if rendered_resp && rendered_resp.success?
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 && response && response.cookies && !response.cookies.empty?
147
- CookieStore.save(host, response.cookies)
148
- log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
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, :curl] : [:mobile]
281
+ auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:mobile]
173
282
  when :android_first
174
- auto_fallback ? [:android, :ios, :impersonate, :curl] : [:android]
283
+ auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:android]
175
284
  when :ios_first
176
- auto_fallback ? [:ios, :android, :impersonate, :curl] : [:ios]
285
+ auto_fallback ? [:ios, :android, :impersonate, :firefox] : [:ios]
177
286
  when :impersonate_first
178
- auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
287
+ auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
179
288
  when :auto, :smart
180
- auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
289
+ auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
181
290
  else
182
- auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
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)
@@ -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
- "#<#{self.class.name}:0x#{object_id.to_s(16)} @code=#{code} @status_message=#{status_message.inspect} @headers=#{headers.to_h.inspect} @parsed_response=#{parsed_response.inspect}>"
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.success?
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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpMimic
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.5"
5
5
  end
6
+