Module: Heatwave::Crawler::Guards
- Defined in:
- app/services/heatwave/crawler/guards.rb
Overview
Reopens Heatwave::Crawler (defined in crawler.rb) to house the Guards
module of shared fetch-safety and bot-wall heuristics.
Shared fetch safety + bot-wall heuristics, promoted from the Sunny
fetch_url tool so every crawler consumer gets the same protection:
- Guards.validate_public_url! SSRF guard — http(s) only, no embedded
credentials, DNS-resolved IPs must not be loopback/private/link-local.
Caveat (documented in the supercrawler plan): the guard resolves once
and the fetcher re-resolves, so DNS-rebinding is theoretically possible;
accepted risk today. - Guards.bot_challenge? Cloudflare/Amazon/etc. interstitial markers.
- Guards.blocked_content? post-extraction captcha/block phrases.
- Guards.garbled_content? binary/non-text body detection.
Constant Summary collapse
- BOT_CHALLENGE_STATUSES =
HTTP statuses that are bot-wall responses even without body markers.
[403, 503].freeze
- BOT_CHALLENGE_MARKERS =
Raw-HTML markers of a bot interstitial (Cloudflare, Amazon, generic).
[ 'Just a moment', 'cf-browser-verification', '__cf_chl_', 'Enable JavaScript and cookies to continue', 'Checking your browser', 'Click the button below to continue shopping', 'Enter the characters you see below', 'Sorry, we just need to make sure', 'api-services-support@amazon.com', 'To discuss automated access to Amazon', 'Please verify you are a human', 'Access Denied</title>', 'Robot or human?' ].freeze
- BLOCKED_CONTENT_MARKERS =
Downcased extracted-text markers of a block/captcha page.
[ 'click the button below to continue shopping', 'enter the characters you see below', 'sorry, we just need to make sure', 'please verify you are a human', 'automated access', 'unusual traffic from your computer', 'robot or human?' ].freeze
Class Method Summary collapse
-
.blocked_content?(text) ⇒ Boolean
True when the extracted content is a block page.
-
.bot_challenge?(body, status_code) ⇒ Boolean
True when the response is a bot interstitial.
-
.garbled_content?(body) ⇒ Boolean
True when the body looks binary/non-text.
- .reject_private_targets!(uri) ⇒ void
-
.sanitize_url_for_log(url) ⇒ String
Host+path, safe for log lines (no query/credentials).
-
.validate_public_url!(raw_url, allow_private_hosts: false) ⇒ URI::HTTP
The parsed URI.
Class Method Details
.blocked_content?(text) ⇒ Boolean
Returns true when the extracted content is a block page.
95 96 97 98 99 100 |
# File 'app/services/heatwave/crawler/guards.rb', line 95 def blocked_content?(text) downcased = text.to_s.downcase # rubocop:disable Style/ArrayIntersect -- substring matching, not element equality BLOCKED_CONTENT_MARKERS.any? { |marker| downcased.include?(marker) } # rubocop:enable Style/ArrayIntersect end |
.bot_challenge?(body, status_code) ⇒ Boolean
Returns true when the response is a bot interstitial.
81 82 83 84 85 86 87 88 89 90 91 |
# File 'app/services/heatwave/crawler/guards.rb', line 81 def bot_challenge?(body, status_code) return true if BOT_CHALLENGE_STATUSES.include?(status_code) body = body.to_s # A short page whose only payload is the challenge platform script. return true if body.length < 15_000 && body.include?('challenge-platform') # rubocop:disable Style/ArrayIntersect -- substring matching, not element equality BOT_CHALLENGE_MARKERS.any? { |marker| body.include?(marker) } # rubocop:enable Style/ArrayIntersect end |
.garbled_content?(body) ⇒ Boolean
Returns true when the body looks binary/non-text.
104 105 106 107 108 109 110 |
# File 'app/services/heatwave/crawler/guards.rb', line 104 def garbled_content?(body) sample = body.to_s.byteslice(0, 2048) || '' return false if sample.empty? non_text = sample.each_byte.count { |byte| byte < 9 || (byte > 13 && byte < 32) || byte == 127 } non_text.to_f / sample.bytesize > 0.15 end |
.reject_private_targets!(uri) ⇒ void
This method returns an undefined value.
126 127 128 129 130 131 132 133 |
# File 'app/services/heatwave/crawler/guards.rb', line 126 def reject_private_targets!(uri) Resolv.each_address(uri.host) do |addr| ip = IPAddr.new(addr) raise ArgumentError, 'Private or local network targets are not allowed.' if ip.loopback? || ip.private? || ip.link_local? end rescue SocketError raise ArgumentError, "Host could not be resolved: #{uri.host}" end |
.sanitize_url_for_log(url) ⇒ String
Returns host+path, safe for log lines (no query/credentials).
114 115 116 117 118 119 |
# File 'app/services/heatwave/crawler/guards.rb', line 114 def sanitize_url_for_log(url) uri = URI.parse(url.to_s.strip) "#{uri.host}#{uri.path}" rescue URI::InvalidURIError '[invalid-url]' end |
.validate_public_url!(raw_url, allow_private_hosts: false) ⇒ URI::HTTP
Returns the parsed URI.
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'app/services/heatwave/crawler/guards.rb', line 59 def validate_public_url!(raw_url, allow_private_hosts: false) uri = begin URI.parse(raw_url.to_s.strip) rescue URI::InvalidURIError => e # URI.parse raises URI::InvalidURIError (a URI::Error, NOT an # ArgumentError) on malformed input — e.g. an unrendered template link # like ".../{{ locale }}/...". Honor this method's documented # ArgumentError contract so Crawler#fetch's `rescue ArgumentError` turns # it into a guarded failure Result instead of crashing the caller # (AppSignal #6332: ArticleLinkCheckerWorker). raise ArgumentError, "Malformed URL: #{e.}" end raise ArgumentError, 'Only http:// and https:// URLs are supported.' unless uri.is_a?(URI::HTTP) && uri.host.present? raise ArgumentError, 'URLs with embedded credentials are not supported.' if uri.userinfo.present? reject_private_targets!(uri) unless allow_private_hosts uri end |