1#!/usr/bin/ruby 2# iExploder browser Harness (test a single web browser) 3# 4# Copyright 2010 Thomas Stromberg - All Rights Reserved. 5# 6# Licensed under the Apache License, Version 2.0 (the "License"); 7# you may not use this file except in compliance with the License. 8# You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17# 18#---------------------------------------------------------------------------- 19# PLEASE NOTE: 20# 21# You must disable automatic session restoring for this to be useful. 22# 23# chrome --incognito 24# opera --nosession -newprivatetab 25# firefox -private 26require 'cgi' 27require 'open-uri' 28require 'optparse' 29require './iexploder.rb' 30require './scanner.rb' 31 32MAC_CRASH_PATH = "#{ENV['HOME']}/Library/Logs/CrashReporter" 33TESTCASE_URL = "http://127.0.0.1:3100/iexploder.cgi" 34 35class BrowserHarness 36 def initialize(port, config_path, log_dir, test_dir, watchdog_timer, scan_timer) 37 @app_base_url = "http://127.0.0.1:#{port}/" 38 @app_url = "#{@app_base_url}iexploder.cgi" 39 @port = port 40 @log_dir = log_dir 41 @server_log_path = "#{log_dir}/iexploder_webserver-#{port}.log" 42 @client_log_path = "#{log_dir}/iexploder_harness-#{port}.log" 43 @test_dir = test_dir 44 @watchdog_timer = watchdog_timer 45 @scan_timer = scan_timer 46 @config_path = config_path 47 48 @ie = IExploder.new(@config_path) 49 @ie.cgi_url = @app_url 50 51 @browser_id = nil 52 @browser_name = nil 53 msg("Client log: #{@client_log_path}") 54 msg("Server log: #{@server_log_path}") 55 @server_pid = launch_server() 56 end 57 58 def msg(text) 59 now = Time.now() 60 msg = ">>> #{@browser_name}:#{@port} | #{now}: #{text}" 61 puts msg 62 STDOUT.flush 63 64 f = File.open(@client_log_path, 'a') 65 f.puts msg 66 f.close 67 end 68 69 def launch_server() 70 args = ['./webserver.rb', "-p#{@port}", "-c#{@config_path}", "-l#{@server_log_path}"] 71 pids = fork { exec(*args) } 72 msg("Server args: #{args.inspect}") 73 msg("Server pid: #{pids.inspect}") 74 return pids 75 end 76 77 def launch_browser(args, url) 78 if ! File.exist?(args[0]) 79 msg("First argument does not appear to be an executable file: #{args[0]}") 80 kill_server() 81 exit 82 end 83 84 browser = File.basename(args[0]) 85 @browser_name = File.basename(browser) 86 if browser =~ /\.app$/ 87 pids = launch_mac_browser(args, url) 88 else 89 pids = launch_posix_browser(args, url) 90 end 91 sleep(@scan_timer * 3) 92 if ! File.size?(@server_log_path) 93 puts "#{@server_log_path} was never written to. Unable to launch browser?" 94 kill_server() 95 exit 96 end 97 return pids 98 end 99 100 def launch_posix_browser(args, url) 101 browser = File.basename(args[0]) 102 msg("Killing browser processes: #{browser}") 103 system("pkill #{browser} && pkill -9 #{browser}") 104 args = args + [url] 105 msg("Launching browser: #{args.inspect}") 106 browser_pid = fork { 107 exec(*args) 108 } 109 return [browser_pid] 110 end 111 112 def find_pids(text) 113 # Only tested on Mac OS X. 114 pids = [] 115 `ps -x`.each do |proc_line| 116 if proc_line =~ /^ *(\d+).*#{text}/ 117 pid = $1.to_i 118 # Do not include yourself. 119 if pid != Process.pid 120 pids << $1.to_i 121 end 122 end 123 end 124 return pids 125 end 126 127 def launch_mac_browser(args, url) 128 # This is dedicated to Safari. 129 if args.length > 1 130 msg(".app type launches do not support arguments, ignoring #{args[1..99].inspect}") 131 end 132 browser = args[0] 133 pids = find_pids(browser) 134 if pids 135 kill_pids(find_pids(browser)) 136 sleep(2) 137 end 138 command = "open -a \"#{browser}\" \"#{url}\"" 139 msg(".app open command: #{command}") 140 system(command) 141 return find_pids(browser) 142 end 143 144 def kill_pids(pids) 145 pids.each do |pid| 146 msg("Killing #{pid}") 147 begin 148 Process.kill("INT", pid) 149 sleep(0.5) 150 Process.kill("KILL", pid) 151 rescue 152 sleep(0.1) 153 end 154 end 155 end 156 157 def encode_browser() 158 return @browser_id.gsub(' ', '_').gsub(';', '').gsub('/', '-').gsub(/[\(\):\!\@\#\$\%\^\&\*\+=\{\}\[\]\'\"\<\>\?\|\\]/, '').gsub(/_$/, '').gsub(/^_/, '') 159 end 160 161 def kill_server() 162 kill_pids([@server_pid]) 163 end 164 165 def parse_test_url(value) 166 current_vars = nil 167 test_num = nil 168 subtest_data = nil 169 lookup_values = false 170 if value =~ /iexploder.cgi(.*)/ 171 current_vars = $1 172 if current_vars =~ /[&\?]t=(\d+)/ 173 test_num = $1 174 end 175 if current_vars =~ /[&\?]s=([\d_,]+)/ 176 subtest_data = $1 177 end 178 if current_vars =~ /[&\?]l=(\w+)/ 179 lookup_value = $1 180 end 181 else 182 msg("Unable to parse url in #{value}") 183 return [nil, nil, nil, nil] 184 end 185 return [current_vars, test_num, subtest_data, lookup_value] 186 end 187 188 def check_log_status() 189 timestamp, uri, user_agent = open("#{@app_base_url}last_page.cgi").read().chomp.split(' ') 190 age = (Time.now() - timestamp.to_i).to_i 191 if not @browser_id 192 @browser_id = CGI.unescape(user_agent) 193 msg("My browser is #{@browser_id}") 194 end 195 196 197 return [age, uri] 198 end 199 200 def save_testcase(url, case_type=nil) 201 msg("Saving testcase: #{url}") 202 vars, test_num, subtest_data, lookup_value = parse_test_url(url) 203 if not case_type 204 case_type = 'testcase' 205 end 206 207 testcase_name = ([case_type, encode_browser(), 'TEST', test_num, subtest_data].join('-')).gsub(/-$/, '') + ".html" 208 testcase_path = "#{@test_dir}/#{testcase_name}" 209 data = open(url).read() 210 # Slow down our redirection time, and replace our testcase urls. 211 data.gsub!(/0;URL=\/iexploder.*?\"/, "1;URL=#{testcase_name}\"") 212 data.gsub!(/window\.location=\"\/iexploder.*?\"/, "window\.location=\"#{testcase_name}\"") 213 214 # I wish I did not have to do this, but the reality is that I can't imitate header fuzzing 215 # without a webservice in the backend. Change all URL's to use a well known localhost 216 # port. 217 data.gsub!(/\/iexploder.cgi/, TESTCASE_URL) 218 219 f = File.open(testcase_path, 'w') 220 f.write(data) 221 f.close 222 msg("Wrote testcase #{testcase_path}") 223 return testcase_path 224 end 225 226 def calculate_next_url(test_num, subtest_data) 227 @ie.test_num = test_num.to_i 228 @ie.subtest_data = subtest_data 229 if subtest_data and subtest_data.length > 0 230 (width, offsets) = @ie.parseSubTestData(subtest_data) 231 # We increment within combo_creator 232 (width, offsets, lines) = combine_combo_creator(@ie.config['html_tags_per_page'], width, offsets) 233 return @ie.generateTestUrl(@ie.nextTestNum(), width, offsets) 234 else 235 return @ie.generateTestUrl(@ie.nextTestNum()) 236 end 237 end 238 239 def find_crash_logs(max_age) 240 crashed_files = [] 241 check_files = Dir.glob("*core*") 242 if File.exists?(MAC_CRASH_PATH) 243 check_files = check_files + Dir.glob("#{MAC_CRASH_PATH}/*.*") 244 end 245 check_files.each do |file| 246 mtime = File.stat(file).mtime 247 age = (Time.now() - mtime).to_i 248 if age < max_age 249 msg("#{file} is only #{age}s old: #{mtime}") 250 crashed_files << file 251 end 252 end 253 return crashed_files 254 end 255 256 def test_browser(args, test_num, random_mode=false) 257 # NOTE: random_mode is not yet supported. 258 259 browser_pids = [] 260 subtest_data = nil 261 @ie.test_num = test_num 262 @ie.random_mode = random_mode 263 next_url = @ie.generateTestUrl(test_num) 264 265 while next_url 266 msg("Starting at: #{next_url}") 267 if browser_pids 268 kill_pids(browser_pids) 269 end 270 browser_pids = launch_browser(args, next_url) 271 test_is_running = true 272 crash_files = [] 273 274 while test_is_running 275 sleep(@scan_timer) 276 begin 277 age, request_uri = check_log_status() 278 rescue 279 msg("Failed to get status. webserver likely crashed.") 280 kill_pids([@server_pid]) 281 @server_pid = launch_server() 282 next_url = @ie.generateTestUrl(test_num) 283 test_is_running = false 284 next 285 end 286 vars, test_num, subtest_data, lookup_value = parse_test_url(request_uri) 287 if lookup_value == 'survived_redirect' 288 msg("We survived #{vars}. Bummer, could not repeat crash. Moving on.") 289 test_is_running = false 290 next_url = calculate_next_url(test_num, subtest_data) 291 next 292 elsif age > @watchdog_timer 293 msg("Stuck at #{vars}, waited for #{@watchdog_timer}s. Killing browser.") 294 kill_pids(browser_pids) 295 current_url = "#{@app_url}#{vars}" 296# save_testcase(current_url, 'possible') 297 crash_files = find_crash_logs(@watchdog_timer + (@scan_timer * 2)) 298 if crash_files.length > 0 299 msg("Found recent crash logs: #{crash_files.inspect} - last page: #{current_url}") 300 end 301 302 if vars =~ /THE_END/ 303 msg("We hung at the end. Saving a testcase just in case.") 304 save_testcase(current_url) 305 next_url = calculate_next_url(test_num, nil) 306 test_is_running = false 307 next 308 end 309 310 # This is for subtesting 311 if subtest_data 312 if lookup_value 313 msg("Confirmed crashing/hanging page at #{current_url} - saving testcase.") 314 save_testcase(current_url) 315 next_url = calculate_next_url(test_num, nil) 316 test_is_running = false 317 next 318 else 319 msg("Stopped at #{current_url}. Attempting to reproduce simplified crash/hang condition.") 320 browser_pids = launch_browser(args, "#{current_url}&l=test_redirect") 321 end 322 # Normal testing goes here 323 else 324 if lookup_value 325 msg("Reproducible crash/hang at #{current_url}, generating smaller test case.") 326 url = current_url.gsub(/&l=(\w+)/, '') 327 browser_pids = launch_browser(args, "#{url}&s=0") 328 else 329 msg("Stopped at #{current_url}. Attempting to reproduce crash/hang condition.") 330 browser_pids = launch_browser(args, "#{current_url}&l=test_redirect") 331 end 332 end 333 elsif age > @scan_timer 334 msg("Waiting for #{vars} to finish loading... (#{age}s of #{@watchdog_timer}s)") 335 end 336 end 337 end 338 end 339end 340 341if $0 == __FILE__ 342 options = { 343 :port => rand(16000).to_i + 16000, 344 :test_dir => File.dirname($0) + '/../output', 345 :log_dir => File.dirname($0) + '/../output', 346 :test_num => nil, 347 :watchdog_timer => 60, 348 :scan_timer => 5, 349 :config_path => 'config.yaml', 350 :random_mode => false 351 } 352 353 optparse = OptionParser.new do |opts| 354 opts.banner = "Usage: browser_harness.rb [options] -- <browser path> <browser options>" 355 opts.on( '-t', '--test NUM', 'Test to start at' ) { |test_num| options[:test_num] = test_num.to_i } 356 opts.on( '-p', '--port NUM', 'Listen on TCP port NUM (random)' ) { |port| options[:port] = port.to_i } 357 opts.on( '-c', '--config PATH', 'Use PATH for configuration file' ) { |path| options[:config_path] = path } 358 opts.on( '-d', '--testdir PATH', 'Use PATH to save testcases (/tmp)' ) { |path| options[:test_dir] = path } 359 opts.on( '-l', '--logdir PATH', 'Use PATH to save logs (/tmp)' ) { |path| options[:log_dir] = path } 360 opts.on( '-w', '--watchdog NUM', 'How many seconds to wait for pages to load (45s)' ) { |sec| options[:watchdog_timer] = sec.to_i } 361 opts.on( '-r', '--random', 'Generate test numbers pseudo-randomly' ) { options[:random_mode] = true } 362 opts.on( '-s', '--scan NUM', 'How often to check for new log data (5s)' ) { |sec| options[:scan_timer] = sec.to_i } 363 opts.on( '-h', '--help', 'Display this screen' ) { puts opts; exit } 364 end 365 optparse.parse! 366 367 if options[:port] == 0 368 puts "Unable to parse port option. Try adding -- as an argument before you specify your browser location." 369 exit 370 end 371 372 if ARGV.length < 1 373 puts "No browser specified. Perhaps you need some --help?" 374 exit 375 end 376 puts "options: #{options.inspect}" 377 puts "browser: #{ARGV.inspect}" 378 379 harness = BrowserHarness.new( 380 options[:port], 381 options[:config_path], 382 options[:log_dir], 383 options[:test_dir], 384 options[:watchdog_timer], 385 options[:scan_timer] 386 ) 387 388 harness.test_browser(ARGV, options[:test_num], options[:random_mode]) 389end 390