Skip to content

Stored XSS in blob viewer

⚠️ 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 #3247096 by joaxcar on 2025-07-10, assigned to @katwu:

Report | Attachments | How To Reproduce

Report

Summary

Hi team, another slightly strange one, but I felt like it was worth reporting.

There was a fix for the code navigation feature used in blob viewers in the latest patch, which made me have a look at it. It turns out that there is another XSS vulnerability there, but it has some constraints at the moment.

This XSS worked out of the box in at least Gitlab version 17.5.1, but after that, there was a change in how lsif artifacts are parsed. I will go into details, but the constraint is that I dont have a way to create the artifacts needed on gitlab.com today. This is, however, not an issue if a bad actor has created an lsif artifact when a GitLab server was at 17.5.1. The artifact will still exist, and so will the XSS.

I have tested this by setting up a GitLab 17.5.1, creating the malicious lsif artifact, and then upgrading the instance to the latest 18.1.2 just to check that the artifacts stick around. On Gitlab.com, it's possible to test it by just mocking the lsif data.

Technical details

The blob viewer will trigger code navigation if the file that is rendered is simple (this only works for filetypes that are server-side highlighted, as python is for example)

app/assets/javascripts/repository/components/blob_content_viewer.vue

          if (type === SIMPLE_BLOB_VIEWER) {  
            eventHub.$emit('showBlobInteractionZones', this.blobInfo.path);  
          }  

The code navigation will fetch an lsif file with some json that instructs the frontend how to modify the rendered code. If you look in the index.js, there is a block that will run only if end_line does not exist in the downloaded data. This is the case for lsif artifacts parsed prior to GitLab 16. Somewhere around that time, a new parser was added that would add end_lines to the parsed data. The bad path is inside the deprecatedNodeUpdate

app/assets/javascripts/code_navigation/utils/index.js

    if (d.end_line === undefined) {  
      // For old cached data we should use the old way of parsing  
      deprecatedNodeUpdate({ d, line, wrapTextNodes });  
    } else {  

In deprecatedNodeUpdate, there is this line

const deprecatedNodeUpdate = ({ d, line, wrapTextNodes }) => {

...  
      elm.replaceWith(...wrapNodes(elm.textContent, elm.classList, elm.dataset));  
...

 }  

This line will try to wrap some text with a few spans.

const wrapNodes = (text, classList, dataset) => {  
  const wrapper = createSpan();  
  // eslint-disable-next-line no-unsanitized/property  
  wrapper.innerHTML = wrapSpacesWithSpans(text);  
  wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text, classList, dataset));  
  return wrapper.childNodes;  
};  

The issue is that elm.textContent is passed into wrapper.innerHTML = wrapSpacesWithSpans(text) which essentially takes the textContent from a DOM node and passes it to innerHTML. The problem with this is that a node like this <span>&lt;img/src/onerror=alert()&gt;</span> will have textContent == "<img/src/onerror=alert()>" and thus convert to real HTML when put into innerHTML

So a python file with this content

"<img/src/onerror=alert(document.domain)>"  

and a lsif artifact like this

[{"start_line":0,"start_char":1,"definition_path":"hej.py#L5","hover":[{"value":""}]}]  

will trigger an XSS on a self hosted server. There are multiple CSP bypasses on gitlab.com that can be used here as well but I will get back with an example of that in a comment.

POC

This is a quick example using my gitlab instance. Use Chrome for this test

  1. Visit https://gl.j15.se/test/asdf/-/blob/main/hej.py?ref_type=heads and see that there is a python file with the string "<img/src/onerror=alert(document.domain)>". This is running on version 18.1.2 and has the wrong format of the lsif artifact. Just to show how it looks
  2. Now open devtools and go to the network tab
  3. Refresh the page
  4. You will see a call to https://gl.j15.se/test/asdf/-/jobs/100/artifacts/raw/lsif/hej.py.json?file_type=lsif right click this call and pick Overwrite Content
  5. There will be a small popup asking you where to store the Chrome override files, pick any directory
  6. Now in the small editor that opens delete all text that is there and replace it with
[{"start_line":0,"start_char":1,"definition_path":"hej.py#L5","hover":[{"value":""}]}]  
  1. Click ctrl-s to save
  2. Refresh the page again (with devtools still open)
  3. The string in the python file should now disapear and trigger an XSS alert
Disclaimer

The json in step 6 is possible to generate on an older version of gitlab, the whole thing with devtols is NOT part of a real attack. I think that this is worth fixing for two reasons

  1. There might be people who already know about this and have a payload stored even on gitlab.com. This payload will still work today
  2. There might be ways to spoof the lsif artifact that I don't know about, or ways to generate bad artifacts today. I have just not found one yet

Also, I will get back as soon as possible with an example project on gitlab.com with a full CSP bypass!

Examples

Screen_Recording_2025-07-11_at_01.21.16.mov

What is the current bug behavior?

textContent is passed into innerHTML which is an XSS vector

What is the expected correct behavior?

Either use innerHTML and send it into innerHTML or sanatize the textContent

Best regards
Johan

Impact

Stored XSS with CSP bypass

Attachments

Warning: Attachments received through HackerOne, please exercise caution!

How To Reproduce

Please add reproducibility information to this section: