• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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