diff --git a/app/finders/users/wp_json_api.rb b/app/finders/users/wp_json_api.rb index 4bc27ad9..8e68d8a1 100644 --- a/app/finders/users/wp_json_api.rb +++ b/app/finders/users/wp_json_api.rb @@ -6,18 +6,27 @@ module WPScan # Since 4.7 - Need more investigation as it seems WP 4.7.1 reduces the exposure, see https://github.com/wpscanteam/wpscan/issues/1038) # class WpJsonApi < CMSScanner::Finders::Finder + MAX_PER_PAGE = 100 # See https://developer.wordpress.org/rest-api/using-the-rest-api/pagination/ + # @param [ Hash ] opts # # @return [ Array ] def aggressive(_opts = {}) - found = [] + found = [] + current_page = 0 - JSON.parse(Browser.get(api_url).body)&.each do |user| - found << CMSScanner::User.new(user['slug'], - id: user['id'], - found_by: found_by, - confidence: 100, - interesting_entries: [api_url]) + loop do + current_page += 1 + + res = Typhoeus.get(api_url, + params: { per_page: MAX_PER_PAGE, page: current_page }) + + total_pages ||= res.headers['X-WP-TotalPages'].to_i + + users_in_page = users_from_response(res) + found += users_in_page + + break if current_page >= total_pages || users_in_page.empty? end found @@ -25,6 +34,23 @@ module WPScan found end + # @param [ Typhoeus::Response ] response + # + # @return [ Array ] The users from the response + def users_from_response(response) + found = [] + + JSON.parse(response.body)&.each do |user| + found << CMSScanner::User.new(user['slug'], + id: user['id'], + found_by: found_by, + confidence: 100, + interesting_entries: [response.effective_url]) + end + + found + end + # @return [ String ] The URL of the API listing the Users def api_url @api_url ||= target.url('wp-json/wp/v2/users/') diff --git a/spec/app/finders/users/wp_json_api_spec.rb b/spec/app/finders/users/wp_json_api_spec.rb index df911f8f..6f7e2cb4 100644 --- a/spec/app/finders/users/wp_json_api_spec.rb +++ b/spec/app/finders/users/wp_json_api_spec.rb @@ -2,42 +2,79 @@ describe WPScan::Finders::Users::WpJsonApi do subject(:finder) { described_class.new(target) } let(:target) { WPScan::Target.new(url) } let(:url) { 'http://wp.lab/' } - let(:fixtures) { File.join(FINDERS_FIXTURES, 'users', 'wp_json_api') } + let(:fixtures) { FINDERS_FIXTURES.join('users', 'wp_json_api') } describe '#aggressive' do - before do - allow(target).to receive(:sub_dir).and_return(false) - stub_request(:get, finder.api_url).to_return(body: body) - end + before { allow(target).to receive(:sub_dir).and_return(false) } - context 'when not a JSON response' do - let(:body) { '' } + context 'when only one page of results' do + before do + stub_request(:get, finder.api_url) + .with(query: { page: 1, per_page: 100 }) + .to_return(body: body, headers: {}) + end - its(:aggressive) { should eql([]) } - end - - context 'when a JSON response' do - context 'when unauthorised' do - let(:body) { File.read(File.join(fixtures, '401.json')) } + context 'when not a JSON response' do + let(:body) { '' } its(:aggressive) { should eql([]) } end - context 'when limited exposure (WP >= 4.7.1)' do - let(:body) { File.read(File.join(fixtures, '4.7.2.json')) } + context 'when a JSON response' do + context 'when unauthorised' do + let(:body) { File.read(fixtures.join('401.json')) } - it 'returns the expected array of users' do - users = finder.aggressive - - expect(users.size).to eql 1 - - user = users.first - - expect(user.id).to eql 1 - expect(user.username).to eql 'admin' - expect(user.confidence).to eql 100 - expect(user.interesting_entries).to eql ['http://wp.lab/wp-json/wp/v2/users/'] + its(:aggressive) { should eql([]) } end + + context 'when limited exposure (WP >= 4.7.1)' do + let(:body) { File.read(fixtures.join('4.7.2.json')) } + + it 'returns the expected array of users' do + users = finder.aggressive + + expect(users.size).to eql 1 + + user = users.first + + expect(user.id).to eql 1 + expect(user.username).to eql 'admin' + expect(user.confidence).to eql 100 + expect(user.interesting_entries).to eql ['http://wp.lab/wp-json/wp/v2/users/?page=1&per_page=100'] + end + end + end + end + + context 'when multiple pages of results' do + before do + stub_request(:get, finder.api_url) + .with(query: { page: 1, per_page: 100 }) + .to_return(body: File.read(fixtures.join('4.7.2.json')), headers: { 'X-WP-TotalPages' => 2 }) + + stub_request(:get, finder.api_url) + .with(query: { page: 2, per_page: 100 }) + .to_return(body: File.read(fixtures.join('4.7.2-2.json')), headers: { 'X-WP-TotalPages' => 2 }) + end + + it 'returns the expected array of users' do + users = finder.aggressive + + expect(users.size).to eql 2 + + user = users.first + + expect(user.id).to eql 1 + expect(user.username).to eql 'admin' + expect(user.confidence).to eql 100 + expect(user.interesting_entries).to eql ['http://wp.lab/wp-json/wp/v2/users/?page=1&per_page=100'] + + user = users.second + + expect(user.id).to eql 20 + expect(user.username).to eql 'user' + expect(user.confidence).to eql 100 + expect(user.interesting_entries).to eql ['http://wp.lab/wp-json/wp/v2/users/?page=2&per_page=100'] end end end diff --git a/spec/fixtures/finders/users/wp_json_api/4.7.2-2.json b/spec/fixtures/finders/users/wp_json_api/4.7.2-2.json new file mode 100644 index 00000000..a1866b8e --- /dev/null +++ b/spec/fixtures/finders/users/wp_json_api/4.7.2-2.json @@ -0,0 +1,28 @@ +[ + { + "id": 20, + "name": "user", + "url": "", + "description": "", + "link": "http://wp.lab/wordpress-4.7/author/user/", + "slug": "user", + "avatar_urls": { + "24": "http://1.gravatar.com/avatar/473fe256a0c7b9e907b55b2f492f8686?s=24&d=mm&r=g", + "48": "http://1.gravatar.com/avatar/473fe256a0c7b9e907b55b2f492f8686?s=48&d=mm&r=g", + "96": "http://1.gravatar.com/avatar/473fe256a0c7b9e907b55b2f492f8686?s=96&d=mm&r=g" + }, + "meta": [], + "_links": { + "self": [ + { + "href": "http://wp.lab/wordpress-4.7/wp-json/wp/v2/users/20" + } + ], + "collection": [ + { + "href": "http://wp.lab/wordpress-4.7/wp-json/wp/v2/users" + } + ] + } + } +] \ No newline at end of file