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 thelsif
artifact. Just to show how it looks - Now open
devtools
and 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=lsif
right 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-s
to 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
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: