Skip to content

XSS in k8s proxy endpoint when serving response without content-type header

⚠️ Please read the process on how to fix security issues before starting to work on the issue. Vulnerabilities must be fixed in a security mirror.

HackerOne report #2932309 by joaxcar on 2025-01-11, assigned to @kmorrison1:

Report | Attachments | How To Reproduce

Report

Summary

A while back the k8s proxy endpoint got some XSS protection (see issue) by checking that all content-type headers in the response match a certain whitelist

	allowedResponseContentTypes = []string{  
		runtime.ContentTypeJSON,  
		runtime.ContentTypeYAML,  
		runtime.ContentTypeProtobuf,  
		runtime.ContentTypeCBOR,  
		"text/plain",  
	}  

using this function

func checkContentType(h http.Header, allowed ...string) error {  
	// There should be at most one Content-Type header, but it's not our job to do something about it if there is more.  
	// We just ensure thy are all allowed.  
nextContentType:  
	for _, ct := range h[httpz.ContentTypeHeader] {  
		mediatype, _, err := mime.ParseMediaType(ct)  
		if err != nil && err != mime.ErrInvalidMediaParameter {  
			// Parsing error and not a MIME parameter parsing error, which we ignore.  
			return fmt.Errorf("check Content-Type: %w", err)  
		}  
		for _, a := range allowed {  
			if mediatype == a {  
				// This one is allowed, onto the next Content-Type header  
				continue nextContentType  
			}  
		}  
		return fmt.Errorf("%s not allowed: %s", httpz.ContentTypeHeader, mediatype)  
	}  
	return nil  
}

The issue is that this function does not take into consideration responses that lack any content-type header. When sending back a response without a content-type, the response will be sent through, and when ending up in a browser, the browser will mime sniff the content, and if it looks like HTML, it will render it accordingly.

Even if we now can server HTML on the endpoint the page is still protected by requiring a CSRF token in either a special header or in the URL. This makes regular exploitation hard. I did, however find a way to bypass this on self-hosted instances (there are probably more ways to do this)

If the proxied response also contains a Cache-Control: max-age=604800 header, the result from the server will get cached from the regular fetch made by Gitlab and subsequently served when visiting the endpoint directly, even without a CSRF token.

Steps to reproduce

  1. Set up a self-hosted Gitlab instance, I don't know if you need SSL configured but its probably good to set it up (my test setup has it)
  2. SSH into the server and open /etc/gitlab/gitlab.rb and enable CSP by adding these lines
gitlab_rails['content_security_policy'] = {  
  'enabled' => true,  
  'report_only' => false,  
}
  1. Save the file and then run sudo gitlab-ctl reconfigure
    This will make the instance have ".com"-matching CSP
  2. Now log in as a regular user
  3. Create a new group mynewgroup
  4. Create two projects in the group config and attack
  5. In the project mynewgroup/config create a file with the name .gitlab/agents/test-agent/config.yaml and the content (replace groupname if you have another)
user_access:  
  access_as:  
    agent: {}  
  groups:  
    - id: mynewgroup  
  1. Go to https://gitlab.example..com/mynewgroup/config/-/clusters and click connect a cluster
  2. In the popup under the heading Register agent with the UI click the dropdown and select the test-agent, click Register
  3. Copy the Agent access token
  4. In a terminal on your local computer, create a file called glab-agentk-token-local and paste the token from step 10 in the file
  5. Download the file server.py server.py into the same directory
  6. Open another terminal tab in the same directory, in one of the tabs run python3 server.py and in the other tab run this command (this command is made to work on linux, if you use MacOS replace 127.0.0.1:9999 with host.docker.internal:9999, and replace gitlab.example.com with your server domain)
docker run \  
--network host \  
--rm \  
-it \  
-v ./glab-agentk-token-local:/etc/agentk/secrets/token \  
-e POD_NAMESPACE=agentk-nsname \  
-e POD_NAME=agent-podname registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/agentk:latest  \  
--kas-address=wss://gitlab.example.com/-/kubernetes-agent/ \  
--token-file=/etc/agentk/secrets/token \  
-s 127.0.0.1:9999  
  1. You should now in the browser see the test-agent going to connected state (if not refresh the browers)
  2. Now go to the project https://gitlab.com/mynewgroup/attack/-/environments and click New Environment
  3. Give the environment a name, then add https://example.com as the External URL, and select test-agent as the Gitlab Agent. And put this as the description (again replace gitlab.example.com with your server host)
<h1><a href="https://gitlab.example.com/-/kubernetes-agent/k8s-proxy/api/v1/pods">click me</a></h1>  
  1. Click Create and take a note of the ID
  2. If you now visit https://gitlab.com/mynewgroup/attack/-/environments/ID_FROM_STEP_17 and click the Click here link the XSS should pop (Not blocked by the CSP from step 2)

Impact

Full unlimited XSS bypassing any CSP. No matter what CSP you put on the Gitlab config this endpoint is served without it

Some notes on CVSS

The XSS works on kas.gitlab.com as well, but the caching trick does not work on that environment. I guess it's a cross-domain issue.

I know that the CVSS calculator states that high impact is only given to

XSS with .com CSP bypass

in my opinion, this bug fits this description even if the full POC chain does not work on gitlab.com at the moment. The reason here is that the XSS will bypass any configured CSP, and my understanding of the intention of the rule for mid or high impact is to distinguish between XSS's that require:

  • a self-hosted server without CSP configured

as opposed to this POC that shows that this attack works on

  • self-hosted servers with .com equivalent CSP configuration given by GitLab best practices.

If you don't agree with this I am happy to have a longer discussion about it :)

Examples

Screenshot_2025-01-12_at_00.15.01.png

What is the current bug behavior?

The K8s proxy will only block responses that include bad content-type headers but will allow responses without any content-type at all, leading to XSS

What is the expected correct behavior?

Responses should get a default text/plain if content-type is missing

Impact

XSS with CSP bypass

Attachments

Warning: Attachments received through HackerOne, please exercise caution!

How To Reproduce

Please add reproducibility information to this section: