TEST: hookerbrowser and browserdetails spec

This commit is contained in:
Jake Webster
2026-02-02 11:00:50 +10:00
parent 0ceb550409
commit 088b244109
2 changed files with 278 additions and 0 deletions
@@ -413,5 +413,158 @@ RSpec.describe BeEF::Core::Handlers::BrowserDetails do
expect(BeEF::Core::Models::BrowserDetails).to receive(:set).with(session_id, 'network.proxy.server', 'proxy.example.com')
described_class.new(proxy_data)
end
context 'filter failures (err_msg branches)' do
# Full data with optional keys so later filters (e.g. battery, capabilities) don't trigger err_msg
let(:full_data) do
data.merge('results' => data['results'].merge(
'hardware.battery.level' => '50%',
'browser.name.reported' => 'Mozilla/5.0',
'browser.engine' => 'Gecko',
'browser.window.cookies' => 'session=abc',
'host.os.name' => 'Windows',
'host.os.family' => 'Windows',
'host.os.version' => '10',
'browser.capabilities.vbscript' => 'yes'
))
end
def stub_all_filters_valid_except(except_key = nil)
%i[
is_valid_hook_session_id? is_valid_browsername? is_valid_browserversion? is_valid_ip?
is_valid_browserstring? is_valid_cookies? is_valid_osname? is_valid_hwname?
is_valid_date_stamp? is_valid_pagetitle? is_valid_url? is_valid_pagereferrer?
is_valid_hostname? is_valid_port? is_valid_browser_plugins? is_valid_system_platform?
nums_only? is_valid_yes_no? is_valid_memory? is_valid_gpu? is_valid_cpu? alphanums_only?
].each do |m|
allow(BeEF::Filters).to receive(m).and_return(except_key == m ? false : true)
end
end
it 'calls err_msg when browser name is invalid' do
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
stub_all_filters_valid_except(:is_valid_browsername?)
allow(BeEF::Core::Models::BrowserDetails).to receive(:set)
allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name)
zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1')
allow(zombie).to receive(:firstseen=)
allow(zombie).to receive(:domain=)
allow(zombie).to receive(:port=)
allow(zombie).to receive(:httpheaders=)
allow(zombie).to receive(:httpheaders).and_return('{}')
allow(zombie).to receive(:save!)
allow(JSON).to receive(:parse).with('{}').and_return({})
allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie)
err_msg_calls = []
allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last }
described_class.new(full_data)
expect(err_msg_calls).to include(a_string_matching(/Invalid browser name/))
end
it 'calls err_msg when IP is invalid' do
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
stub_all_filters_valid_except(:is_valid_ip?)
allow(BeEF::Core::Models::BrowserDetails).to receive(:set)
allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox')
zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1')
allow(zombie).to receive(:firstseen=)
allow(zombie).to receive(:domain=)
allow(zombie).to receive(:port=)
allow(zombie).to receive(:httpheaders=)
allow(zombie).to receive(:httpheaders).and_return('{}')
allow(zombie).to receive(:save!)
allow(JSON).to receive(:parse).with('{}').and_return({})
allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie)
err_msg_calls = []
allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last }
described_class.new(full_data)
expect(err_msg_calls).to include(a_string_matching(/Invalid IP address/))
end
it 'calls err_msg when browser version is invalid' do
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
stub_all_filters_valid_except(:is_valid_browserversion?)
allow(BeEF::Core::Models::BrowserDetails).to receive(:set)
allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox')
zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1')
allow(zombie).to receive(:firstseen=)
allow(zombie).to receive(:domain=)
allow(zombie).to receive(:port=)
allow(zombie).to receive(:httpheaders=)
allow(zombie).to receive(:httpheaders).and_return('{}')
allow(zombie).to receive(:save!)
allow(JSON).to receive(:parse).with('{}').and_return({})
allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie)
err_msg_calls = []
allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last }
described_class.new(full_data)
expect(err_msg_calls).to include(a_string_matching(/Invalid browser version/))
end
it 'calls err_msg when browser.name.reported is invalid' do
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
allow(BeEF::Filters).to receive(:is_valid_browsername?).and_return(true)
allow(BeEF::Filters).to receive(:is_valid_browserversion?).and_return(true)
allow(BeEF::Filters).to receive(:is_valid_ip?).and_return(true)
allow(BeEF::Filters).to receive(:is_valid_browserstring?).and_return(false)
stub_all_filters_valid_except(nil)
allow(BeEF::Filters).to receive(:is_valid_browserstring?).and_return(false)
allow(BeEF::Core::Models::BrowserDetails).to receive(:set)
allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox')
zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1')
allow(zombie).to receive(:firstseen=)
allow(zombie).to receive(:domain=)
allow(zombie).to receive(:port=)
allow(zombie).to receive(:httpheaders=)
allow(zombie).to receive(:httpheaders).and_return('{}')
allow(zombie).to receive(:save!)
allow(JSON).to receive(:parse).with('{}').and_return({})
allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie)
err_msg_calls = []
allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last }
described_class.new(full_data)
expect(err_msg_calls).to include(a_string_matching(/browser\.name\.reported/))
end
it 'calls err_msg when cookies are invalid' do
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
stub_all_filters_valid_except(:is_valid_cookies?)
allow(BeEF::Core::Models::BrowserDetails).to receive(:set)
allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox')
zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1')
allow(zombie).to receive(:firstseen=)
allow(zombie).to receive(:domain=)
allow(zombie).to receive(:port=)
allow(zombie).to receive(:httpheaders=)
allow(zombie).to receive(:httpheaders).and_return('{}')
allow(zombie).to receive(:save!)
allow(JSON).to receive(:parse).with('{}').and_return({})
allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie)
err_msg_calls = []
allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last }
described_class.new(full_data)
expect(err_msg_calls).to include(a_string_matching(/Invalid cookies/))
end
it 'calls err_msg when host.os.name is invalid' do
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
stub_all_filters_valid_except(:is_valid_osname?)
allow(BeEF::Core::Models::BrowserDetails).to receive(:set)
allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox')
zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1')
allow(zombie).to receive(:firstseen=)
allow(zombie).to receive(:domain=)
allow(zombie).to receive(:port=)
allow(zombie).to receive(:httpheaders=)
allow(zombie).to receive(:httpheaders).and_return('{}')
allow(zombie).to receive(:save!)
allow(JSON).to receive(:parse).with('{}').and_return({})
allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie)
err_msg_calls = []
allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last }
described_class.new(full_data)
expect(err_msg_calls).to include(a_string_matching(/operating system name/))
end
end
end
end
@@ -10,6 +10,131 @@ RSpec.describe BeEF::Core::Handlers::HookedBrowsers do
# .new returns Sinatra::Wrapper; use allocate to get the real class instance for unit testing
let(:handler) { described_class.allocate }
describe "GET '/'" do
let(:config) { BeEF::Core::Configuration.instance }
# Use a host permitted by Router's host_authorization (.localhost, .test, or config public host)
let(:rack_env) { { 'REMOTE_ADDR' => '192.168.1.1', 'HTTP_HOST' => 'localhost' } }
def app
described_class
end
before do
allow(BeEF::Core::Logger.instance).to receive(:register)
allow(config).to receive(:get).and_call_original
allow(config).to receive(:get).with('beef.http.restful_api.allow_cors').and_return(false)
end
it 'returns 404 when permitted_hooking_subnet is nil' do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(nil)
get '/', {}, rack_env
expect(last_response.status).to eq(404)
end
it 'returns 404 when permitted_hooking_subnet is empty' do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return([])
get '/', {}, rack_env
expect(last_response.status).to eq(404)
end
it 'returns 404 when client IP is not in permitted subnet' do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['10.0.0.0/8'])
get '/', {}, rack_env
expect(last_response.status).to eq(404)
end
it 'returns 404 when client IP is in excluded_hooking_subnet' do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['0.0.0.0/0'])
allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return(['192.168.1.0/24'])
get '/', {}, rack_env
expect(last_response.status).to eq(404)
end
it 'returns 200 and hook body when IP permitted, not excluded, no session (new browser)' do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['192.168.0.0/16'])
allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([])
allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook')
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
allow(BeEF::Filters).to receive(:is_valid_hostname?).with('localhost').and_return(true)
allow(config).to receive(:get).with('beef.http.websocket.enable').and_return(false)
allow_any_instance_of(described_class).to receive(:confirm_browser_user_agent).and_return(false)
allow_any_instance_of(described_class).to receive(:legacy_build_beefjs!).with('localhost')
get '/', {}, rack_env
expect(last_response.status).to eq(200)
expect(last_response.headers['Content-Type']).to include('javascript')
end
it 'uses multi_stage_beefjs when websocket disabled and confirm_browser_user_agent true' do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['0.0.0.0/0'])
allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([])
allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook')
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
allow(BeEF::Filters).to receive(:is_valid_hostname?).with('localhost').and_return(true)
allow(config).to receive(:get).with('beef.http.websocket.enable').and_return(false)
allow_any_instance_of(described_class).to receive(:confirm_browser_user_agent).and_return(true)
allow_any_instance_of(described_class).to receive(:multi_stage_beefjs!).with('localhost')
get '/', {}, { 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_HOST' => 'localhost' }
expect(last_response.status).to eq(200)
end
it 'returns early with empty body when hostname is invalid' do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['0.0.0.0/0'])
allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([])
allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook')
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([])
# Use permitted host so request reaches handler; stub hostname validation to fail
allow(BeEF::Filters).to receive(:is_valid_hostname?).with('localhost').and_return(false)
get '/', {}, { 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_HOST' => 'localhost' }
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('')
end
context 'when session exists (existing browser path)' do
let(:hooked_browser) do
double('HookedBrowser',
id: 1,
ip: '192.168.1.1',
lastseen: Time.new.to_i - 120,
session: 'existing_session',
count!: nil,
save!: true).tap do |d|
allow(d).to receive(:lastseen=)
allow(d).to receive(:ip=)
end
end
before do
allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['192.168.0.0/16'])
allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([])
allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook')
allow(config).to receive(:get).with('beef.http.allow_reverse_proxy').and_return(false)
relation = double('Relation', first: hooked_browser)
allow(BeEF::Core::Models::HookedBrowser).to receive(:where).with(session: 'existing_session').and_return(relation)
allow(BeEF::Core::Models::Command).to receive(:where).with(hooked_browser_id: 1, instructions_sent: false).and_return([])
allow(BeEF::Core::Models::Execution).to receive(:where).with(is_sent: false, session_id: 'existing_session').and_return([])
allow(BeEF::API::Registrar.instance).to receive(:fire)
end
it 'returns 200 and updates lastseen' do
get '/', { 'beefhook' => 'existing_session' }, rack_env
expect(last_response.status).to eq(200)
expect(hooked_browser).to have_received(:save!)
end
it 'logs zombie comeback when lastseen was more than 60 seconds ago' do
get '/', { 'beefhook' => 'existing_session' }, rack_env
expect(BeEF::Core::Logger.instance).to have_received(:register).with('Zombie', /appears to have come back online/, '1')
end
it 'calls add_command_instructions for each pending command' do
command = double('Command', id: 1, command_module_id: 1)
allow(BeEF::Core::Models::Command).to receive(:where).with(hooked_browser_id: 1, instructions_sent: false).and_return([command])
expect_any_instance_of(described_class).to receive(:add_command_instructions).with(command, hooked_browser)
get '/', { 'beefhook' => 'existing_session' }, rack_env
end
end
end
describe '#confirm_browser_user_agent' do
it 'returns true when user_agent suffix matches a legacy UA string' do
allow(BeEF::Core::Models::LegacyBrowserUserAgents).to receive(:user_agents).and_return(['IE 8.0'])