Commit 6a7fb7d5 authored by Miguel Rincon's avatar Miguel Rincon

Have dompurify accept <use> with icons href

In order for icons to be rendered, the need an <use> tag that might
be open to vulnerabilities, this change allows developer to enable
<use> tags safely when needed.
parent 6f019a70
import { take } from 'lodash'; import { take } from 'lodash';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants';
export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize());
......
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import updateDescription from '../utils/update_description'; import updateDescription from '../utils/update_description';
......
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
// We currently load + parse the data from the issue app and related merge request // We currently load + parse the data from the issue app and related merge request
let cachedParsedData; let cachedParsedData;
......
import { sanitize, addHook } from 'dompurify';
import { getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility';
// Safely allow SVG <use> tags
const defaultConfig = {
ADD_TAGS: ['use'],
};
const getIconUrlsRegex = () => {
const { gon } = window;
// Only icons urls from `gon` are allowed
const allowed = [gon.sprite_file_icons, gon.sprite_icons]
.map(url => relativePathToAbsolute(url, getBaseURL()))
.filter(url => url);
if (allowed.length) {
return allowed.join('|');
}
return null; // No urls allowed
};
const removeUnsafeHref = (node, allowedRegex = null, attr = 'href') => {
if (node.hasAttribute(attr) && !node.getAttribute(attr).match(allowedRegex)) {
const url = relativePathToAbsolute(node.getAttribute(attr), getBaseURL());
if (!url.match(allowedRegex)) {
node.removeAttribute(attr);
}
}
};
/**
* Sanitize icons' <use> tag attributes, to safely include
* svgs such as in:
*
* <svg viewBox="0 0 100 100">
* <use href="/assets/icons-xxx.svg#icon_name"></use>
* </svg>
*
* Note: In order to render icons, you should still allow <use>
* when invoking `sanitize`, for example:
*
* ```
* import { sanitize } from '~/lib/dompurify';
*
* sanitize(content, { ADD_TAGS: ['use'] });
* ```
*
* @param {Object} node - Node to sanitize
*/
const sanitizeSvgIcons = node => {
const allowed = getIconUrlsRegex();
removeUnsafeHref(node, allowed);
// Note: `xlink:href` is deprecated
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
removeUnsafeHref(node, allowed, 'xlink:href');
};
addHook('afterSanitizeAttributes', node => {
if (node.tagName.toLowerCase() === 'use') {
sanitizeSvgIcons(node);
}
});
const defaultSanitize = (val, config = defaultConfig) => {
return sanitize(val, config);
};
export { defaultSanitize as sanitize };
import fuzzaldrinPlus from 'fuzzaldrin-plus'; import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
/** /**
* Wraps substring matches with HTML `<span>` elements. * Wraps substring matches with HTML `<span>` elements.
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import marked from 'marked'; import marked from 'marked';
import { sanitize } from 'dompurify';
import katex from 'katex'; import katex from 'katex';
import { sanitize } from '~/lib/dompurify';
import Prompt from './prompt.vue'; import Prompt from './prompt.vue';
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
import Prompt from '../prompt.vue'; import Prompt from '../prompt.vue';
export default { export default {
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import $ from 'jquery'; import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus'; import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as flash } from '~/flash'; import { deprecatedCreateFlash as flash } from '~/flash';
......
import Vue from 'vue'; import Vue from 'vue';
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
import UsersCache from './lib/utils/users_cache'; import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
......
...@@ -432,7 +432,7 @@ To avoid this error, use the applicable HTML entity code (`&lt;` or `&gt;`) inst ...@@ -432,7 +432,7 @@ To avoid this error, use the applicable HTML entity code (`&lt;` or `&gt;`) inst
- In JavaScript: - In JavaScript:
```javascript ```javascript
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
const i18n = { LESS_THAN_ONE_HOUR: sanitize(__('In &lt; 1 hour'), { ALLOWED_TAGS: [] }) }; const i18n = { LESS_THAN_ONE_HOUR: sanitize(__('In &lt; 1 hour'), { ALLOWED_TAGS: [] }) };
......
<script> <script>
import { escape } from 'lodash'; import { escape } from 'lodash';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import { sanitize } from 'dompurify';
import { GlTable, GlLink, GlIcon, GlAvatarLink, GlAvatar, GlTooltipDirective } from '@gitlab/ui'; import { GlTable, GlLink, GlIcon, GlAvatarLink, GlAvatar, GlTooltipDirective } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
import { __, sprintf, n__ } from '~/locale'; import { __, sprintf, n__ } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility'; import { getTimeago } from '~/lib/utils/datetime_utility';
import ApproversColumn from './approvers_column.vue'; import ApproversColumn from './approvers_column.vue';
......
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
const ALLOWED_TAGS = ['strong']; const ALLOWED_TAGS = ['strong'];
......
<script> <script>
import { sanitize } from 'dompurify';
import { GlFormTextarea, GlButton } from '@gitlab/ui'; import { GlFormTextarea, GlButton } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
export default { export default {
components: { GlFormTextarea, GlButton }, components: { GlFormTextarea, GlButton },
......
import { sanitize } from '~/lib/dompurify';
// GDK
const localGon = {
sprite_file_icons: '/assets/icons-123a.svg',
sprite_icons: '/assets/icons-456b.svg',
};
// Production
const absoluteGon = {
sprite_file_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-123a.svg`,
sprite_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-456b.svg`,
};
describe('~/lib/dompurify', () => {
let originalGon;
describe('uses local configuration', () => {
// As dompurify uses a "Persistent Configuration", it might
// ignore config, this check verifies we respect
// https://github.com/cure53/DOMPurify#persistent-configuration
it('no allowed tags', () => {
expect(sanitize('<br/>', { ALLOWED_TAGS: [] })).toBe('');
expect(sanitize('<strong></strong>', { ALLOWED_TAGS: [] })).toBe('');
});
});
describe.each`
type | gon
${'local'} | ${localGon}
${'absolute'} | ${absoluteGon}
`('when gon contains $type icon urls', ({ gon }) => {
beforeAll(() => {
originalGon = window.gon;
window.gon = gon;
});
afterAll(() => {
window.gon = originalGon;
});
it('sanitizes icons allowing safe xlink:href sprite_file_icons', () => {
const html = '<svg><use xlink:href="/assets/icons-123a.svg#ellipsis_h"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe(
'<svg><use xlink:href="/assets/icons-123a.svg#ellipsis_h"></use></svg>',
);
});
it('sanitizes icons allowing safe href sprite_file_icons', () => {
const html = '<svg><use href="/assets/icons-123a.svg#ellipsis_h"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe(
'<svg><use href="/assets/icons-123a.svg#ellipsis_h"></use></svg>',
);
});
it('sanitizes icons allowing safe href sprite_icons', () => {
const html = '<svg><use href="/assets/icons-456b.svg#ellipsis_h"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe(
'<svg><use href="/assets/icons-456b.svg#ellipsis_h"></use></svg>',
);
});
it('sanitizes icons allowing safe xlink:href sprite_icons', () => {
const html = '<svg><use xlink:href="/assets/icons-456b.svg#ellipsis_h"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe(
'<svg><use xlink:href="/assets/icons-456b.svg#ellipsis_h"></use></svg>',
);
});
it('sanitizes icons disabling unsafe href paths', () => {
const html = '<svg><use href="/an/evil/url"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe('<svg><use></use></svg>');
});
it('sanitizes icons disabling unsafe xlink:href paths', () => {
const html = '<svg><use xlink:href="/an/evil/url"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe('<svg><use></use></svg>');
});
it('sanitizes icons disabling unsafe href hosts', () => {
const html = '<svg><use href="https://evil.url/assets/icons-123a.svg"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe('<svg><use></use></svg>');
});
it('sanitizes icons disabling unsafe xlink:href hosts', () => {
const html = '<svg><use xlink:href="https://evil.url/assets/icons-123a.svg"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe('<svg><use></use></svg>');
});
});
describe('when gon does not contain icon urls', () => {
beforeAll(() => {
originalGon = window.gon;
window.gon = {};
});
afterAll(() => {
window.gon = originalGon;
});
it('sanitizes icons disabling all xlink:href values', () => {
const html = '<svg><use xlink:href="/assets/icons-123a.svg#ellipsis_h"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe('<svg><use></use></svg>');
});
it('sanitizes icons disabling all href values', () => {
const html = '<svg><use href="/assets/icons-123a.svg#ellipsis_h"></use></svg>';
expect(sanitize(html, { ADD_TAGS: ['use'] })).toBe('<svg><use></use></svg>');
});
});
});
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery'; import $ from 'jquery';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { sanitize } from 'dompurify'; import { sanitize } from '~/lib/dompurify';
import ProjectFindFile from '~/project_find_file'; import ProjectFindFile from '~/project_find_file';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
jest.mock('dompurify', () => ({ jest.mock('~/lib/dompurify', () => ({
addHook: jest.fn(),
sanitize: jest.fn(val => val), sanitize: jest.fn(val => val),
})); }));
......
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