Skip to content

Commit 4f67b48

Browse files
Maxime Jantonbobylito
authored andcommitted
feat(hits): opt-in xss filtering for hits and infinite hits. FIX #2138
* feat(connectHits): handle XSS and escape HTML entities * test(connectHits): ensure hit are correctly escaped * feat(connectHits): remove flagged boolean * feat(connectHits): add `escapeHits` and `escapeHitsWhitelist` options * feat(hitsWidget): support `escapeHits` and `escapeHitsWhitelist * test(hitsWidget): should throw on incorrect options * fix(connectHits): replace <em> from objects into highlightProperties * fix(connectHits): replace Array.includes with Array.indexOf * refactor(escapeHits): export function, escape only highlight * refactor(connectHits): use `escapeHighlight` helper * refactor(connectHits): use tag config from `escapeHighlight * test(connectHits): separated test for escape highlight property * refactor(connectHits): always escape highlight property * feat(connectInfiniteHits): always escape highlight property * fix(escape-highlight): ensure `results.hits` is escaped only once * feat(hits): opt-in for escaping hits * feat(infinite-hits): opt-in for escape * test(hits): remove un-used test
1 parent 8cb2d5c commit 4f67b48

File tree

9 files changed

+361
-16
lines changed

9 files changed

+361
-16
lines changed

