Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for SAML authentication #253

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
coverage
pkg
Gemfile.lock
.DS_Store
59 changes: 54 additions & 5 deletions app/controllers/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,69 @@ def check_target_availability
handle_redirection(res)
end

def handle_scheme_change(effective_url, effective_uri)
# Case of http://a.com => https://a.com (or the opposite)
if !NS::ParsedCli.ignore_main_redirect && target.uri.domain == effective_uri.domain &&
target.uri.path == effective_uri.path && target.uri.scheme != effective_uri.scheme

target.url = effective_url
end
end

# Checks if the effective_uri contains a SAMLRequest
#
# @param [ Addressable::URI ] effective_uri
#
# @return [ Boolean ]
def saml_request?(effective_uri)
return false unless effective_uri

effective_uri.to_s.match?(/[?&]SAMLRequest/i)
end

# Handle redirect if the target contains 'SAMLRequest', indicating a need for SAML authentication.
#
# @param [ Addressable::URI ] effective_uri
# @raise [ Error::SAMLAuthenticationRequired ] If the effective_uri contains 'SAMLRequest'
#
# @return [ Void ]
def handle_saml_authentication(effective_uri)
# If we ended up here, the cookie_string is set, and no --expect-saml flag was included
raise Error::SAMLAuthenticationFailed if NS::ParsedCli.cookie_string && !NS::ParsedCli.expect_saml
# If we ended up here but no --expect-saml flag was included
raise Error::SAMLAuthenticationRequired unless NS::ParsedCli.expect_saml

# Authenticate using the ferrum browser
cookie_string = BrowserAuthenticator.authenticate(effective_uri.to_s)

target_url = target.url # Needed for overriding in tests

# Filter out --expect-saml, --cookie-string, and --no-banner flags from the original options
filtered_options = ARGV.reject do |arg|
arg.start_with?('--expect-saml', '--cookie-string', '--no-banner')
end.join(' ')

# Restart the scan with the cookies set and pass in the original options filtered
command = "wpscan --url #{target_url} --cookie-string '#{cookie_string}' --no-banner #{filtered_options}"
raise Error::AuthenticatedRescanFailure, command unless Kernel.system(command)

exit(NS::ExitCode::OK)
end

# Checks for redirects, an out of scope redirect will raise an Error::HTTPRedirect
#
# @param [ Typhoeus::Response ] res
def handle_redirection(res)
effective_url = target.homepage_res.effective_url # Basically get and follow location of target.url
effective_uri = Addressable::URI.parse(effective_url)

# Case of http://a.com => https://a.com (or the opposite)
if !NS::ParsedCli.ignore_main_redirect && target.uri.domain == effective_uri.domain &&
target.uri.path == effective_uri.path && target.uri.scheme != effective_uri.scheme

target.url = effective_url
if NS::ParsedCli.expect_saml && !saml_request?(effective_uri)
puts 'SAML authentication was expected but not required.'
puts # New line to serve as buffer before the scan results start
end

handle_saml_authentication(effective_uri) if saml_request?(effective_uri)
handle_scheme_change(effective_url, effective_uri)
return if target.in_scope?(effective_url)

raise Error::HTTPRedirect, effective_url unless NS::ParsedCli.ignore_main_redirect
Expand Down
9 changes: 5 additions & 4 deletions app/controllers/core/cli_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ def cli_browser_options
exists: true,
advanced: true,
default: APP_DIR.join('user_agents.txt')),
OptCredentials.new(['--http-auth login:password']),
OptPositiveInteger.new(['-t', '--max-threads VALUE', 'The max threads to use'],
default: 5),
OptPositiveInteger.new(['--throttle MilliSeconds', 'Milliseconds to wait before doing another web request. ' \
Expand All @@ -63,7 +62,7 @@ def cli_browser_options
OptBoolean.new(['--disable-tls-checks',
'Disables SSL/TLS certificate verification, and downgrade to TLS1.0+ ' \
'(requires cURL 7.66 for the latter)'])
] + cli_browser_proxy_options + cli_browser_cookies_options + cli_browser_cache_options
] + cli_authentication_options + cli_browser_cookies_options + cli_browser_cache_options
end

