Skip to content
This repository has been archived by the owner on Dec 30, 2022. It is now read-only.

Commit

Permalink
fix(Highlight, Snippet): prevent XSS via routing (#783)
Browse files Browse the repository at this point in the history
Co-authored-by: Haroen Viaene <hello@haroen.me>
  • Loading branch information
Eunjae Lee and Haroenv committed Jun 11, 2020
1 parent 20671bf commit 0b20520
Show file tree
Hide file tree
Showing 9 changed files with 465 additions and 29 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,15 @@
"bundlesize": [
{
"path": "./dist/vue-instantsearch.js",
"maxSize": "47 kB"
"maxSize": "47.75 kB"
},
{
"path": "./dist/vue-instantsearch.esm.js",
"maxSize": "14.50 kB"
"maxSize": "15.75 kB"
},
{
"path": "./dist/vue-instantsearch.common.js",
"maxSize": "14.75 kB"
"maxSize": "16 kB"
}
],
"jest": {
Expand Down
23 changes: 10 additions & 13 deletions src/components/Highlight.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
<template>
<span
:class="suit()"
v-html="innerHTML"
<ais-highlighter
:hit="hit"
:attribute="attribute"
:highlighted-tag-name="highlightedTagName"
:suit="suit"
highlight-property="_highlightResult"
pre-tag="<mark>"
post-tag="</mark>"
/>
</template>

<script>
import instantsearch from 'instantsearch.js/es';
import { createSuitMixin } from '../mixins/suit';
import AisHighlighter from './Highlighter.vue';
export default {
name: 'AisHighlight',
mixins: [createSuitMixin({ name: 'Highlight' })],
components: { AisHighlighter },
props: {
hit: {
type: Object,
Expand All @@ -26,14 +32,5 @@ export default {
default: 'mark',
},
},
computed: {
innerHTML() {
return instantsearch.highlight({
attribute: this.attribute,
hit: this.hit,
highlightedTagName: this.highlightedTagName,
});
},
},
};
</script>
60 changes: 60 additions & 0 deletions src/components/Highlighter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<template>
<span
:class="suit()"
>
<component
v-for="({ value, isHighlighted }, index) in parsedHighlights"
:class="[isHighlighted && suit('highlighted')]"
:key="index"
:is="isHighlighted ? highlightedTagName : textNode"
>{{ value }}</component>
</span>
</template>

<script>
import { parseAlgoliaHit } from '../util/parseAlgoliaHit';
export default {
name: 'AisHighlighter',
props: {
hit: {
type: Object,
required: true,
},
attribute: {
type: String,
required: true,
},
highlightedTagName: {
type: String,
default: 'mark',
},
suit: { type: Function, required: true },
highlightProperty: { type: String, required: true },
preTag: { type: String, required: true },
postTag: { type: String, required: true },
},
data() {
return {
textNode: {
functional: true,
render(createElement, context) {
const slots = context.slots();
return slots.default;
},
},
};
},
computed: {
parsedHighlights() {
return parseAlgoliaHit({
attribute: this.attribute,
hit: this.hit,
highlightProperty: this.highlightProperty,
preTag: this.preTag,
postTag: this.postTag,
});
},
},
};
</script>
23 changes: 10 additions & 13 deletions src/components/Snippet.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
<template>
<span
:class="suit()"
v-html="innerHTML"
<ais-highlighter
:hit="hit"
:attribute="attribute"
:highlighted-tag-name="highlightedTagName"
:suit="suit"
highlight-property="_snippetResult"
pre-tag="<mark>"
post-tag="</mark>"
/>
</template>

<script>
import instantsearch from 'instantsearch.js/es';
import { createSuitMixin } from '../mixins/suit';
import AisHighlighter from './Highlighter.vue';
export default {
name: 'AisSnippet',
mixins: [createSuitMixin({ name: 'Snippet' })],
components: { AisHighlighter },
props: {
hit: {
type: Object,
Expand All @@ -26,14 +32,5 @@ export default {
default: 'mark',
},
},
computed: {
innerHTML() {
return instantsearch.snippet({
attribute: this.attribute,
hit: this.hit,
highlightedTagName: this.highlightedTagName,
});
},
},
};
</script>
203 changes: 203 additions & 0 deletions src/util/__tests__/parseAlgoliaHit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// copied from React InstantSearch
import { parseAlgoliaHit } from '../parseAlgoliaHit';

describe('parseAlgoliaHit()', () => {
it('it does not break when there is a missing attribute', () => {
const attribute = 'attr';
const out = parseAlgoliaHit({
attribute,
hit: {},
highlightProperty: '_highlightResult',
});
expect(out).toEqual([]);
});

it('creates a single element when there is no tag', () => {
const value = 'foo bar baz';
const attribute = 'attr';
const out = parseAlgoliaHit({
attribute,
hit: createHit(attribute, value),
highlightProperty: '_highlightResult',
});
expect(out).toEqual([{ isHighlighted: false, value }]);
});

it('creates a single element when there is only a tag', () => {
const textValue = 'foo bar baz';
const value = `__ais-highlight__${textValue}__/ais-highlight__`;
const attribute = 'attr';
const out = parseAlgoliaHit({
attribute,
hit: createHit(attribute, value),
highlightProperty: '_highlightResult',
});
expect(out).toEqual([{ value: textValue, isHighlighted: true }]);
});

it('fetches and parses a deep attribute', () => {
const textValue = 'foo bar baz';
const value = `__ais-highlight__${textValue}__/ais-highlight__`;
const hit = {
lvl0: { lvl1: { lvl2: value } },
_highlightResult: {
lvl0: { lvl1: { lvl2: { value } } },
},
};
const out = parseAlgoliaHit({
attribute: 'lvl0.lvl1.lvl2',
hit,
highlightProperty: '_highlightResult',
});
expect(out).toEqual([{ value: textValue, isHighlighted: true }]);
});

it('parses the string and returns the part that are highlighted - 1 big highlight', () => {
const str =
'like __ais-highlight__al__/ais-highlight__golia does __ais-highlight__al__/ais-highlight__golia';
const hit = createHit('attr', str);
const parsed = parseAlgoliaHit({
attribute: 'attr',
hit,
highlightProperty: '_highlightResult',
});
expect(parsed).toEqual([
{ value: 'like ', isHighlighted: false },
{ value: 'al', isHighlighted: true },
{ value: 'golia does ', isHighlighted: false },
{ value: 'al', isHighlighted: true },
{ value: 'golia', isHighlighted: false },
]);
});

it('supports the array format, parses it and returns the part that is highlighted', () => {
const hit = {
tags: ['litterature', 'biology', 'photography'],
_highlightResult: {
tags: [
{ value: 'litterature' },
{ value: 'biology' },
{ value: '__ais-highlight__photo__/ais-highlight__graphy' },
],
},
};

const actual = parseAlgoliaHit({
attribute: 'tags',
hit,
highlightProperty: '_highlightResult',
});

const exepectation = [
[{ value: 'litterature', isHighlighted: false }],
[{ value: 'biology', isHighlighted: false }],
[
{ value: 'photo', isHighlighted: true },
{ value: 'graphy', isHighlighted: false },
],
];

expect(actual).toEqual(exepectation);
});

it('parses the string and returns the part that are highlighted - same pre and post tag', () => {
const str = 'surpise **lo**l mouhahah roflmao **lo**utre';
const hit = createHit('attr', str);
const parsed = parseAlgoliaHit({
preTag: '**',
postTag: '**',
attribute: 'attr',
hit,
highlightProperty: '_highlightResult',
});
expect(parsed).toEqual([
{ value: 'surpise ', isHighlighted: false },
{ value: 'lo', isHighlighted: true },
{ value: 'l mouhahah roflmao ', isHighlighted: false },
{ value: 'lo', isHighlighted: true },
{ value: 'utre', isHighlighted: false },
]);
});

it('throws when hit is `null`', () => {
expect(
parseAlgoliaHit.bind(null, {
attribute: 'unknownattribute',
hit: null,
highlightProperty: '_highlightResult',
})
).toThrow('`hit`, the matching record, must be provided');
});

it('throws when hit is `undefined`', () => {
expect(
parseAlgoliaHit.bind(null, {
attribute: 'unknownAttribute',
hit: undefined,
highlightProperty: '_highlightResult',
})
).toThrow('`hit`, the matching record, must be provided');
});

it('unescapes escaped strings', () => {
expect(
parseAlgoliaHit({
attribute: 'title',
hit: {
_highlightResult: {
title: {
value: '&#39;TV&#39; &amp; &lt;Home&gt; &quot;Theater&quot;',
},
},
},
highlightProperty: '_highlightResult',
})
).toEqual([
{
isHighlighted: false,
value: `'TV' & <Home> "Theater"`,
},
]);

expect(
parseAlgoliaHit({
attribute: 'categories',
hit: {
_highlightResult: {
categories: [
{
value: 'TV &amp; Home Theater',
},
{
value: '__ais-highlight__&#39;TV&#39;__/ais-highlight__',
},
],
},
},
highlightProperty: '_highlightResult',
})
).toEqual([
[
{
isHighlighted: false,
value: `TV & Home Theater`,
},
],
[
{
isHighlighted: true,
value: `'TV'`,
},
],
]);
});
});

function createHit(attribute, value) {
return {
[attribute]: value,
_highlightResult: {
[attribute]: { value },
},
};
}
29 changes: 29 additions & 0 deletions src/util/__tests__/unescape.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { unescape } from '../unescape';

describe('unescape', () => {
it('unescapes value', () => {
expect(unescape('fred, barney, &amp; pebbles')).toBe(
'fred, barney, & pebbles'
);

expect(unescape('&amp;&lt;&gt;&quot;&#39;/')).toEqual('&<>"\'/');
});

it('handles strings with nothing to unescape', () => {
expect(unescape('abc')).toEqual('abc');
});

it('does not unescape the "`" character', () => {
expect(unescape('`')).toEqual('`');
});

it('does not unescape the "/" character', () => {
expect(unescape('/')).toEqual('/');
});

it('handles strings with tags', () => {
expect(unescape('<mark>TV &amp; Home Theater</mark>')).toBe(
'<mark>TV & Home Theater</mark>'
);
});
});
Loading

0 comments on commit 0b20520

Please sign in to comment.