From 2a3b1b13d96278af1eb7981f0997a554934b5f89 Mon Sep 17 00:00:00 2001 From: "J.D. House" Date: Wed, 8 Jul 2026 16:57:52 -0500 Subject: [PATCH 1/6] wip --- package-lock.json | 6 +- src/_scss/components/_externalLink.scss | 1 + .../layouts/default/header/nav/_megaMenu.scss | 1 + .../default/header/nav/mobile/_dropdown.scss | 1 + .../pages/analystGuide/analystGuide.scss | 4 +- src/_scss/pages/covid19/index.scss | 11 +++- src/_scss/pages/data-sources/index.scss | 2 +- .../pages/homepage/_featuredContent.scss | 1 + .../sharedComponents/ExternalLink.jsx | 44 +++++++++++++-- .../megaMenu/dropdowns/MenuDropdown.jsx | 2 +- .../megaMenu/mobile/MobileDropdownItem.jsx | 2 +- src/js/helpers/url.js | 55 ++++++++++++++++++- 12 files changed, 113 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe3f458898..c71bdec492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3309,9 +3309,9 @@ } }, "node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" diff --git a/src/_scss/components/_externalLink.scss b/src/_scss/components/_externalLink.scss index f5aa1560ff..c8815ea257 100644 --- a/src/_scss/components/_externalLink.scss +++ b/src/_scss/components/_externalLink.scss @@ -4,6 +4,7 @@ } .usda-external-link__card { + @include button-link; &:focus, &:active, &:hover { diff --git a/src/_scss/layouts/default/header/nav/_megaMenu.scss b/src/_scss/layouts/default/header/nav/_megaMenu.scss index e3ed1bafd8..c63a57ac5e 100644 --- a/src/_scss/layouts/default/header/nav/_megaMenu.scss +++ b/src/_scss/layouts/default/header/nav/_megaMenu.scss @@ -101,6 +101,7 @@ // button overrides for the external links .usda-external-link { + @include button-link; background-color: $gray-cool-2; margin: 0; padding: 0; diff --git a/src/_scss/layouts/default/header/nav/mobile/_dropdown.scss b/src/_scss/layouts/default/header/nav/mobile/_dropdown.scss index 3d71c9f467..277393287c 100644 --- a/src/_scss/layouts/default/header/nav/mobile/_dropdown.scss +++ b/src/_scss/layouts/default/header/nav/mobile/_dropdown.scss @@ -20,6 +20,7 @@ // button overrides for the external links .usda-external-link { + @include button-link; background-color: white; margin: 0; padding: 0; diff --git a/src/_scss/pages/analystGuide/analystGuide.scss b/src/_scss/pages/analystGuide/analystGuide.scss index cbd2e95870..69e7a35bdf 100644 --- a/src/_scss/pages/analystGuide/analystGuide.scss +++ b/src/_scss/pages/analystGuide/analystGuide.scss @@ -153,7 +153,9 @@ color: $color-base; font-weight: $font-normal; word-break: break-word; - .analyst-guide__external-link { + .analyst-guide__external-link, + .usda-external-link, + .usda-external-link__card { @include button-unstyled; color: $theme-color-1; font-size: inherit; diff --git a/src/_scss/pages/covid19/index.scss b/src/_scss/pages/covid19/index.scss index 3ba17b65b0..1cf746cd03 100644 --- a/src/_scss/pages/covid19/index.scss +++ b/src/_scss/pages/covid19/index.scss @@ -143,11 +143,17 @@ font-size: $smallest-font-size; .usda-external-link { + @include button-link; font-size: $smallest-font-size; } } - p a { - font-weight: $font-semibold; + p { + a, + .usda-external-link, + .usda-external-link__card { + @include button-link; + font-weight: $font-semibold; + } } .glossary-term { font-weight: $font-semibold; @@ -240,6 +246,7 @@ font-size: $smallest-font-size; .usda-external-link { + @include button-link; font-size: $smallest-font-size; } } diff --git a/src/_scss/pages/data-sources/index.scss b/src/_scss/pages/data-sources/index.scss index 414a5f5a85..dd7e13080a 100644 --- a/src/_scss/pages/data-sources/index.scss +++ b/src/_scss/pages/data-sources/index.scss @@ -35,7 +35,7 @@ margin: auto; } .about-section-content .usda-external-link { - @include button-unstyled; + @include button-link; display: inline; padding: 0 rem(5); font-size: rem(18); diff --git a/src/_scss/pages/homepage/_featuredContent.scss b/src/_scss/pages/homepage/_featuredContent.scss index 649b2cec8a..8b57c63de1 100644 --- a/src/_scss/pages/homepage/_featuredContent.scss +++ b/src/_scss/pages/homepage/_featuredContent.scss @@ -44,6 +44,7 @@ } .usda-external-link__card { + @include button-link; &:focus, &:active { cursor: pointer; diff --git a/src/js/components/sharedComponents/ExternalLink.jsx b/src/js/components/sharedComponents/ExternalLink.jsx index d2f791928a..3eff9e55b2 100644 --- a/src/js/components/sharedComponents/ExternalLink.jsx +++ b/src/js/components/sharedComponents/ExternalLink.jsx @@ -8,6 +8,7 @@ import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { showModal } from 'redux/actions/modal/modalActions'; +import { sanitizeUrl } from '../../helpers/url'; const propTypes = { url: PropTypes.string.isRequired, @@ -19,26 +20,57 @@ const propTypes = { const ExternalLink = ({ url, children, isCard, showIcon = false }) => { + const sanitized = sanitizeUrl(url); + const isSafe = sanitized !== null; + const href = isSafe ? sanitized : 'not available'; + const dispatch = useDispatch(); const redirect = () => { - dispatch(showModal(url)); + dispatch(showModal(href)); }; const keyPressHandler = (e) => { if (e.key === 'Enter') { e.preventDefault(); - dispatch(showModal(url)); + dispatch(showModal(href)); } }; if (isCard) { if (showIcon) { - return {children} ; + return ( + + ); } - return {children}; + return ( + + ); } return ( - ); }; diff --git a/src/js/components/sharedComponents/megaMenu/dropdowns/MenuDropdown.jsx b/src/js/components/sharedComponents/megaMenu/dropdowns/MenuDropdown.jsx index c32d951d42..9ecb16ab15 100644 --- a/src/js/components/sharedComponents/megaMenu/dropdowns/MenuDropdown.jsx +++ b/src/js/components/sharedComponents/megaMenu/dropdowns/MenuDropdown.jsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { uniqueId } from 'lodash-es'; import { Link } from "react-router"; import FadeContents from "./FadeContents"; -import isRedirectNeeded from '../../../../helpers/url'; +import { isRedirectNeeded } from '../../../../helpers/url'; import ExternalLink from "../../ExternalLink"; import { showSlideout } from "../../../../helpers/slideoutHelper"; diff --git a/src/js/components/sharedComponents/megaMenu/mobile/MobileDropdownItem.jsx b/src/js/components/sharedComponents/megaMenu/mobile/MobileDropdownItem.jsx index ce6c392592..e158d5df5d 100644 --- a/src/js/components/sharedComponents/megaMenu/mobile/MobileDropdownItem.jsx +++ b/src/js/components/sharedComponents/megaMenu/mobile/MobileDropdownItem.jsx @@ -9,7 +9,7 @@ import Analytics from 'helpers/analytics/Analytics'; import { uniqueId } from 'lodash-es'; import { Link, useNavigate } from "react-router"; import PropTypes from 'prop-types'; -import isRedirectNeeded from '../../../../helpers/url'; +import { isRedirectNeeded } from '../../../../helpers/url'; import ExternalLink from '../../ExternalLink'; import { showSlideout } from '../../../../helpers/slideoutHelper'; diff --git a/src/js/helpers/url.js b/src/js/helpers/url.js index 8bc2ba8e32..7396207dad 100644 --- a/src/js/helpers/url.js +++ b/src/js/helpers/url.js @@ -1,2 +1,53 @@ -const isRedirectNeeded = (item) => item.externalLink && !item.url.includes('.gov'); -export default isRedirectNeeded; +export const isRedirectNeeded = (item) => item.externalLink && !item.url.includes('.gov'); + +const commonAttackParams = [ + "redirect", + "redirect_url", + "next", + "url", + "return", + "returnUrl", + "return_url", + "goto", + "destination", + "dest", + "forward", + "user", + "userName", + "user_name", + "password", + "login" +]; + +export const sanitizeUrl = (rawURL, blockRedirect = true) => { + // must be non-empty string + if (typeof rawURL !== "string" || rawURL.trim() === "") return null; + + // strip whitespace + invisible/zero-width unicode characters + const stripped = rawURL + .trim() + .replace(/[\s\p{Cf}]/gu, ''); + + let parsed; + + try { + parsed = new URL(stripped); + } + catch { + return null + + } + + // https only + if (parsed.protocol !== "https:") return null; + + // hostname must exist and not just whitespace + if (!parsed.hostname || parsed.hostname.trim() === "") return null; + + if (blockRedirect) { + // remove open-direct query params + commonAttackParams.forEach((param) => parsed.searchParams.delete(param)); + } + + return encodeURI(parsed.toString()); +} From 13657087a1e1475a3200ab29b98c7ced5c24e3da Mon Sep 17 00:00:00 2001 From: "J.D. House" Date: Thu, 9 Jul 2026 13:21:29 -0500 Subject: [PATCH 2/6] sanitize agency api urls --- package-lock.json | 12 ++++++------ .../agencyLanding/AgencyLandingContainer.jsx | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index c71bdec492..04b7ae7f84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24594,9 +24594,9 @@ } }, "node_modules/css-minimizer-webpack-plugin/node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.50", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.50.tgz", + "integrity": "sha512-ydBWw0G6WFwWHzh9RK4B5c690UkreOG0llq0r+DaI7LgKgxigf8mhHzIPI3S0850g1BPkq/zpuCfrq4QFgUlTQ==", "dev": true, "license": "MIT" }, @@ -26480,9 +26480,9 @@ "dev": true }, "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.50", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.50.tgz", + "integrity": "sha512-ydBWw0G6WFwWHzh9RK4B5c690UkreOG0llq0r+DaI7LgKgxigf8mhHzIPI3S0850g1BPkq/zpuCfrq4QFgUlTQ==", "license": "MIT" }, "node_modules/zod": { diff --git a/src/js/containers/agencyLanding/AgencyLandingContainer.jsx b/src/js/containers/agencyLanding/AgencyLandingContainer.jsx index 38347c28de..3f22e82aff 100644 --- a/src/js/containers/agencyLanding/AgencyLandingContainer.jsx +++ b/src/js/containers/agencyLanding/AgencyLandingContainer.jsx @@ -14,6 +14,7 @@ import { orderBy } from 'lodash-es'; import AgenciesTableFields from 'dataMapping/agencyLanding/agenciesTableFields'; import * as AgencyLandingHelper from 'helpers/agencyLandingHelper'; import * as MoneyFormatter from 'helpers/moneyFormatter'; +import { sanitizeUrl } from 'helpers/url'; import AgencyLandingContent from 'components/agencyLanding/AgencyLandingContent'; @@ -161,7 +162,7 @@ export class AgencyLandingContainer extends React.Component { agency_name: `${item.agency_name} (${item.abbreviation})`, budget_authority_amount: formattedCurrency, percentage_of_total_budget_authority: percent, - congressional_justification_url: item.congressional_justification_url + congressional_justification_url: sanitizeUrl(item.congressional_justification_url) || 'not available' } }; From f4dca8dd2e3c081b2accda4047e945a2bd6333a4 Mon Sep 17 00:00:00 2001 From: "J.D. House" Date: Thu, 9 Jul 2026 13:21:54 -0500 Subject: [PATCH 3/6] sanitize awardDataArchive urls --- .../bulkDownload/archive/AwardDataArchiveContainer.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/containers/bulkDownload/archive/AwardDataArchiveContainer.jsx b/src/js/containers/bulkDownload/archive/AwardDataArchiveContainer.jsx index 0fab3d2bbe..5180e98a3d 100644 --- a/src/js/containers/bulkDownload/archive/AwardDataArchiveContainer.jsx +++ b/src/js/containers/bulkDownload/archive/AwardDataArchiveContainer.jsx @@ -8,6 +8,7 @@ import { requestArchiveFiles } from 'helpers/bulkDownloadHelper'; import AwardDataArchiveContent from 'components/bulkDownload/archive/AwardDataArchiveContent'; import useRequestAgenciesList from "hooks/useRequestAgenciesList"; import PropTypes from "prop-types"; +import { sanitizeUrl } from 'helpers/url'; const dayjs = require('dayjs'); @@ -76,7 +77,7 @@ const AwardDataArchiveContainer = ({ filters, setFilters, results, setResults }) const file = { agency: formattedAgency, fileName: item.file_name, - url: item.url, + url: sanitizeUrl(item.url) || 'not available', fy: formattedFY, date: formattedDate }; From 206264d83e26eaf4b901f1a63f41bdfd03bf610d Mon Sep 17 00:00:00 2001 From: "J.D. House" Date: Thu, 9 Jul 2026 13:53:45 -0500 Subject: [PATCH 4/6] final url sanitation --- .../assistanceListing/CFDADetailModal.jsx | 5 +- .../sharedComponents/RedirectModal.jsx | 165 ++++++++++-------- .../globalModal/GlobalModalContainer.jsx | 127 ++++++-------- src/js/helpers/redirectHelper.js | 6 +- 4 files changed, 154 insertions(+), 149 deletions(-) diff --git a/src/js/components/covid19/assistanceListing/CFDADetailModal.jsx b/src/js/components/covid19/assistanceListing/CFDADetailModal.jsx index 3bb6f4ed59..2785d5a627 100644 --- a/src/js/components/covid19/assistanceListing/CFDADetailModal.jsx +++ b/src/js/components/covid19/assistanceListing/CFDADetailModal.jsx @@ -9,6 +9,7 @@ import Modal from 'react-aria-modal'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import ReadMore from 'components/sharedComponents/ReadMore'; import CFDAOpportunityTotals from 'containers/covid19/assistanceListing/CFDAOpportunityTotals'; +import { sanitizeUrl } from '../../../helpers/url'; const propTypes = { mounted: PropTypes.bool, @@ -69,7 +70,7 @@ const CFDADetailModal = ({
Program Website
- {data.cfda_website ? + {sanitizeUrl(data.cfda_website) ?
- {data.resource_link ? + {sanitizeUrl(data.resource_link) ? +const RedirectModal = ({ + mounted, + hideModal, + url +}) => { + const isSafe = sanitizeUrl(url); + + if (!isSafe) { + // modal shouldn't have shown. + // clean up and return + hideModal(); + return; + } + + return ( + +
+
+ +
+
+
+ + +
-
-
- - - -
-
-
-
+
+
+
You're leaving a Federal Government website. -
-
+
+
You have selected to visit: -
- -
+
+ +
You're going to a website that is not managed or controlled by the Federal Government. Its privacy policies may differ from ours. -
-
-
+
+
+
- - ); - } +
+ + ); } RedirectModal.propTypes = propTypes; +export default RedirectModal; diff --git a/src/js/containers/globalModal/GlobalModalContainer.jsx b/src/js/containers/globalModal/GlobalModalContainer.jsx index 5ebf9106f2..6454911b9b 100644 --- a/src/js/containers/globalModal/GlobalModalContainer.jsx +++ b/src/js/containers/globalModal/GlobalModalContainer.jsx @@ -4,85 +4,72 @@ */ import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; - +import { useDispatch, useSelector } from 'react-redux'; import { hideModal } from 'redux/actions/modal/modalActions'; import FilterModal from "components/sharedComponents/FilterModal"; import RedirectModal from 'components/sharedComponents/RedirectModal'; import InterimDataDisclaimerModal from 'components/covid19/InterimDataDisclaimerModal'; import CovidModalContainer from 'containers/covid19/CovidModalContainer'; -import TrainingVideoModal from "../../components/sharedComponents/TrainingVideoModal"; -import { globalModalProps } from '../../propTypes'; -import UEIModalContainer from "../shared/UEIModalContainer"; +import TrainingVideoModal from "components/sharedComponents/TrainingVideoModal"; +import UEIModalContainer from "containers/shared/UEIModalContainer"; + -const propTypes = { - globalModal: globalModalProps, - hideModal: PropTypes.func -}; -export class GlobalModalContainer extends React.Component { - render() { - if (this.props.globalModal.modal === "redirect") { - return ( - - ); - } - if (this.props.globalModal.modal === "covid") { - return ( - - ); - } - if (this.props.globalModal.modal === "covid-data-disclaimer") { - return ( - - ); - } - if (this.props.globalModal.modal === "uei") { - return ( - - ); - } - if (this.props.globalModal.modal === "training-videos") { - return ( - - ); - } +const GlobalModalContainer = () => { + const globalModal = useSelector((state) => state.modal); + const dispatch = useDispatch(); + + if (globalModal.modal === "redirect") { + return ( + + ); + } + if (globalModal.modal === "covid") { + return ( + + ); + } + if (globalModal.modal === "covid-data-disclaimer") { + return ( + + ); + } + if (globalModal.modal === "uei") { + return ( + + ); + } + if (globalModal.modal === "training-videos") { + return ( + + ); + } - if (this.props.globalModal.modal === "filter") { - return ( - - ); - } - return null; + if (globalModal.modal === "filter") { + return ( + + ); } + return null; } -GlobalModalContainer.propTypes = propTypes; +export default GlobalModalContainer; -export default connect( - (state) => ({ - globalModal: state.modal - }), - (dispatch) => ({ - hideModal: () => dispatch(hideModal()) - }) -)(GlobalModalContainer); diff --git a/src/js/helpers/redirectHelper.js b/src/js/helpers/redirectHelper.js index 093835f022..a7578da5c4 100644 --- a/src/js/helpers/redirectHelper.js +++ b/src/js/helpers/redirectHelper.js @@ -5,8 +5,12 @@ import storeSingleton from 'redux/storeSingleton'; import * as redirectModalActions from 'redux/actions/modal/modalActions'; +import { sanitizeUrl } from './url'; // eslint-disable-next-line import/prefer-default-export export const showRedirectModal = (url) => { - storeSingleton.store.dispatch(redirectModalActions.showModal(url)); + const safeUrl = sanitizeUrl(url); + if (safeUrl) { + storeSingleton.store.dispatch(redirectModalActions.showModal(safeUrl)); + } }; From 61e493db75de84161a94229fa4879bd032a976db Mon Sep 17 00:00:00 2001 From: "J.D. House" Date: Thu, 9 Jul 2026 14:16:35 -0500 Subject: [PATCH 5/6] add unit test --- tests/helpers/urlHelper-test.js | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/helpers/urlHelper-test.js diff --git a/tests/helpers/urlHelper-test.js b/tests/helpers/urlHelper-test.js new file mode 100644 index 0000000000..7f6da62700 --- /dev/null +++ b/tests/helpers/urlHelper-test.js @@ -0,0 +1,53 @@ +/** + * @jest-environment jsdom + * + * urlHelper-test.js + * Created by JD House 7/7/2026 +*/ + +import { sanitizeUrl } from "../../src/js/helpers/url"; + + +describe('sanitizeUrl()', () => { + it('returns a normalized https URL unchanged', () => { + expect(sanitizeUrl('https://api.usaspending.gov/')) + .toBe('https://api.usaspending.gov/'); + }); + + it('returns a normalized https URL unchanged', () => { + expect(sanitizeUrl('https://api.usaspending.gov/docs/intro-tutorial')) + .toBe('https://api.usaspending.gov/docs/intro-tutorial'); + }); + + it('strips leading / trailing whitespace', () => { + expect(sanitizeUrl(' https://api.usaspending.gov/ ')) + .toBe('https://api.usaspending.gov/'); + }); + + it('strips invisible unicode characters', () => { + expect(sanitizeUrl("\u200Bhttps://api.usaspending.gov/\u200B")) + .toBe('https://api.usaspending.gov/'); + }); + + it('removes common known open-direct attack query params', () => { + expect(sanitizeUrl("https://api.usaspending.gov/?redirect=http://evil-route.com")) + .toBe('https://api.usaspending.gov/'); + }); + + it('removes multiple common known open-direct attack query params', () => { + expect(sanitizeUrl("https://api.usaspending.gov/?redirect=http://evil-route.com/?url=/moreEvil&safe=yes")) + .toBe('https://api.usaspending.gov/?safe=yes'); + }); + + // should return null + + it('blocks non https url', () => { + expect(sanitizeUrl("http://test-https.com")) + .toBeNull(); + }) + + it('blocks non javascript: URLs', () => { + expect(sanitizeUrl("javascript:alert(1")) + .toBeNull(); + }) +}); From 53d67e27620e7b100149bc812a3cc511a80598ec Mon Sep 17 00:00:00 2001 From: "J.D. House" Date: Thu, 9 Jul 2026 15:24:08 -0500 Subject: [PATCH 6/6] fix lint --- src/_scss/pages/data-sources/index.scss | 2 +- src/_scss/pages/interactiveDataSources/index.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_scss/pages/data-sources/index.scss b/src/_scss/pages/data-sources/index.scss index 4e7e3183df..b59c08ef38 100644 --- a/src/_scss/pages/data-sources/index.scss +++ b/src/_scss/pages/data-sources/index.scss @@ -89,4 +89,4 @@ } @import "../../layouts/default/header/warning"; -} \ No newline at end of file +} diff --git a/src/_scss/pages/interactiveDataSources/index.scss b/src/_scss/pages/interactiveDataSources/index.scss index 556361da92..153c3e8b4d 100644 --- a/src/_scss/pages/interactiveDataSources/index.scss +++ b/src/_scss/pages/interactiveDataSources/index.scss @@ -79,7 +79,7 @@ overflow-x: auto; } } - + .body-padded__content { @import "./_scrollerOverlayCard.scss"; margin-top: 0;