# @return [ Array<OptParseValidator::OptBase> ]
Expand All @@ -76,11 +75,13 @@ def cli_browser_headers_options
end

# @return [ Array<OptParseValidator::OptBase> ]
def cli_browser_proxy_options
def cli_authentication_options
[
OptCredentials.new(['--http-auth login:password', 'HTTP Authentication credentials']),
OptProxy.new(['--proxy protocol://IP:port',
'Supported protocols depend on the cURL installed']),
OptCredentials.new(['--proxy-auth login:password'])
OptCredentials.new(['--proxy-auth login:password', 'Proxy authentication credentials']),
OptBoolean.new(['--expect-saml', 'Expect SAML authentication to be required'], advanced: true)
]
end

Expand Down
2 changes: 1 addition & 1 deletion cms_scanner.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ Gem::Specification.new do |s|
s.add_dependency 'typhoeus', '>= 1.3', '< 1.5'
s.add_dependency 'xmlrpc', '~> 0.3'
s.add_dependency 'yajl-ruby', '~> 1.4.1' # Better JSON parser regarding memory usage

s.add_dependency 'sys-proctable', '>= 1.2.2', '< 1.4.0' # Required by get_process_mem for Windows OS.
s.add_dependency "ferrum", "~> 0.8"

s.add_development_dependency 'bundler', '>= 1.6'
s.add_development_dependency 'rake', '~> 13.0'
Expand Down
1 change: 1 addition & 0 deletions lib/cms_scanner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
require 'cms_scanner/finders'
require 'cms_scanner/vulnerability'
require 'cms_scanner/progressbar_null_output'
require 'cms_scanner/browser_authenticator'

# Module
module CMSScanner
Expand Down
34 changes: 34 additions & 0 deletions lib/cms_scanner/browser_authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

require 'ferrum'

module CMSScanner
module BrowserAuthenticator
def self.authenticate(login_url)
browser = Ferrum::Browser.new(headless: false)

begin
puts 'SAML authentication needed. Log in via the opened browser, then press enter.'
browser.goto(login_url)
gets # Waits for user input

# Attempt an innocuous command to check if the browser is still responsive
browser.current_url

cookies = browser.cookies.all
rescue Ferrum::BrowserError, Ferrum::DeadBrowserError
raise Error::BrowserFailed
ensure
browser.quit if browser&.process
end

raise Error::SAMLAuthenticationFailed if cookies.nil? || cookies.empty?

# Format the cookies into a string
cookies.map do |_cookie_name, cookie_object|
cookie_attributes = cookie_object.instance_variable_get(:@attributes)
"#{cookie_attributes['name']}=#{cookie_attributes['value']}"
end.join('; ')
end
end
end
1 change: 1 addition & 0 deletions lib/cms_scanner/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ class Standard < StandardError

require_relative 'errors/http'
require_relative 'errors/scan'
require_relative 'errors/saml'
49 changes: 49 additions & 0 deletions lib/cms_scanner/errors/saml.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

module CMSScanner
module Error
# SAML Authentication Required Error
class SAMLAuthenticationRequired < Standard
# :nocov:
def to_s
'SAML authentication is required to access this resource, consider using --expect-saml.'
end
# :nocov:
end

# SAML Authentication Failed Error
class SAMLAuthenticationFailed < Standard
# :nocov:
def to_s
'SAML authentication is required to access this resource. ' \
'Please ensure correct authentication credentials.'
end
# :nocov:
end

# SAML Authentication Failed Error
class AuthenticatedRescanFailure < Standard
attr_reader :command

# @param [ String ] url
def initialize(wpscan_command)
@command = wpscan_command
end

# :nocov:
def to_s
"Following authentication, the system failed to execute follow-up command: #{command}"
end
# :nocov:
end

# Ferrum Browser Error
class BrowserFailed < Standard
# :nocov:
def to_s
'The browser was closed or failed before authentication could be completed.'
end
# :nocov:
end
end
end
2 changes: 1 addition & 1 deletion lib/cms_scanner/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

