Stored XSS in blob viewer
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><img/src/onerror=alert()></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
- 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 thelsifartifact. Just to show how it looks - Now open
devtoolsand go to the network tab - Refresh the page
- You will see a call to
https://gl.j15.se/test/asdf/-/jobs/100/artifacts/raw/lsif/hej.py.json?file_type=lsifright click this call and pickOverwrite Content - There will be a small popup asking you where to store the Chrome override files, pick any directory
- 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":""}]}]
- Click
ctrl-sto save - Refresh the page again (with devtools still open)
- 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
- There might be people who already know about this and have a payload stored even on gitlab.com. This payload will still work today
- There might be ways to spoof the
lsifartifact 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: