diff --git a/packages/react-search-ui-views/package.json b/packages/react-search-ui-views/package.json
index ee8123f0..a35ee09f 100644
--- a/packages/react-search-ui-views/package.json
+++ b/packages/react-search-ui-views/package.json
@@ -59,6 +59,7 @@
"@storybook/react": "^4.1.18",
"babel-loader": "^8.0.6",
"chokidar-cli": "^1.2.2",
+ "core-js": "^3.6.5",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"enzyme-to-json": "^3.3.5",
diff --git a/packages/react-search-ui-views/src/Result.js b/packages/react-search-ui-views/src/Result.js
index 89aece52..ffc6ede8 100644
--- a/packages/react-search-ui-views/src/Result.js
+++ b/packages/react-search-ui-views/src/Result.js
@@ -1,7 +1,7 @@
import PropTypes from "prop-types";
import React from "react";
-import { appendClassName } from "./view-helpers";
+import { appendClassName, getUrlSanitizer } from "./view-helpers";
import { isFieldValueWrapper } from "./types/FieldValueWrapper";
function getFieldType(result, field, type) {
@@ -59,7 +59,7 @@ function Result({
}) {
const fields = getEscapedFields(result);
const title = getEscapedField(result, titleField);
- const url = getRaw(result, urlField);
+ const url = getUrlSanitizer(URL, location)(getRaw(result, urlField));
return (
diff --git a/packages/react-search-ui-views/src/__tests__/Result.test.js b/packages/react-search-ui-views/src/__tests__/Result.test.js
index 79e6b615..9274b246 100644
--- a/packages/react-search-ui-views/src/__tests__/Result.test.js
+++ b/packages/react-search-ui-views/src/__tests__/Result.test.js
@@ -105,3 +105,19 @@ it("renders with className prop applied", () => {
const { className } = wrapper.props();
expect(className).toEqual("sui-result test-class");
});
+
+it("renders correctly when there is a malicious URL", () => {
+ const wrapper = shallow(
+
+ );
+ expect(wrapper).toMatchSnapshot();
+});
diff --git a/packages/react-search-ui-views/src/__tests__/__snapshots__/Result.test.js.snap b/packages/react-search-ui-views/src/__tests__/__snapshots__/Result.test.js.snap
index 7732bca5..9451924a 100644
--- a/packages/react-search-ui-views/src/__tests__/__snapshots__/Result.test.js.snap
+++ b/packages/react-search-ui-views/src/__tests__/__snapshots__/Result.test.js.snap
@@ -90,6 +90,60 @@ exports[`renders correctly when there is a URL 1`] = `
`;
+exports[`renders correctly when there is a malicious URL 1`] = `
+
+
+
+
+ -
+
+ field
+
+
+
+
+ -
+
+ url
+
+
+
+
+
+
+
+`;
+
exports[`renders correctly when there is a title 1`] = `
{
+ let url;
+ let currentLocation;
+
+ beforeEach(() => {
+ url = "";
+ currentLocation = "";
+ });
+
+ function subject() {
+ return getUrlSanitizer(URL, currentLocation)(url);
+ }
+
+ describe("when valid url with http", () => {
+ beforeEach(() => {
+ url = "http://www.example.com/";
+ currentLocation = "http://www.mysite.com";
+ });
+
+ it("should allow it", () => {
+ expect(subject()).toEqual(url);
+ });
+ });
+
+ describe("when valid url with https", () => {
+ beforeEach(() => {
+ url = "https://www.example.com/";
+ currentLocation = "http://www.mysite.com";
+ });
+
+ it("should allow it", () => {
+ expect(subject()).toEqual(url);
+ });
+ });
+
+ describe("when relative url", () => {
+ beforeEach(() => {
+ url = "/item/1234";
+ currentLocation = "http://www.mysite.com";
+ });
+
+ it("should allow it", () => {
+ expect(subject()).toEqual(url);
+ });
+ });
+
+ describe("when the protocol is javascript", () => {
+ beforeEach(() => {
+ url = "javascript://test%0aalert(document.domain)";
+ currentLocation = "http://www.mysite.com";
+ });
+
+ it("should disallow it", () => {
+ expect(subject()).toEqual("");
+ });
+ });
+
+ describe("when the protocol is javascript with spaces in it", () => {
+ beforeEach(() => {
+ url = " javascript://test%0aalert(document.domain)";
+ currentLocation = "http://www.mysite.com";
+ });
+
+ it("should disallow it", () => {
+ expect(subject()).toEqual("");
+ });
+ });
+
+ describe("when the url is invalid", () => {
+ beforeEach(() => {
+ url = "My bad URL
";
+ currentLocation = "http://www.mysite.com";
+ });
+
+ it("treats it as a relative url, which should still be safe", () => {
+ expect(subject()).toEqual(url);
+ });
+ });
+
+ describe("when the protocol is not whitelisted", () => {
+ beforeEach(() => {
+ url = "mailto://user@example.com";
+ currentLocation = "http://www.mysite.com";
+ });
+
+ it("should disallow it", () => {
+ expect(subject()).toEqual("");
+ });
+ });
+
+ describe("when the url is protocol-less", () => {
+ beforeEach(() => {
+ url = "//www.example.com/";
+ currentLocation = "https://www.mysite.com";
+ });
+
+ it("uses the protocol from the current location", () => {
+ expect(subject()).toEqual(url);
+ });
+ });
+});
diff --git a/packages/react-search-ui-views/src/view-helpers/getUrlSanitizer.js b/packages/react-search-ui-views/src/view-helpers/getUrlSanitizer.js
new file mode 100644
index 00000000..a2764947
--- /dev/null
+++ b/packages/react-search-ui-views/src/view-helpers/getUrlSanitizer.js
@@ -0,0 +1,21 @@
+const VALID_PROTOCOLS = ["http:", "https:"];
+
+/**
+ *
+ * @param {URL} URLParser URL interface provided by browser https://developer.mozilla.org/en-US/docs/Web/API/URL
+ * @param {String} currentLocation String representation of the browser's current location
+ */
+export default function getUrlSanitizer(URLParser, currentLocation) {
+ // This function is curried so that dependencies can be injected and don't need to be mocked in tests.
+ return url => {
+ let parsedUrl = {};
+
+ try {
+ // Attempts to parse a URL as relative
+ parsedUrl = new URLParser(url, currentLocation);
+ // eslint-disable-next-line no-empty
+ } catch (e) {}
+
+ return VALID_PROTOCOLS.includes(parsedUrl.protocol) ? url : "";
+ };
+}
diff --git a/packages/react-search-ui-views/src/view-helpers/index.js b/packages/react-search-ui-views/src/view-helpers/index.js
index 8d1c7bdf..3e813714 100644
--- a/packages/react-search-ui-views/src/view-helpers/index.js
+++ b/packages/react-search-ui-views/src/view-helpers/index.js
@@ -1,2 +1,3 @@
export { default as getFilterValueDisplay } from "./getFilterValueDisplay";
export { default as appendClassName } from "./appendClassName";
+export { default as getUrlSanitizer } from "./getUrlSanitizer";