Skip to content

Commit

Permalink
feat(hits): opt-in xss filtering for hits and infinite hits. FIX #2138
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Maxime Janton authored and bobylito committed May 30, 2017
1 parent 8cb2d5c commit 4f67b48
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 16 deletions.
62 changes: 59 additions & 3 deletions src/connectors/hits/__tests__/connectHits-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ describe('connectHits', () => {
const makeWidget = connectHits(rendering);
const widget = makeWidget();

expect(widget.getConfiguration).toEqual(undefined);
expect(widget.getConfiguration()).toEqual({
highlightPreTag: '__ais-highlight__',
highlightPostTag: '__/ais-highlight__',
});

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

const helper = jsHelper(fakeClient, '', {});
helper.search = sinon.stub();
Expand All @@ -72,7 +75,8 @@ describe('connectHits', () => {
{fake: 'data'},
{sample: 'infos'},
];
const results = new SearchResults(helper.state, [{hits}]);

const results = new SearchResults(helper.state, [{hits: [].concat(hits)}]);
widget.render({
results,
state: helper.state,
Expand All @@ -84,4 +88,56 @@ describe('connectHits', () => {
expect(secondRenderingOptions.hits).toEqual(hits);
expect(secondRenderingOptions.results).toEqual(results);
});

it('escape highlight properties if requested', () => {
const rendering = sinon.stub();
const makeWidget = connectHits(rendering);
const widget = makeWidget({escapeHits: true});

const helper = jsHelper(fakeClient, '', {});
helper.search = sinon.stub();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
});

const firstRenderingOptions = rendering.lastCall.args[0];
expect(firstRenderingOptions.hits).toEqual([]);
expect(firstRenderingOptions.results).toBe(undefined);

const hits = [
{
_highlightResult: {
foobar: {
value: '<script>__ais-highlight__foobar__/ais-highlight__</script>',
},
},
},
];

const results = new SearchResults(helper.state, [{hits}]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
});

const escapedHits = [
{
_highlightResult: {
foobar: {
value: '&lt;script&gt;<em>foobar</em>&lt;/script&gt;',
},
},
},
];

const secondRenderingOptions = rendering.lastCall.args[0];
expect(secondRenderingOptions.hits).toEqual(escapedHits);
expect(secondRenderingOptions.results).toEqual(results);
});
});
22 changes: 20 additions & 2 deletions src/connectors/hits/connectHits.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import escapeHits, {tagConfig} from '../../lib/escape-highlight.js';
import {checkRendering} from '../../lib/utils.js';

const usage = `Usage:
Expand All @@ -9,7 +10,11 @@ var customHits = connectHits(function render(params, isFirstRendering) {
// widgetParams,
// }
});
search.addWidget(customHits());
search.addWidget(
customHits({
[ escapeHits = false ]
})
);
Full documentation available at https://community.algolia.com/instantsearch.js/connectors/connectHits.html
`;

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

/**
* @typedef {Object} CustomHitsWidgetOptions
* @property {boolean} [escapeHits = false] If true, escape HTML tags from `hits[i]._highlightResult`.
*/

/**
* **Hits** connector provides the logic to create custom widgets that will render the results retrieved from Algolia.
* @type {Connector}
* @param {function(HitsRenderingOptions, boolean)} renderFn Rendering function for the custom **Hits** widget.
* @return {function} Re-usable widget factory for a custom **Hits** widget.
* @return {function(CustomHitsWidgetOptions)} Re-usable widget factory for a custom **Hits** widget.
* @example
* // custom `renderFn` to render the custom Hits widget
* function renderFn(HitsRenderingOptions) {
Expand All @@ -49,6 +59,10 @@ export default function connectHits(renderFn) {
checkRendering(renderFn, usage);

return (widgetParams = {}) => ({
getConfiguration() {
return tagConfig;
},

init({instantSearchInstance}) {
renderFn({
hits: [],
Expand All @@ -59,6 +73,10 @@ export default function connectHits(renderFn) {
},

render({results, instantSearchInstance}) {
if (widgetParams.escapeHits && results.hits && results.hits.length > 0) {
results.hits = escapeHits(results.hits);
}

renderFn({
hits: results.hits,
results,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ describe('connectInfiniteHits', () => {
hitsPerPage: 10,
});

expect(widget.getConfiguration).toEqual(undefined);
expect(widget.getConfiguration()).toEqual({
highlightPostTag: '__/ais-highlight__',
highlightPreTag: '__ais-highlight__',
});

// test if widget is not rendered yet at this point
expect(rendering.callCount).toBe(0);
Expand Down Expand Up @@ -136,4 +139,56 @@ describe('connectInfiniteHits', () => {
expect(fourthRenderingOptions.hits).toEqual(thirdHits);
expect(fourthRenderingOptions.results).toEqual(thirdResults);
});

it('escape highlight properties if requested', () => {
const rendering = sinon.stub();
const makeWidget = connectInfiniteHits(rendering);
const widget = makeWidget({escapeHits: true});

const helper = jsHelper(fakeClient, '', {});
helper.search = sinon.stub();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
});

const firstRenderingOptions = rendering.lastCall.args[0];
expect(firstRenderingOptions.hits).toEqual([]);
expect(firstRenderingOptions.results).toBe(undefined);

const hits = [
{
_highlightResult: {
foobar: {
value: '<script>__ais-highlight__foobar__/ais-highlight__</script>',
},
},
},
];

const results = new SearchResults(helper.state, [{hits}]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
});

const escapedHits = [
{
_highlightResult: {
foobar: {
value: '&lt;script&gt;<em>foobar</em>&lt;/script&gt;',
},
},
},
];

const secondRenderingOptions = rendering.lastCall.args[0];
expect(secondRenderingOptions.hits).toEqual(escapedHits);
expect(secondRenderingOptions.results).toEqual(results);
});
});
20 changes: 18 additions & 2 deletions src/connectors/infinite-hits/connectInfiniteHits.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import escapeHits, {tagConfig} from '../../lib/escape-highlight.js';
import {checkRendering} from '../../lib/utils.js';

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

/**
* @typedef {Object} CustomInfiniteHitsWidgetOptions
* @property {boolean} [escapeHits = false] If true, escape HTML tags from `hits[i]._highlightResult`.
*/

/**
* **InfiniteHits** connector provides the logic to create custom widgets that will render an continuous list of results retrieved from Algolia.
*
* This connector provides a `InfiniteHitsRenderingOptions.showMore()` function to load next page of matched results.
* @type {Connector}
* @param {function(InfiniteHitsRenderingOptions, boolean)} renderFn Rendering function for the custom **InfiniteHits** widget.
* @return {function(object)} Re-usable widget factory for a custom **InfiniteHits** widget.
* @return {function(CustomInfiniteHitsWidgetOptions)} Re-usable widget factory for a custom **InfiniteHits** widget.
* @example
* // custom `renderFn` to render the custom InfiniteHits widget
* function renderFn(InfiniteHitsRenderingOptions, isFirstRendering) {
Expand Down Expand Up @@ -73,6 +81,10 @@ export default function connectInfiniteHits(renderFn) {
const getShowMore = helper => () => helper.nextPage().search();

return {
getConfiguration() {
return tagConfig;
},

init({instantSearchInstance, helper}) {
this.showMore = getShowMore(helper);

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

if (widgetParams.escapeHits && results.hits && results.hits.length > 0) {
results.hits = escapeHits(results.hits);
}

hitsCache = [...hitsCache, ...results.hits];

const isLastPage = results.nbPages <= results.page + 1;
Expand Down
Loading

0 comments on commit 4f67b48

Please sign in to comment.