cyllective's blog

Collabora Online Stored XSS (CVE-2024-29182)

13. May 2024, #web #cve #collabora

In March 2024, we’ve identified a stored XSS vulnerability in Collabora Online ↗, a Libreoffice-based online document collaboration platform. This post is a short rundown of how the vulnerability was identified and goes into more detail what component was impacted.

Breakdown #

As with most document solutions, Collabora provides cross references, a feature that can reference parts of a document on other pages that will then automatically update. When you hover over such a reference, a tooltip menu appears, providing further contextual information about the referenced content.

This tooltip is susceptible to stored XSS because the referenced content is not sufficiently sanitized before rendering:


Digging further #

With the vulnerable component identified, we dug further to determine what actually happens when hovering over references. To do so, we fired up BurpSuite and started intercepting the web traffic.

Upon hovering over the reference, a websocket message was dispatched containing our referenced text:

Websocket traffic

Looking through the codebase of CollaboraOnline/online ↗ git repository, we located the corresponding websocket message handler in browser/src/layer/tile/CanvasTileLayer.js ↗:

_onMessage: function (textMsg, img) {
    this._saveMessageForReplay(textMsg);
    ...
    else if (textMsg.startsWith('tooltip:')) {
        var tooltipInfo = JSON.parse(textMsg.substring('tooltip:'.length + 1));
        if (tooltipInfo.type === 'formulausage') {
            this._onCalcFunctionUsageMsg(tooltipInfo.text);
        }
        else if (tooltipInfo.type === 'generaltooltip') {
            var tooltipInfo = JSON.parse(textMsg.substring(textMsg.indexOf('{')));
            this._map.uiManager.showDocumentTooltip(tooltipInfo); // [1]
        }
        else {
            console.error('unknown tooltip type');
        }
    }
    ...
}

As our message is of type generaltooltip, the showDocumentTooltip function is called. This function is located in browser/src/control/Control.UIManager.js ↗ and reads:

showDocumentTooltip: function(tooltipInfo) {
    var split = tooltipInfo.rectangle.split(',');
    var latlng = this.map._docLayer._twipsToLatLng(new L.Point(+split[0], +split[1]));
    var pt = this.map.latLngToContainerPoint(latlng);
    var elem = $('.leaflet-layer');

    elem.tooltip();
    elem.tooltip('enable');
    elem.tooltip('option', 'content', tooltipInfo.text);
    elem.tooltip('option', 'items', elem[0]);
    elem.tooltip('option', 'position', { my: 'left bottom',  at: 'left+' + pt.x + ' top+' + pt.y, collision: 'fit fit' });
    elem.tooltip('open');
    document.addEventListener('mousemove', function() {
        elem.tooltip('close');
        elem.tooltip('disable');
    }, {once: true});
},

Based on the callchain thus far, we can deduce that upon hovering over the reference, the referenced text sent over a websocket and is handled by a call to _onMessage. This function in turn calls showDocumentTooltip to render the tooltip with calls to $('.leaflet-layer').tooltip(...).

But what does .tooltip(...) do?

More digging #

Luckily for us, the origin of the .tooltip(...) calls were easy to track down. According to jQuery’s selector, it appears to have something to do with “leaflet”. A quick search-engine-of-choice search later, we were presented with the API documentation of leafletjs ↗.

The relevant component for us is the tooltip ↗. According to the usage examples of tooltips, it looks as if the content property can contain HTML strings!

var tooltip = L.tooltip()
    .setLatLng(latlng)
    .setContent('Hello world!<br />This is a nice tooltip.')
    .addTo(map);

...

var tooltip = L.tooltip(latlng, {content: 'Hello world!<br />This is a nice tooltip.'})
    .addTo(map);

Armed with this knowledge, we went ahead and cloned the leafletjs repository ↗ to figure out how the content is processed.

The tooltip component is located in src/layer/Tooltip.js ↗, but does not actually contain the logic for handling the content. Instead, it extends the DivOverlay component:

import {DivOverlay} from './DivOverlay.js';
...

export const Tooltip = DivOverlay.extend({
    ...
});

The DivOverlay component is located in src/layer/DivOverlay.js ↗ and contains the logic we were looking for - the _updateContent function:

_updateContent() {
    if (!this._content) { return; }

    const node = this._contentNode;
    const content = (typeof this._content === 'function') ? this._content(this._source || this) : this._content;

    if (typeof content === 'string') {
        node.innerHTML = content; // 💣💥 Oops!
    } else {
        ...
    }

    ...
},

As depicted in the code snippet above, if the content is of type string it is simply assigned to the HTML element’s innerHTML property, which leads to the stored XSS vulnerability.

Timeline #