src/connectors/hits/__tests__/connectHits-test.js

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ describe('connectHits', () => {
1515
const makeWidget = connectHits(rendering);
1616
const widget = makeWidget();
1717

18-
expect(widget.getConfiguration).toEqual(undefined);
18+
expect(widget.getConfiguration()).toEqual({
19+
highlightPreTag: '__ais-highlight__',
20+
highlightPostTag: '__/ais-highlight__',
21+
});
1922

2023
// test if widget is not rendered yet at this point
2124
expect(rendering.callCount).toBe(0);
@@ -52,7 +55,7 @@ describe('connectHits', () => {
5255
it('Provides the hits and the whole results', () => {
5356
const rendering = sinon.stub();
5457
const makeWidget = connectHits(rendering);
55-
const widget = makeWidget();
58+
const widget = makeWidget({});
5659

5760
const helper = jsHelper(fakeClient, '', {});
5861
helper.search = sinon.stub();
@@ -72,7 +75,8 @@ describe('connectHits', () => {
7275
{fake: 'data'},
7376
{sample: 'infos'},
7477
];
75-
const results = new SearchResults(helper.state, [{hits}]);
78+
79+
const results = new SearchResults(helper.state, [{hits: [].concat(hits)}]);
7680
widget.render({
7781
results,
7882
state: helper.state,
@@ -84,4 +88,56 @@ describe('connectHits', () => {
8488
expect(secondRenderingOptions.hits).toEqual(hits);
8589
expect(secondRenderingOptions.results).toEqual(results);
8690
});
91+
92+
it('escape highlight properties if requested', () => {
93+
const rendering = sinon.stub();
94+
const makeWidget = connectHits(rendering);
95+
const widget = makeWidget({escapeHits: true});
96+
97+
const helper = jsHelper(fakeClient, '', {});
98+
helper.search = sinon.stub();
99+
100+
widget.init({
101+
helper,
102+
state: helper.state,
103+
createURL: () => '#',
104+
onHistoryChange: () => {},
105+
});
106+
107+
const firstRenderingOptions = rendering.lastCall.args[0];
108+
expect(firstRenderingOptions.hits).toEqual([]);
109+
expect(firstRenderingOptions.results).toBe(undefined);
110+
111+
const hits = [
112+
{
113+
_highlightResult: {
114+
foobar: {
115+
value: '<script>__ais-highlight__foobar__/ais-highlight__</script>',
116+
},
117+
},
118+
},
119+
];
120+
121+
const results = new SearchResults(helper.state, [{hits}]);
122+
widget.render({
123+
results,
124+
state: helper.state,
125+
helper,
126+
createURL: () => '#',
127+
});
128+
129+
const escapedHits = [
130+
{
131+
_highlightResult: {
132+
foobar: {
133+
value: '&lt;script&gt;<em>foobar</em>&lt;/script&gt;',
134+
},
135+
},
136+
},
137+
];
138+
139+
const secondRenderingOptions = rendering.lastCall.args[0];
140+
expect(secondRenderingOptions.hits).toEqual(escapedHits);
141+
expect(secondRenderingOptions.results).toEqual(results);
142+
});
87143
});

src/connectors/hits/connectHits.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import escapeHits, {tagConfig} from '../../lib/escape-highlight.js';
12
import {checkRendering} from '../../lib/utils.js';
23

34
const usage = `Usage:
@@ -9,7 +10,11 @@ var customHits = connectHits(function render(params, isFirstRendering) {
910
// widgetParams,
1011
// }
1112
});
12-
search.addWidget(customHits());
13+
search.addWidget(
14+
customHits({
15+
[ escapeHits = false ]
16+
})
17+
);
1318
Full documentation available at https://community.algolia.com/instantsearch.js/connectors/connectHits.html
1419
`;
1520

@@ -20,11 +25,16 @@ Full documentation available at https://community.algolia.com/instantsearch.js/c
2025
* @property {Object} widgetParams All original widget options forwarded to the `renderFn`.
2126
*/
2227

28+
/**
29+
* @typedef {Object} CustomHitsWidgetOptions
30+
* @property {boolean} [escapeHits = false] If true, escape HTML tags from `hits[i]._highlightResult`.
31+
*/
32+
2333
/**
2434
* **Hits** connector provides the logic to create custom widgets that will render the results retrieved from Algolia.
2535
* @type {Connector}
2636
* @param {function(HitsRenderingOptions, boolean)} renderFn Rendering function for the custom **Hits** widget.
27-
* @return {function} Re-usable widget factory for a custom **Hits** widget.
37+
* @return {function(CustomHitsWidgetOptions)} Re-usable widget factory for a custom **Hits** widget.
2838
* @example
2939
* // custom `renderFn` to render the custom Hits widget
3040
* function renderFn(HitsRenderingOptions) {
@@ -49,6 +59,10 @@ export default function connectHits(renderFn) {
4959
checkRendering(renderFn, usage);
5060

5161
return (widgetParams = {}) => ({
62+
getConfiguration() {
63+
return tagConfig;
64+
},
65+
5266
init({instantSearchInstance}) {
5367
renderFn({
5468
hits: [],
@@ -59,6 +73,10 @@ export default function connectHits(renderFn) {
5973
},
6074

6175
render({results, instantSearchInstance}) {
76+
if (widgetParams.escapeHits && results.hits && results.hits.length > 0) {
77+
results.hits = escapeHits(results.hits);
78+
}
79+
6280
renderFn({
6381
hits: results.hits,
6482
results,

src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ describe('connectInfiniteHits', () => {
1717
hitsPerPage: 10,
1818
});
1919

20-
expect(widget.getConfiguration).toEqual(undefined);
20+
expect(widget.getConfiguration()).toEqual({
21+
highlightPostTag: '__/ais-highlight__',
22+
highlightPreTag: '__ais-highlight__',
23+
});
2124

2225
// test if widget is not rendered yet at this point
2326
expect(rendering.callCount).toBe(0);
@@ -136,4 +139,56 @@ describe('connectInfiniteHits', () => {
136139
expect(fourthRenderingOptions.hits).toEqual(thirdHits);
137140
expect(fourthRenderingOptions.results).toEqual(thirdResults);
138141
});
142+
143+
it('escape highlight properties if requested', () => {
144+
const rendering = sinon.stub();
145+
const makeWidget = connectInfiniteHits(rendering);
146+
const widget = makeWidget({escapeHits: true});
147+
148+
const helper = jsHelper(fakeClient, '', {});
149+
helper.search = sinon.stub();
150+
151+
widget.init({
152+
helper,
153+
state: helper.state,
154+
createURL: () => '#',
155+
onHistoryChange: () => {},
156+
});
157+
158+
const firstRenderingOptions = rendering.lastCall.args[0];
159+
expect(firstRenderingOptions.hits).toEqual([]);
160+
expect(firstRenderingOptions.results).toBe(undefined);
161+
162+
const hits = [
163+
{
164+
_highlightResult: {
165+
foobar: {
166+
value: '<script>__ais-highlight__foobar__/ais-highlight__</script>',
167+
},
168+
},
169+
},
170+
];
171+
172+
const results = new SearchResults(helper.state, [{hits}]);
173+
widget.render({
174+
results,
175+
state: helper.state,
176+
helper,
177+
createURL: () => '#',
178+
});
179+
180+
const escapedHits = [
181+
{
182+
_highlightResult: {
183+
foobar: {
184+
value: '&lt;script&gt;<em>foobar</em>&lt;/script&gt;',
185+
},
186+
},
187+
},
188+
];
189+
190+
const secondRenderingOptions = rendering.lastCall.args[0];
191+
expect(secondRenderingOptions.hits).toEqual(escapedHits);
192+
expect(secondRenderingOptions.results).toEqual(results);
193+
});
139194
});

src/connectors/infinite-hits/connectInfiniteHits.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import escapeHits, {tagConfig} from '../../lib/escape-highlight.js';
12
import {checkRendering} from '../../lib/utils.js';
23

34
const usage = `Usage:
@@ -12,7 +13,9 @@ var customInfiniteHits = connectInfiniteHits(function render(params, isFirstRend
1213
// }
1314
});
1415
search.addWidget(
15-
customInfiniteHits()
16+
customInfiniteHits({
17+
escapeHits: true,
18+
})
1619
);
1720
Full documentation available at https://community.algolia.com/instantsearch.js/connectors/connectInfiniteHits.html
1821
`;
@@ -26,13 +29,18 @@ Full documentation available at https://community.algolia.com/instantsearch.js/c
2629
* @property {Object} widgetParams All original widget options forwarded to the `renderFn`.
2730
*/
2831

32+
/**
33+
* @typedef {Object} CustomInfiniteHitsWidgetOptions
34+
* @property {boolean} [escapeHits = false] If true, escape HTML tags from `hits[i]._highlightResult`.
35+
*/
36+
2937
/**
3038
* **InfiniteHits** connector provides the logic to create custom widgets that will render an continuous list of results retrieved from Algolia.
3139
*
3240
* This connector provides a `InfiniteHitsRenderingOptions.showMore()` function to load next page of matched results.
3341
* @type {Connector}
3442
* @param {function(InfiniteHitsRenderingOptions, boolean)} renderFn Rendering function for the custom **InfiniteHits** widget.
35-
* @return {function(object)} Re-usable widget factory for a custom **InfiniteHits** widget.
43+
* @return {function(CustomInfiniteHitsWidgetOptions)} Re-usable widget factory for a custom **InfiniteHits** widget.
3644
* @example
3745
* // custom `renderFn` to render the custom InfiniteHits widget
3846
* function renderFn(InfiniteHitsRenderingOptions, isFirstRendering) {
@@ -73,6 +81,10 @@ export default function connectInfiniteHits(renderFn) {
7381
const getShowMore = helper => () => helper.nextPage().search();
7482

7583
return {
84+
getConfiguration() {
85+
return tagConfig;
86+
},
87+
7688
init({instantSearchInstance, helper}) {
7789
this.showMore = getShowMore(helper);
7890

@@ -91,6 +103,10 @@ export default function connectInfiniteHits(renderFn) {
91103
hitsCache = [];
92104
}
93105

106+
if (widgetParams.escapeHits && results.hits && results.hits.length > 0) {
107+
results.hits = escapeHits(results.hits);
108+
}
109+
94110
hitsCache = [...hitsCache, ...results.hits];
95111

96112
const isLastPage = results.nbPages <= results.page + 1;

0 commit comments

Comments
 (0)