# Version
module CMSScanner
VERSION = '0.13.9'
VERSION = '0.14.0'
end
80 changes: 79 additions & 1 deletion spec/app/controllers/core_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
banner cache_dir cache_ttl clear_cache connect_timeout cookie_jar cookie_string
detection_mode disable_tls_checks force format headers help hh http_auth ignore_main_redirect
max_scan_duration max_threads output proxy proxy_auth random_user_agent request_timeout
scope throttle url user_agent user_agents_list verbose version vhost
scope throttle url user_agent user_agents_list verbose version vhost expect_saml
]
)
end
Expand Down Expand Up @@ -346,4 +346,82 @@
core.after_scan
end
end

describe '#handle_redirection' do
let(:redirection) { 'http://example.com/?SAMLRequest=value' }
let(:response) { Typhoeus::Response.new(effective_url: redirection) }

before do
allow(core.target).to receive(:homepage_res).and_return(response)
allow(core.target).to receive(:in_scope?).with(redirection).and_return(true)
allow(Addressable::URI).to receive(:parse).with(redirection).and_return(Addressable::URI.parse(redirection))
end

context 'when redirection URL contains SAMLRequest' do
it 'raises SAMLAuthenticationRequired error' do
expect { core.handle_redirection(response) }.to raise_error(CMSScanner::Error::SAMLAuthenticationRequired)
end
end

context 'when redirection URL does not contain SAMLRequest' do
let(:redirection) { 'http://example.com/' } # No SAMLRequest in the URL

it 'does not raise any error' do
expect { core.handle_redirection(response) }.not_to raise_error
end
end
end

describe '#handle_saml_authentication' do
let(:target_url) { 'http://example.com' }
let(:effective_uri) { Addressable::URI.parse('http://example.com/?SAMLRequest=value') }
let(:cookies) { [{ name: 'sampleName', value: 'sampleValue' }] }
let(:cookie_string) { 'sampleName=sampleValue' }

before do
allow(CMSScanner::BrowserAuthenticator).to receive(:authenticate).and_return(cookies)
allow(CMSScanner::NS::ParsedCli).to receive(:expect_saml).and_return(true)
allow(CMSScanner::NS::ParsedCli).to receive(:cookie_string).and_return(nil)
end

context 'when SAMLRequest is present and --expect-saml is not set' do
it 'raises SAMLAuthenticationRequired error' do
allow(CMSScanner::NS::ParsedCli).to receive(:expect_saml).and_return(false)
expect { core.handle_saml_authentication(effective_uri) }
.to raise_error(CMSScanner::Error::SAMLAuthenticationRequired)
end
end

context 'when SAMLRequest is present and --expect-saml is set' do
let(:target_url) { 'http://example.com' }
let(:effective_uri) { Addressable::URI.parse("#{target_url}/?SAMLRequest=value") }
let(:mock_cookie_string) { 'session_id=abc123; auth_token=xyz789' }

before do
allow(core).to receive_message_chain(:target, :url).and_return(target_url)
allow(CMSScanner::BrowserAuthenticator)
.to receive(:authenticate)
.with(effective_uri.to_s)
.and_return(mock_cookie_string)
# Mock the Kernel.system call before the test and ensure it returns true
allow(Kernel).to receive(:system).and_return(true)
end

it 'authenticates and restarts scan with cookies and filters original options' do
original_options = '--some-flag value --another-flag --expect-saml --cookie-string old_value --no-banner'
stub_const('ARGV', original_options.split)
filtered_options = original_options.split.reject do |arg|
arg.start_with?('--expect-saml', '--cookie-string', '--no-banner')
end.join(' ')
command = "wpscan --url #{target_url} --cookie-string '#{mock_cookie_string}' --no-banner #{filtered_options}"

expect(Kernel).to receive(:system).with(command).and_return(true)
expect { core.handle_saml_authentication(effective_uri) }.to raise_error(SystemExit)
end
end

after do
RSpec::Mocks.space.proxy_for(CMSScanner::Browser.instance).reset # Ensure all mocks are cleared
end
end
end
Loading