Commit 87fd342a2317567f7854570dd5624dd64dffebd4
Exists in
spb-stable
and in
2 other branches
Merge branch 'more-secure-api' into 'master'
More secure api Dont expose user email via API. Fixes #1314
Showing
17 changed files
with
161 additions
and
64 deletions
Show diff stats
CHANGELOG
| ... | ... | @@ -35,6 +35,7 @@ v 7.0.0 |
| 35 | 35 | - Be more selective when killing stray Sidekiqs |
| 36 | 36 | - Check LDAP user filter during sign-in |
| 37 | 37 | - Remove wall feature (no data loss - you can take it from database) |
| 38 | + - Dont expose user emails via API unless you are admin | |
| 38 | 39 | |
| 39 | 40 | v 6.9.2 |
| 40 | 41 | - Revert the commit that broke the LDAP user filter | ... | ... |
app/assets/javascripts/project_users_select.js.coffee
| ... | ... | @@ -37,13 +37,9 @@ |
| 37 | 37 | |
| 38 | 38 | projectUserFormatResult: (user) -> |
| 39 | 39 | if user.avatar_url |
| 40 | - avatar = gon.relative_url_root + user.avatar_url | |
| 41 | - else if gon.gravatar_enabled | |
| 42 | - avatar = gon.gravatar_url | |
| 43 | - avatar = avatar.replace('%{hash}', md5(user.email)) | |
| 44 | - avatar = avatar.replace('%{size}', '24') | |
| 40 | + avatar = user.avatar_url | |
| 45 | 41 | else |
| 46 | - avatar = gon.relative_url_root + "#{image_path('no_avatar.png')}" | |
| 42 | + avatar = gon.default_avatar_url | |
| 47 | 43 | |
| 48 | 44 | if user.id == '' |
| 49 | 45 | avatarMarkup = '' | ... | ... |
app/assets/javascripts/users_select.js.coffee
| 1 | 1 | $ -> |
| 2 | 2 | userFormatResult = (user) -> |
| 3 | 3 | if user.avatar_url |
| 4 | - avatar = gon.relative_url_root + user.avatar_url | |
| 5 | - else if gon.gravatar_enabled | |
| 6 | - avatar = gon.gravatar_url | |
| 7 | - avatar = avatar.replace('%{hash}', md5(user.email)) | |
| 8 | - avatar = avatar.replace('%{size}', '24') | |
| 4 | + avatar = user.avatar_url | |
| 9 | 5 | else |
| 10 | - avatar = gon.relative_url_root + "#{image_path('no_avatar.png')}" | |
| 6 | + avatar = gon.default_avatar_url | |
| 11 | 7 | |
| 12 | 8 | "<div class='user-result'> |
| 13 | 9 | <div class='user-image'><img class='avatar s24' src='#{avatar}'></div> | ... | ... |
app/controllers/application_controller.rb
| ... | ... | @@ -164,9 +164,8 @@ class ApplicationController < ActionController::Base |
| 164 | 164 | def add_gon_variables |
| 165 | 165 | gon.default_issues_tracker = Project.issues_tracker.default_value |
| 166 | 166 | gon.api_version = API::API.version |
| 167 | - gon.gravatar_url = request.ssl? || Gitlab.config.gitlab.https ? Gitlab.config.gravatar.ssl_url : Gitlab.config.gravatar.plain_url | |
| 168 | 167 | gon.relative_url_root = Gitlab.config.gitlab.relative_url_root |
| 169 | - gon.gravatar_enabled = Gitlab.config.gravatar.enabled | |
| 168 | + gon.default_avatar_url = URI::join(Gitlab.config.gitlab.url, ActionController::Base.helpers.image_path('no_avatar.png')).to_s | |
| 170 | 169 | |
| 171 | 170 | if current_user |
| 172 | 171 | gon.current_user_id = current_user.id | ... | ... |
app/helpers/application_helper.rb
| ... | ... | @@ -60,23 +60,21 @@ module ApplicationHelper |
| 60 | 60 | |
| 61 | 61 | def avatar_icon(user_email = '', size = nil) |
| 62 | 62 | user = User.find_by(email: user_email) |
| 63 | - if user && user.avatar.present? | |
| 64 | - user.avatar.url | |
| 63 | + | |
| 64 | + if user | |
| 65 | + user.avatar_url(size) || default_avatar | |
| 65 | 66 | else |
| 66 | 67 | gravatar_icon(user_email, size) |
| 67 | 68 | end |
| 68 | 69 | end |
| 69 | 70 | |
| 70 | 71 | def gravatar_icon(user_email = '', size = nil) |
| 71 | - size = 40 if size.nil? || size <= 0 | |
| 72 | + GravatarService.new.execute(user_email, size) || | |
| 73 | + default_avatar | |
| 74 | + end | |
| 72 | 75 | |
| 73 | - if !Gitlab.config.gravatar.enabled || user_email.blank? | |
| 74 | - image_path('no_avatar.png') | |
| 75 | - else | |
| 76 | - gravatar_url = request.ssl? || gitlab_config.https ? Gitlab.config.gravatar.ssl_url : Gitlab.config.gravatar.plain_url | |
| 77 | - user_email.strip! | |
| 78 | - sprintf gravatar_url, hash: Digest::MD5.hexdigest(user_email.downcase), size: size, email: user_email | |
| 79 | - end | |
| 76 | + def default_avatar | |
| 77 | + image_path('no_avatar.png') | |
| 80 | 78 | end |
| 81 | 79 | |
| 82 | 80 | def last_commit(project) | ... | ... |
app/models/user.rb
| ... | ... | @@ -482,4 +482,12 @@ class User < ActiveRecord::Base |
| 482 | 482 | def public_profile? |
| 483 | 483 | authorized_projects.public_only.any? |
| 484 | 484 | end |
| 485 | + | |
| 486 | + def avatar_url(size = nil) | |
| 487 | + if avatar.present? | |
| 488 | + URI::join(Gitlab.config.gitlab.url, avatar.url).to_s | |
| 489 | + else | |
| 490 | + GravatarService.new.execute(email, size) | |
| 491 | + end | |
| 492 | + end | |
| 485 | 493 | end | ... | ... |
| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | +class GravatarService | |
| 2 | + def execute(email, size = nil) | |
| 3 | + if gravatar_config.enabled && email.present? | |
| 4 | + size = 40 if size.nil? || size <= 0 | |
| 5 | + | |
| 6 | + sprintf gravatar_url, | |
| 7 | + hash: Digest::MD5.hexdigest(email.strip.downcase), | |
| 8 | + size: size, | |
| 9 | + email: email.strip | |
| 10 | + end | |
| 11 | + end | |
| 12 | + | |
| 13 | + def gitlab_config | |
| 14 | + Gitlab.config.gitlab | |
| 15 | + end | |
| 16 | + | |
| 17 | + def gravatar_config | |
| 18 | + Gitlab.config.gravatar | |
| 19 | + end | |
| 20 | + | |
| 21 | + def gravatar_url | |
| 22 | + if gitlab_config.https | |
| 23 | + gravatar_config.ssl_url | |
| 24 | + else | |
| 25 | + gravatar_config.plain_url | |
| 26 | + end | |
| 27 | + end | |
| 28 | +end | ... | ... |
doc/api/users.md
| ... | ... | @@ -6,6 +6,34 @@ Get a list of users. |
| 6 | 6 | |
| 7 | 7 | This function takes pagination parameters `page` and `per_page` to restrict the list of users. |
| 8 | 8 | |
| 9 | +### For normal users: | |
| 10 | + | |
| 11 | +``` | |
| 12 | +GET /users | |
| 13 | +``` | |
| 14 | + | |
| 15 | +```json | |
| 16 | +[ | |
| 17 | + { | |
| 18 | + "id": 1, | |
| 19 | + "username": "john_smith", | |
| 20 | + "name": "John Smith", | |
| 21 | + "state": "active", | |
| 22 | + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg", | |
| 23 | + }, | |
| 24 | + { | |
| 25 | + "id": 2, | |
| 26 | + "username": "jack_smith", | |
| 27 | + "name": "Jack Smith", | |
| 28 | + "state": "blocked", | |
| 29 | + "avatar_url": "http://gravatar.com/../e32131cd8.jpeg", | |
| 30 | + } | |
| 31 | +] | |
| 32 | +``` | |
| 33 | + | |
| 34 | + | |
| 35 | +### For admins: | |
| 36 | + | |
| 9 | 37 | ``` |
| 10 | 38 | GET /users |
| 11 | 39 | ``` |
| ... | ... | @@ -29,6 +57,7 @@ GET /users |
| 29 | 57 | "theme_id": 1, |
| 30 | 58 | "color_scheme_id": 2, |
| 31 | 59 | "is_admin": false, |
| 60 | + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg", | |
| 32 | 61 | "can_create_group": true |
| 33 | 62 | }, |
| 34 | 63 | { |
| ... | ... | @@ -48,6 +77,7 @@ GET /users |
| 48 | 77 | "theme_id": 1, |
| 49 | 78 | "color_scheme_id": 3, |
| 50 | 79 | "is_admin": false, |
| 80 | + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg", | |
| 51 | 81 | "can_create_group": true, |
| 52 | 82 | "can_create_project": true |
| 53 | 83 | } |
| ... | ... | @@ -62,6 +92,29 @@ Also see `def search query` in `app/models/user.rb`. |
| 62 | 92 | |
| 63 | 93 | Get a single user. |
| 64 | 94 | |
| 95 | +#### For user: | |
| 96 | + | |
| 97 | +``` | |
| 98 | +GET /users/:id | |
| 99 | +``` | |
| 100 | + | |
| 101 | +Parameters: | |
| 102 | + | |
| 103 | +- `id` (required) - The ID of a user | |
| 104 | + | |
| 105 | +```json | |
| 106 | +{ | |
| 107 | + "id": 1, | |
| 108 | + "username": "john_smith", | |
| 109 | + "name": "John Smith", | |
| 110 | + "state": "active", | |
| 111 | + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg", | |
| 112 | +} | |
| 113 | +``` | |
| 114 | + | |
| 115 | + | |
| 116 | +#### For admin: | |
| 117 | + | |
| 65 | 118 | ``` |
| 66 | 119 | GET /users/:id |
| 67 | 120 | ``` | ... | ... |
lib/api/entities.rb
| 1 | 1 | module API |
| 2 | 2 | module Entities |
| 3 | - class User < Grape::Entity | |
| 4 | - expose :id, :username, :email, :name, :bio, :skype, :linkedin, :twitter, :website_url, | |
| 5 | - :theme_id, :color_scheme_id, :state, :created_at, :extern_uid, :provider | |
| 6 | - expose :is_admin?, as: :is_admin | |
| 7 | - expose :can_create_group?, as: :can_create_group | |
| 8 | - expose :can_create_project?, as: :can_create_project | |
| 3 | + class UserSafe < Grape::Entity | |
| 4 | + expose :name, :username | |
| 5 | + end | |
| 9 | 6 | |
| 10 | - expose :avatar_url do |user, options| | |
| 11 | - if user.avatar.present? | |
| 12 | - user.avatar.url | |
| 13 | - end | |
| 14 | - end | |
| 7 | + class UserBasic < UserSafe | |
| 8 | + expose :id, :state, :avatar_url | |
| 15 | 9 | end |
| 16 | 10 | |
| 17 | - class UserSafe < Grape::Entity | |
| 18 | - expose :name, :username | |
| 11 | + class User < UserBasic | |
| 12 | + expose :created_at | |
| 13 | + expose :is_admin?, as: :is_admin | |
| 14 | + expose :bio, :skype, :linkedin, :twitter, :website_url | |
| 19 | 15 | end |
| 20 | 16 | |
| 21 | - class UserBasic < Grape::Entity | |
| 22 | - expose :id, :username, :email, :name, :state, :created_at | |
| 17 | + class UserFull < User | |
| 18 | + expose :email | |
| 19 | + expose :theme_id, :color_scheme_id, :extern_uid, :provider | |
| 20 | + expose :can_create_group?, as: :can_create_group | |
| 21 | + expose :can_create_project?, as: :can_create_project | |
| 23 | 22 | end |
| 24 | 23 | |
| 25 | - class UserLogin < User | |
| 24 | + class UserLogin < UserFull | |
| 26 | 25 | expose :private_token |
| 27 | 26 | end |
| 28 | 27 | ... | ... |
lib/api/internal.rb
lib/api/projects.rb
| ... | ... | @@ -209,7 +209,7 @@ module API |
| 209 | 209 | @users = User.where(id: user_project.team.users.map(&:id)) |
| 210 | 210 | @users = @users.search(params[:search]) if params[:search].present? |
| 211 | 211 | @users = paginate @users |
| 212 | - present @users, with: Entities::User | |
| 212 | + present @users, with: Entities::UserBasic | |
| 213 | 213 | end |
| 214 | 214 | |
| 215 | 215 | # Get a project labels | ... | ... |
lib/api/users.rb
| ... | ... | @@ -13,7 +13,12 @@ module API |
| 13 | 13 | @users = @users.active if params[:active].present? |
| 14 | 14 | @users = @users.search(params[:search]) if params[:search].present? |
| 15 | 15 | @users = paginate @users |
| 16 | - present @users, with: Entities::User | |
| 16 | + | |
| 17 | + if current_user.is_admin? | |
| 18 | + present @users, with: Entities::UserFull | |
| 19 | + else | |
| 20 | + present @users, with: Entities::UserBasic | |
| 21 | + end | |
| 17 | 22 | end |
| 18 | 23 | |
| 19 | 24 | # Get a single user |
| ... | ... | @@ -24,7 +29,12 @@ module API |
| 24 | 29 | # GET /users/:id |
| 25 | 30 | get ":id" do |
| 26 | 31 | @user = User.find(params[:id]) |
| 27 | - present @user, with: Entities::User | |
| 32 | + | |
| 33 | + if current_user.is_admin? | |
| 34 | + present @user, with: Entities::UserFull | |
| 35 | + else | |
| 36 | + present @user, with: Entities::UserBasic | |
| 37 | + end | |
| 28 | 38 | end |
| 29 | 39 | |
| 30 | 40 | # Create user. Available only for admin |
| ... | ... | @@ -53,7 +63,7 @@ module API |
| 53 | 63 | admin = attrs.delete(:admin) |
| 54 | 64 | user.admin = admin unless admin.nil? |
| 55 | 65 | if user.save |
| 56 | - present user, with: Entities::User | |
| 66 | + present user, with: Entities::UserFull | |
| 57 | 67 | else |
| 58 | 68 | not_found! |
| 59 | 69 | end |
| ... | ... | @@ -87,7 +97,7 @@ module API |
| 87 | 97 | admin = attrs.delete(:admin) |
| 88 | 98 | user.admin = admin unless admin.nil? |
| 89 | 99 | if user.update_attributes(attrs, as: :admin) |
| 90 | - present user, with: Entities::User | |
| 100 | + present user, with: Entities::UserFull | |
| 91 | 101 | else |
| 92 | 102 | not_found! |
| 93 | 103 | end | ... | ... |
spec/helpers/application_helper_spec.rb
| ... | ... | @@ -67,10 +67,9 @@ describe ApplicationHelper do |
| 67 | 67 | end |
| 68 | 68 | |
| 69 | 69 | it "should call gravatar_icon when no avatar is present" do |
| 70 | - user = create(:user) | |
| 70 | + user = create(:user, email: 'test@example.com') | |
| 71 | 71 | user.save! |
| 72 | - allow(self).to receive(:gravatar_icon).and_return('gravatar_method_called') | |
| 73 | - avatar_icon(user.email).to_s.should == "gravatar_method_called" | |
| 72 | + avatar_icon(user.email).to_s.should == "http://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=40&d=mm" | |
| 74 | 73 | end |
| 75 | 74 | end |
| 76 | 75 | |
| ... | ... | @@ -87,12 +86,12 @@ describe ApplicationHelper do |
| 87 | 86 | end |
| 88 | 87 | |
| 89 | 88 | it "should return default gravatar url" do |
| 90 | - allow(self).to receive(:request).and_return(double(:ssl? => false)) | |
| 89 | + Gitlab.config.gitlab.stub(https: false) | |
| 91 | 90 | gravatar_icon(user_email).should match('http://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118') |
| 92 | 91 | end |
| 93 | 92 | |
| 94 | 93 | it "should use SSL when appropriate" do |
| 95 | - allow(self).to receive(:request).and_return(double(:ssl? => true)) | |
| 94 | + Gitlab.config.gitlab.stub(https: true) | |
| 96 | 95 | gravatar_icon(user_email).should match('https://secure.gravatar.com') |
| 97 | 96 | end |
| 98 | 97 | ... | ... |
spec/requests/api/notes_spec.rb
| ... | ... | @@ -93,7 +93,7 @@ describe API::API, api: true do |
| 93 | 93 | post api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: 'hi!' |
| 94 | 94 | response.status.should == 201 |
| 95 | 95 | json_response['body'].should == 'hi!' |
| 96 | - json_response['author']['email'].should == user.email | |
| 96 | + json_response['author']['username'].should == user.username | |
| 97 | 97 | end |
| 98 | 98 | |
| 99 | 99 | it "should return a 400 bad request error if body not given" do |
| ... | ... | @@ -112,7 +112,7 @@ describe API::API, api: true do |
| 112 | 112 | post api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user), body: 'hi!' |
| 113 | 113 | response.status.should == 201 |
| 114 | 114 | json_response['body'].should == 'hi!' |
| 115 | - json_response['author']['email'].should == user.email | |
| 115 | + json_response['author']['username'].should == user.username | |
| 116 | 116 | end |
| 117 | 117 | |
| 118 | 118 | it "should return a 400 bad request error if body not given" do | ... | ... |
spec/requests/api/project_members_spec.rb
| ... | ... | @@ -21,7 +21,7 @@ describe API::API, api: true do |
| 21 | 21 | response.status.should == 200 |
| 22 | 22 | json_response.should be_an Array |
| 23 | 23 | json_response.count.should == 2 |
| 24 | - json_response.map { |u| u['email'] }.should include user.email | |
| 24 | + json_response.map { |u| u['username'] }.should include user.username | |
| 25 | 25 | end |
| 26 | 26 | |
| 27 | 27 | it "finds team members with query string" do |
| ... | ... | @@ -29,7 +29,7 @@ describe API::API, api: true do |
| 29 | 29 | response.status.should == 200 |
| 30 | 30 | json_response.should be_an Array |
| 31 | 31 | json_response.count.should == 1 |
| 32 | - json_response.first['email'].should == user.email | |
| 32 | + json_response.first['username'].should == user.username | |
| 33 | 33 | end |
| 34 | 34 | |
| 35 | 35 | it "should return a 404 error if id not found" do |
| ... | ... | @@ -44,7 +44,7 @@ describe API::API, api: true do |
| 44 | 44 | it "should return project team member" do |
| 45 | 45 | get api("/projects/#{project.id}/members/#{user.id}", user) |
| 46 | 46 | response.status.should == 200 |
| 47 | - json_response['email'].should == user.email | |
| 47 | + json_response['username'].should == user.username | |
| 48 | 48 | json_response['access_level'].should == UsersProject::MASTER |
| 49 | 49 | end |
| 50 | 50 | |
| ... | ... | @@ -62,7 +62,7 @@ describe API::API, api: true do |
| 62 | 62 | }.to change { UsersProject.count }.by(1) |
| 63 | 63 | |
| 64 | 64 | response.status.should == 201 |
| 65 | - json_response['email'].should == user2.email | |
| 65 | + json_response['username'].should == user2.username | |
| 66 | 66 | json_response['access_level'].should == UsersProject::DEVELOPER |
| 67 | 67 | end |
| 68 | 68 | |
| ... | ... | @@ -75,7 +75,7 @@ describe API::API, api: true do |
| 75 | 75 | }.not_to change { UsersProject.count }.by(1) |
| 76 | 76 | |
| 77 | 77 | response.status.should == 201 |
| 78 | - json_response['email'].should == user2.email | |
| 78 | + json_response['username'].should == user2.username | |
| 79 | 79 | json_response['access_level'].should == UsersProject::DEVELOPER |
| 80 | 80 | end |
| 81 | 81 | |
| ... | ... | @@ -101,7 +101,7 @@ describe API::API, api: true do |
| 101 | 101 | it "should update project team member" do |
| 102 | 102 | put api("/projects/#{project.id}/members/#{user3.id}", user), access_level: UsersProject::MASTER |
| 103 | 103 | response.status.should == 200 |
| 104 | - json_response['email'].should == user3.email | |
| 104 | + json_response['username'].should == user3.username | |
| 105 | 105 | json_response['access_level'].should == UsersProject::MASTER |
| 106 | 106 | end |
| 107 | 107 | ... | ... |
spec/requests/api/projects_spec.rb
| ... | ... | @@ -37,7 +37,7 @@ describe API::API, api: true do |
| 37 | 37 | response.status.should == 200 |
| 38 | 38 | json_response.should be_an Array |
| 39 | 39 | json_response.first['name'].should == project.name |
| 40 | - json_response.first['owner']['email'].should == user.email | |
| 40 | + json_response.first['owner']['username'].should == user.username | |
| 41 | 41 | end |
| 42 | 42 | end |
| 43 | 43 | end |
| ... | ... | @@ -65,7 +65,7 @@ describe API::API, api: true do |
| 65 | 65 | response.status.should == 200 |
| 66 | 66 | json_response.should be_an Array |
| 67 | 67 | json_response.first['name'].should == project.name |
| 68 | - json_response.first['owner']['email'].should == user.email | |
| 68 | + json_response.first['owner']['username'].should == user.username | |
| 69 | 69 | end |
| 70 | 70 | end |
| 71 | 71 | end |
| ... | ... | @@ -270,7 +270,7 @@ describe API::API, api: true do |
| 270 | 270 | get api("/projects/#{project.id}", user) |
| 271 | 271 | response.status.should == 200 |
| 272 | 272 | json_response['name'].should == project.name |
| 273 | - json_response['owner']['email'].should == user.email | |
| 273 | + json_response['owner']['username'].should == user.username | |
| 274 | 274 | end |
| 275 | 275 | |
| 276 | 276 | it "should return a project by path name" do | ... | ... |
spec/requests/api/users_spec.rb
| ... | ... | @@ -20,7 +20,18 @@ describe API::API, api: true do |
| 20 | 20 | get api("/users", user) |
| 21 | 21 | response.status.should == 200 |
| 22 | 22 | json_response.should be_an Array |
| 23 | - json_response.first['email'].should == user.email | |
| 23 | + json_response.first['username'].should == user.username | |
| 24 | + end | |
| 25 | + end | |
| 26 | + | |
| 27 | + context "when admin" do | |
| 28 | + it "should return an array of users" do | |
| 29 | + get api("/users", admin) | |
| 30 | + response.status.should == 200 | |
| 31 | + json_response.should be_an Array | |
| 32 | + json_response.first.keys.should include 'email' | |
| 33 | + json_response.first.keys.should include 'extern_uid' | |
| 34 | + json_response.first.keys.should include 'can_create_project' | |
| 24 | 35 | end |
| 25 | 36 | end |
| 26 | 37 | end |
| ... | ... | @@ -29,7 +40,7 @@ describe API::API, api: true do |
| 29 | 40 | it "should return a user by id" do |
| 30 | 41 | get api("/users/#{user.id}", user) |
| 31 | 42 | response.status.should == 200 |
| 32 | - json_response['email'].should == user.email | |
| 43 | + json_response['username'].should == user.username | |
| 33 | 44 | end |
| 34 | 45 | |
| 35 | 46 | it "should return a 401 if unauthenticated" do | ... | ... |