Commit bd9bc17a authored by Coung Ngo's avatar Coung Ngo

Fix GFM autocomplete xss

Changelog: security
parent 905e6500
import $ from 'jquery'; import $ from 'jquery';
import '~/lib/utils/jquery_at_who'; import '~/lib/utils/jquery_at_who';
import { escape, sortBy, template } from 'lodash'; import { escape as lodashEscape, sortBy, template } from 'lodash';
import * as Emoji from '~/emoji'; import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { s__, __, sprintf } from '~/locale'; import { s__, __, sprintf } from '~/locale';
...@@ -11,8 +11,21 @@ import { spriteIcon } from './lib/utils/common_utils'; ...@@ -11,8 +11,21 @@ import { spriteIcon } from './lib/utils/common_utils';
import { parsePikadayDate } from './lib/utils/datetime_utility'; import { parsePikadayDate } from './lib/utils/datetime_utility';
import glRegexp from './lib/utils/regexp'; import glRegexp from './lib/utils/regexp';
function sanitize(str) { /**
return str.replace(/<(?:.|\n)*?>/gm, ''); * Escapes user input before we pass it to at.js, which
* renders it as HTML in the autocomplete dropdown.
*
* at.js allows you to reference data using `${}` syntax
* (e.g. ${search}) which it replaces with the actual data
* before rendering it in the autocomplete dropdown.
* To prevent user input from executing this `${}` syntax,
* we also need to escape the $ character.
*
* @param string user input
* @return {string} escaped user input
*/
function escape(string) {
return lodashEscape(string).replace(/\$/g, '&dollar;');
} }
function createMemberSearchString(member) { function createMemberSearchString(member) {
...@@ -44,8 +57,8 @@ export function membersBeforeSave(members) { ...@@ -44,8 +57,8 @@ export function membersBeforeSave(members) {
return { return {
username: member.username, username: member.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
title: sanitize(title), title,
search: sanitize(createMemberSearchString(member)), search: createMemberSearchString(member),
icon: avatarIcon, icon: avatarIcon,
availability: member?.availability, availability: member?.availability,
}; };
...@@ -366,7 +379,7 @@ class GfmAutoComplete { ...@@ -366,7 +379,7 @@ class GfmAutoComplete {
} }
return { return {
id: i.iid, id: i.iid,
title: sanitize(i.title), title: i.title,
reference: i.reference, reference: i.reference,
search: `${i.iid} ${i.title}`, search: `${i.iid} ${i.title}`,
}; };
...@@ -404,7 +417,7 @@ class GfmAutoComplete { ...@@ -404,7 +417,7 @@ class GfmAutoComplete {
return { return {
id: m.iid, id: m.iid,
title: sanitize(m.title), title: m.title,
search: m.title, search: m.title,
expired, expired,
dueDate, dueDate,
...@@ -456,7 +469,7 @@ class GfmAutoComplete { ...@@ -456,7 +469,7 @@ class GfmAutoComplete {
} }
return { return {
id: m.iid, id: m.iid,
title: sanitize(m.title), title: m.title,
reference: m.reference, reference: m.reference,
search: `${m.iid} ${m.title}`, search: `${m.iid} ${m.title}`,
}; };
...@@ -492,7 +505,7 @@ class GfmAutoComplete { ...@@ -492,7 +505,7 @@ class GfmAutoComplete {
beforeSave(merges) { beforeSave(merges) {
if (GfmAutoComplete.isLoading(merges)) return merges; if (GfmAutoComplete.isLoading(merges)) return merges;
return $.map(merges, (m) => ({ return $.map(merges, (m) => ({
title: sanitize(m.title), title: m.title,
color: m.color, color: m.color,
search: m.title, search: m.title,
set: m.set, set: m.set,
...@@ -586,7 +599,7 @@ class GfmAutoComplete { ...@@ -586,7 +599,7 @@ class GfmAutoComplete {
} }
return { return {
id: m.id, id: m.id,
title: sanitize(m.title), title: m.title,
search: `${m.id} ${m.title}`, search: `${m.id} ${m.title}`,
}; };
}); });
......
...@@ -48,7 +48,7 @@ class GfmAutoCompleteEE extends GfmAutoComplete { ...@@ -48,7 +48,7 @@ class GfmAutoCompleteEE extends GfmAutoComplete {
return { return {
id: m.iid, id: m.iid,
reference: m.reference, reference: m.reference,
title: m.title.replace(/<(?:.|\n)*?>/gm, ''), title: m.title,
search: `${m.iid} ${m.title}`, search: `${m.iid} ${m.title}`,
}; };
}); });
...@@ -82,7 +82,7 @@ class GfmAutoCompleteEE extends GfmAutoComplete { ...@@ -82,7 +82,7 @@ class GfmAutoCompleteEE extends GfmAutoComplete {
} }
return { return {
id: m.id, id: m.id,
title: m.title.replace(/<(?:.|\n)*?>/gm, ''), title: m.title,
reference: m.reference, reference: m.reference,
search: `${m.id} ${m.title}`, search: `${m.id} ${m.title}`,
}; };
......
...@@ -574,6 +574,15 @@ describe('GfmAutoComplete', () => { ...@@ -574,6 +574,15 @@ describe('GfmAutoComplete', () => {
}), }),
).toBe('<li><small>grp/proj#5</small> Some Issue</li>'); ).toBe('<li><small>grp/proj#5</small> Some Issue</li>');
}); });
it('escapes title in the template as it is user input', () => {
expect(
GfmAutoComplete.Issues.templateFunction({
id: 5,
title: '${search}<script>oh no $', // eslint-disable-line no-template-curly-in-string
}),
).toBe('<li><small>5</small> &dollar;{search}&lt;script&gt;oh no &dollar;</li>');
});
}); });
describe('GfmAutoComplete.Members', () => { describe('GfmAutoComplete.Members', () => {
...@@ -608,16 +617,18 @@ describe('GfmAutoComplete', () => { ...@@ -608,16 +617,18 @@ describe('GfmAutoComplete', () => {
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
}); });
it('should add escaped title if title is set', () => { it('escapes title in the template as it is user input', () => {
expect( expect(
GfmAutoComplete.Members.templateFunction({ GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG', avatarTag: 'IMG',
username: 'my-group', username: 'my-group',
title: 'MyGroup+', title: '${search}<script>oh no $', // eslint-disable-line no-template-curly-in-string
icon: '<i class="icon"/>', icon: '<i class="icon"/>',
availabilityStatus: '', availabilityStatus: '',
}), }),
).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); ).toBe(
'<li>IMG my-group <small>&dollar;{search}&lt;script&gt;oh no &dollar;</small> <i class="icon"/></li>',
);
}); });
it('should add user availability status if availabilityStatus is set', () => { it('should add user availability status if availabilityStatus is set', () => {
...@@ -782,6 +793,15 @@ describe('GfmAutoComplete', () => { ...@@ -782,6 +793,15 @@ describe('GfmAutoComplete', () => {
${'/unlabel ~'} | ${assignedLabels} ${'/unlabel ~'} | ${assignedLabels}
`('$input shows $output.length labels', expectLabels); `('$input shows $output.length labels', expectLabels);
}); });
it('escapes title in the template as it is user input', () => {
const color = '#123456';
const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string
expect(GfmAutoComplete.Labels.templateFunction(color, title)).toBe(
'<li><span class="dropdown-label-box" style="background: #123456"></span> &dollar;{search}&lt;script&gt;oh no &dollar;</li>',
);
});
}); });
describe('emoji', () => { describe('emoji', () => {
...@@ -829,4 +849,15 @@ describe('GfmAutoComplete', () => { ...@@ -829,4 +849,15 @@ describe('GfmAutoComplete', () => {
}); });
}); });
}); });
describe('milestones', () => {
it('escapes title in the template as it is user input', () => {
const expired = false;
const title = '${search}<script>oh no $'; // eslint-disable-line no-template-curly-in-string
expect(GfmAutoComplete.Milestones.templateFunction(title, expired)).toBe(
'<li>&dollar;{search}&lt;script&gt;oh no &dollar;</li>',
);
});
});
}); });
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