Commit b2383e8f authored by Dheeraj Joshi's avatar Dheeraj Joshi Committed by Fabio Pitino

Render mermaid diagrams in a sandboxed IFrame

This should prevent XSS issues from mermaid
dependency impacting GitLab application.

The changes are behind a feature flag
parent b803af88
......@@ -4,6 +4,7 @@ import initUserPopovers from '../../user_popovers';
import highlightCurrentUser from './highlight_current_user';
import renderMath from './render_math';
import renderMermaid from './render_mermaid';
import renderSandboxedMermaid from './render_sandboxed_mermaid';
import renderMetrics from './render_metrics';
// Render GitLab flavoured Markdown
......@@ -13,7 +14,11 @@ import renderMetrics from './render_metrics';
$.fn.renderGFM = function renderGFM() {
syntaxHighlight(this.find('.js-syntax-highlight').get());
renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid'));
if (gon.features?.sandboxedMermaid) {
renderSandboxedMermaid(this.find('.js-render-mermaid'));
} else {
renderMermaid(this.find('.js-render-mermaid'));
}
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.js-user-link').get());
......
import $ from 'jquery';
import { once, countBy } from 'lodash';
import { __ } from '~/locale';
import {
getBaseURL,
relativePathToAbsolute,
setUrlParams,
joinPaths,
} from '~/lib/utils/url_utility';
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { setAttributes } from '~/lib/utils/dom_utils';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
// `js-render-mermaid` class.
//
// Example markup:
//
// <pre class="js-render-mermaid">
// graph TD;
// A-- > B;
// A-- > C;
// B-- > D;
// C-- > D;
// </pre>
//
const SANDBOX_FRAME_PATH = '/-/sandbox/mermaid';
// This is an arbitrary number; Can be iterated upon when suitable.
const MAX_CHAR_LIMIT = 2000;
// Max # of mermaid blocks that can be rendered in a page.
const MAX_MERMAID_BLOCK_LIMIT = 50;
// Max # of `&` allowed in Chaining of links syntax
const MAX_CHAINING_OF_LINKS_LIMIT = 30;
// Keep a map of mermaid blocks we've already rendered.
const elsProcessingMap = new WeakMap();
let renderedMermaidBlocks = 0;
// Pages without any restrictions on mermaid rendering
const PAGES_WITHOUT_RESTRICTIONS = [
// Group wiki
'groups:wikis:show',
'groups:wikis:edit',
'groups:wikis:create',
// Project wiki
'projects:wikis:show',
'projects:wikis:edit',
'projects:wikis:create',
// Project files
'projects:show',
'projects:blob:show',
];
function shouldLazyLoadMermaidBlock(source) {
/**
* If source contains `&`, which means that it might
* contain Chaining of links a new syntax in Mermaid.
*/
if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) {
return true;
}
return false;
}
function fixElementSource(el) {
// Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly.
const source = el.textContent?.replace(/<br\s*\/>/g, '<br>');
// Remove any extra spans added by the backend syntax highlighting.
Object.assign(el, { textContent: source });
return { source };
}
function getSandboxFrameSrc() {
const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH);
if (!darkModeEnabled()) {
return path;
}
const absoluteUrl = relativePathToAbsolute(path, getBaseURL());
return setUrlParams({ darkMode: darkModeEnabled() }, absoluteUrl);
}
function renderMermaidEl(el, source) {
const iframeEl = document.createElement('iframe');
setAttributes(iframeEl, {
src: getSandboxFrameSrc(),
sandbox: 'allow-scripts',
frameBorder: 0,
scrolling: 'no',
});
// Add the original source into the DOM
// to allow Copy-as-GFM to access it.
const sourceEl = document.createElement('text');
sourceEl.textContent = source;
sourceEl.classList.add('gl-display-none');
const wrapper = document.createElement('div');
wrapper.appendChild(iframeEl);
wrapper.appendChild(sourceEl);
el.closest('pre').replaceWith(wrapper);
// Event Listeners
iframeEl.addEventListener('load', () => {
// Potential risk associated with '*' discussed in below thread
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74414#note_735183398
iframeEl.contentWindow.postMessage(source, '*');
});
window.addEventListener(
'message',
(event) => {
if (event.origin !== 'null' || event.source !== iframeEl.contentWindow) {
return;
}
const { h, w } = event.data;
iframeEl.width = w;
iframeEl.height = h;
},
false,
);
}
function renderMermaids($els) {
if (!$els.length) return;
const pageName = document.querySelector('body').dataset.page;
// A diagram may have been truncated in search results which will cause errors, so abort the render.
if (pageName === 'search:show') return;
let renderedChars = 0;
$els.each((i, el) => {
// Skipping all the elements which we've already queued in requestIdleCallback
if (elsProcessingMap.has(el)) {
return;
}
const { source } = fixElementSource(el);
/**
* Restrict the rendering to a certain amount of character
* and mermaid blocks to prevent mermaidjs from hanging
* up the entire thread and causing a DoS.
*/
if (
!PAGES_WITHOUT_RESTRICTIONS.includes(pageName) &&
((source && source.length > MAX_CHAR_LIMIT) ||
renderedChars > MAX_CHAR_LIMIT ||
renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT ||
shouldLazyLoadMermaidBlock(source))
) {
const html = `
<div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert">
<div>
<div>
<div class="js-warning-text"></div>
<div class="gl-alert-actions">
<button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button>
</div>
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
`;
const $parent = $(el).parent();
if (!$parent.hasClass('lazy-alert-shown')) {
$parent.after(html);
$parent
.siblings()
.find('.js-warning-text')
.text(
__('Warning: Displaying this diagram might cause performance issues on this page.'),
);
$parent.addClass('lazy-alert-shown');
}
return;
}
renderedChars += source.length;
renderedMermaidBlocks += 1;
const requestId = window.requestIdleCallback(() => {
renderMermaidEl(el, source);
});
elsProcessingMap.set(el, requestId);
});
}
const hookLazyRenderMermaidEvent = once(() => {
$(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() {
const parent = $(this).closest('.js-lazy-render-mermaid-container');
const pre = parent.prev();
const el = pre.find('.js-render-mermaid');
parent.remove();
// sandbox update
const element = el.get(0);
const { source } = fixElementSource(element);
renderMermaidEl(element, source);
});
});
export default function renderMermaid($els) {
if (!$els.length) return;
const visibleMermaids = $els.filter(function filter() {
return $(this).closest('details').length === 0 && $(this).is(':visible');
});
renderMermaids(visibleMermaids);
$els.closest('details').one('toggle', function toggle() {
if (this.open) {
renderMermaids($(this).find('.js-render-mermaid'));
}
});
hookLazyRenderMermaidEvent();
}
import mermaid from 'mermaid';
import { getParameterByName } from '~/lib/utils/url_utility';
const setIframeRenderedSize = (h, w) => {
const { origin } = window.location;
window.parent.postMessage({ h, w }, origin);
};
const drawDiagram = (source) => {
const element = document.getElementById('app');
const insertSvg = (svgCode) => {
element.innerHTML = svgCode;
const height = parseInt(element.firstElementChild.getAttribute('height'), 10);
const width = parseInt(element.firstElementChild.style.maxWidth, 10);
setIframeRenderedSize(height, width);
};
mermaid.mermaidAPI.render('mermaid', source, insertSvg);
};
const darkModeEnabled = () => getParameterByName('darkMode') === 'true';
const initMermaid = () => {
let theme = 'neutral';
if (darkModeEnabled()) {
theme = 'dark';
}
mermaid.initialize({
// mermaid core options
mermaid: {
startOnLoad: false,
},
// mermaidAPI options
theme,
flowchart: {
useMaxWidth: true,
htmlLabels: true,
},
secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'htmlLabels'],
securityLevel: 'strict',
});
};
const addListener = () => {
window.addEventListener(
'message',
(event) => {
if (event.origin !== window.location.origin) {
return;
}
drawDiagram(event.data);
},
false,
);
};
addListener();
initMermaid();
export default {};
# frozen_string_literal: true
class SandboxController < ApplicationController # rubocop:disable Gitlab/NamespacedClass
skip_before_action :authenticate_user!
feature_category :not_owned
def mermaid
render layout: false
end
end
<!DOCTYPE html>
<html>
<head>
<%= webpack_bundle_tag("sandboxed_mermaid") %>
</head>
<body>
<div id="app"></div>
</body>
</html>
---
name: sandboxed_mermaid
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74414
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/349755
milestone: '14.7'
type: development
group: group::analyzer frontend
default_enabled: false
......@@ -108,6 +108,9 @@ Rails.application.routes.draw do
get '/autocomplete/namespace_routes' => 'autocomplete#namespace_routes'
end
# sandbox
get '/sandbox/mermaid' => 'sandbox#mermaid'
get '/whats_new' => 'whats_new#index'
# '/-/health' implemented by BasicHealthCheck middleware
......
......@@ -141,6 +141,7 @@ function generateEntries() {
sentry: './sentry/index.js',
performance_bar: './performance_bar/index.js',
jira_connect_app: './jira_connect/subscriptions/index.js',
sandboxed_mermaid: './lib/mermaid.js',
};
return Object.assign(manualEntries, incrementalCompiler.filterEntryPoints(autoEntries));
......
......@@ -147,7 +147,7 @@ module Gitlab
# Using 'self' in the CSP introduces several CSP bypass opportunities
# for this reason we list the URLs where GitLab frames itself instead
def self.allow_framed_gitlab_paths(directives)
['/admin/', '/assets/', '/-/speedscope/index.html'].map do |path|
['/admin/', '/assets/', '/-/speedscope/index.html', '/-/sandbox/mermaid'].map do |path|
append_to_directive(directives, 'frame_src', Gitlab::Utils.append_path(Gitlab.config.gitlab.url, path))
end
end
......
......@@ -57,6 +57,7 @@ module Gitlab
push_frontend_feature_flag(:improved_emoji_picker, default_enabled: :yaml)
push_frontend_feature_flag(:new_header_search, default_enabled: :yaml)
push_frontend_feature_flag(:bootstrap_confirmation_modals, default_enabled: :yaml)
push_frontend_feature_flag(:sandboxed_mermaid, default_enabled: :yaml)
end
# Exposes the state of a feature flag to the frontend code.
......
......@@ -11,6 +11,7 @@ RSpec.describe "User comments on issue", :js do
before do
stub_feature_flags(tribute_autocomplete: false)
stub_feature_flags(sandboxed_mermaid: false)
project.add_guest(user)
sign_in(user)
......
......@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe 'Mermaid rendering', :js do
let_it_be(:project) { create(:project, :public) }
before do
stub_feature_flags(sandboxed_mermaid: false)
end
it 'renders Mermaid diagrams correctly' do
description = <<~MERMAID
```mermaid
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Sandboxed Mermaid rendering', :js do
let_it_be(:project) { create(:project, :public) }
before do
stub_feature_flags(sandboxed_mermaid: true)
end
it 'includes mermaid frame correctly' do
description = <<~MERMAID
```mermaid
graph TD;
A-->B;
A-->C;
B-->D;
C-->D;
```
MERMAID
issue = create(:issue, project: project, description: description)
visit project_issue_path(project, issue)
wait_for_requests
expected = %(<iframe src="/-/sandbox/mermaid" sandbox="allow-scripts" frameborder="0" scrolling="no")
expect(page.html).to include(expected)
end
end
......@@ -85,7 +85,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
expect(directives['style_src']).to eq("'self' 'unsafe-inline' https://cdn.example.com")
expect(directives['font_src']).to eq("'self' https://cdn.example.com")
expect(directives['worker_src']).to eq('http://localhost/assets/ blob: data: https://cdn.example.com')
expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " https://cdn.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html")
expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " https://cdn.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid")
end
end
......@@ -113,7 +113,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
end
it 'does not add CUSTOMER_PORTAL_URL to CSP' do
expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html")
expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid")
end
end
......@@ -123,7 +123,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
end
it 'adds CUSTOMER_PORTAL_URL to CSP' do
expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html")
expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid")
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe SandboxController do
describe 'GET #mermaid' do
it 'renders page without template' do
get sandbox_mermaid_path
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(layout: nil)
end
end
end
......@@ -364,6 +364,12 @@ RSpec.describe AutocompleteController, 'routing' do
end
end
RSpec.describe SandboxController, 'routing' do
it 'to #mermaid' do
expect(get("/-/sandbox/mermaid")).to route_to('sandbox#mermaid')
end
end
RSpec.describe Snippets::BlobsController, "routing" do
it "to #raw" do
expect(get('/-/snippets/1/raw/master/lib/version.rb'))
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment