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";