Created
April 27, 2023 19:43
-
-
Save tsibley/4922b5fde8d8b7010b3271782173617d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
From c04b4e79916b73706deed209600dcee9ee1694b4 Mon Sep 17 00:00:00 2001 | |
From: Thomas Sibley <[email protected]> | |
Date: Thu, 27 Apr 2023 12:13:17 -0700 | |
Subject: [PATCH] Use "href" template literals for safe-by-construction URL | |
strings | |
Avoids having to write out encodeURIComponent(), which is quite a | |
mouthful! | |
--- | |
src/app.js | 7 +++---- | |
src/endpoints/cli.js | 7 ++++--- | |
src/href.js | 13 +++++++++++++ | |
.../src/components/Groups/edit-logo-form.jsx | 7 ++++--- | |
.../src/components/Groups/edit-overview-form.jsx | 5 +++-- | |
static-site/src/sections/group-settings-page.jsx | 5 +++-- | |
static-site/src/sections/individual-group-page.jsx | 5 +++-- | |
7 files changed, 33 insertions(+), 16 deletions(-) | |
create mode 100644 src/href.js | |
diff --git a/src/app.js b/src/app.js | |
index 0da9337..215ea24 100644 | |
--- a/src/app.js | |
+++ b/src/app.js | |
@@ -28,6 +28,7 @@ import { replacer as jsonReplacer } from './json.js'; | |
import * as middleware from './middleware.js'; | |
import * as redirects from './redirects.js'; | |
import * as sources from './sources/index.js'; | |
+import href from './href.js'; | |
const { | |
setSource, | |
@@ -60,8 +61,6 @@ const { | |
GroupSource, | |
} = sources; | |
-const esc = encodeURIComponent; | |
- | |
const jsonMediaType = type => type.match(/^application\/(.+\+)?json$/); | |
@@ -306,7 +305,7 @@ app.use("/groups/:groupName", | |
const restOfUrl = req.url !== "/" ? req.url : ""; | |
const canonicalName = req.context.source.group.name; | |
- const canonicalUrl = `/groups/${esc(canonicalName)}${restOfUrl}`; | |
+ const canonicalUrl = href`/groups/${canonicalName}` + restOfUrl; | |
return req.params.groupName !== canonicalName | |
? res.redirect(canonicalUrl) | |
@@ -345,7 +344,7 @@ app.route("/groups/:groupName/settings/*") | |
// Avoid matching "narratives" as a dataset name. | |
app.routeAsync("/groups/:groupName/narratives") | |
- .getAsync((req, res) => res.redirect(`/groups/${esc(req.params.groupName)}`)); | |
+ .getAsync((req, res) => res.redirect(href`/groups/${req.params.groupName}`)); | |
app.routeAsync("/groups/:groupName/narratives/*") | |
.all(setNarrative(req => req.params[0])) | |
diff --git a/src/endpoints/cli.js b/src/endpoints/cli.js | |
index f2ae96c..e83fd6d 100644 | |
--- a/src/endpoints/cli.js | |
+++ b/src/endpoints/cli.js | |
@@ -4,6 +4,7 @@ import mime from 'mime'; | |
import { pipeline } from 'stream/promises'; | |
import { BadRequest, InternalServerError, NotFound, ServiceUnavailable } from '../httpErrors.js'; | |
import { fetch } from '../fetch.js'; | |
+import href from '../href.js'; | |
const authorization = process.env.GITHUB_TOKEN | |
@@ -18,7 +19,7 @@ const download = async (req, res) => { | |
const endpoint = version === "latest" | |
? "https://api.github.com/repos/nextstrain/cli/releases/latest" | |
- : `https://api.github.com/repos/nextstrain/cli/releases/tags/${encodeURIComponent(version)}`; | |
+ : href`https://api.github.com/repos/nextstrain/cli/releases/tags/${version}`; | |
const response = await fetch(endpoint, {headers: {authorization}}); | |
assertStatusOk(response); | |
@@ -44,7 +45,7 @@ const downloadPRBuild = async (req, res) => { | |
* associated with it. Not all PR-associated workflow runs contain entries | |
* in the run's "pull_request" array. | |
*/ | |
- const prResponse = await fetch(`https://api.github.com/repos/nextstrain/cli/pulls/${encodeURIComponent(prId)}`, {headers: {authorization}}); | |
+ const prResponse = await fetch(href`https://api.github.com/repos/nextstrain/cli/pulls/${prId}`, {headers: {authorization}}); | |
assertStatusOk(prResponse); | |
const pr = await prResponse.json(); | |
@@ -87,7 +88,7 @@ const downloadCIBuild = async (req, res) => { | |
const assetSuffix = req.params.assetSuffix; | |
if (!runId || !assetSuffix) throw new BadRequest(); | |
- const endpoint = `https://api.github.com/repos/nextstrain/cli/actions/runs/${encodeURIComponent(runId)}/artifacts`; | |
+ const endpoint = href`https://api.github.com/repos/nextstrain/cli/actions/runs/${runId}/artifacts`; | |
const apiResponse = await fetch(endpoint, {headers: {authorization}}); | |
assertStatusOk(apiResponse); | |
diff --git a/src/href.js b/src/href.js | |
new file mode 100644 | |
index 0000000..b5808fd | |
--- /dev/null | |
+++ b/src/href.js | |
@@ -0,0 +1,13 @@ | |
+/** | |
+ * Safe-by-construction URL strings via template literals. | |
+ * | |
+ * Interpolations in the template literal are automatically URI-encoded. | |
+ * | |
+ * @returns string | |
+ */ | |
+export default function href(literalParts, ...exprParts) { | |
+ return literalParts.slice(1).reduce( | |
+ (url, literalPart, idx) => `${url}${encodeURIComponent(exprParts[idx])}${literalPart}`, | |
+ literalParts[0] | |
+ ); | |
+} | |
diff --git a/static-site/src/components/Groups/edit-logo-form.jsx b/static-site/src/components/Groups/edit-logo-form.jsx | |
index 2004a0c..c118ee0 100644 | |
--- a/static-site/src/components/Groups/edit-logo-form.jsx | |
+++ b/static-site/src/components/Groups/edit-logo-form.jsx | |
@@ -1,4 +1,5 @@ | |
import React, { useEffect, useState } from "react"; | |
+import href from "../../../../src/href.js"; | |
import { MediumSpacer } from "../../layouts/generalComponents"; | |
import * as splashStyles from "../splash/styles"; | |
import { AvatarWithoutMargins, CenteredForm, InputButton, InputLabel} from "./styles"; | |
@@ -24,7 +25,7 @@ const EditLogoForm = ({ groupName, createErrorMessage, clearErrorMessage }) => { | |
const getGroupLogo = async () => { | |
clearErrorMessage(); | |
try { | |
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/logo`); | |
+ const response = await fetch(href`/groups/${groupName}/settings/logo`); | |
if (response.status === 404) return null; | |
if (response.ok) return URL.createObjectURL(await response.blob()); | |
createErrorMessage(response.statusText); | |
@@ -41,7 +42,7 @@ const EditLogoForm = ({ groupName, createErrorMessage, clearErrorMessage }) => { | |
setDeletionInProgress(true); | |
try { | |
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/logo`, {method: "DELETE"}); | |
+ const response = await fetch(href`/groups/${groupName}/settings/logo`, {method: "DELETE"}); | |
response.ok | |
? setLogo({ ...logo, current: null }) | |
: createErrorMessage(response.statusText); | |
@@ -59,7 +60,7 @@ const EditLogoForm = ({ groupName, createErrorMessage, clearErrorMessage }) => { | |
setUploadInProgress(true); | |
try { | |
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/logo`, { | |
+ const response = await fetch(href`/groups/${groupName}/settings/logo`, { | |
method: "PUT", | |
headers: { | |
"Content-Type": "image/png" | |
diff --git a/static-site/src/components/Groups/edit-overview-form.jsx b/static-site/src/components/Groups/edit-overview-form.jsx | |
index 37ace94..58bda73 100644 | |
--- a/static-site/src/components/Groups/edit-overview-form.jsx | |
+++ b/static-site/src/components/Groups/edit-overview-form.jsx | |
@@ -1,4 +1,5 @@ | |
import React, { useEffect, useState } from "react"; | |
+import href from "../../../../src/href.js"; | |
import * as splashStyles from "../splash/styles"; | |
import { CenteredForm, InputButton, TextArea } from "./styles"; | |
@@ -33,7 +34,7 @@ const EditOverviewForm = ({ groupName, createErrorMessage, clearErrorMessage }) | |
const getOverview = async () => { | |
clearErrorMessage(); | |
try { | |
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/overview`); | |
+ const response = await fetch(href`/groups/${groupName}/settings/overview`); | |
if (response.status === 404) return OVERVIEW_TEMPLATE; | |
if (response.ok) { | |
const currentOverview = await response.text(); | |
@@ -54,7 +55,7 @@ const EditOverviewForm = ({ groupName, createErrorMessage, clearErrorMessage }) | |
setUploadInProgress(true); | |
try { | |
- const response = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/overview`, { | |
+ const response = await fetch(href`/groups/${groupName}/settings/overview`, { | |
method: "PUT", | |
headers: { | |
"Content-Type": "text/markdown" | |
diff --git a/static-site/src/sections/group-settings-page.jsx b/static-site/src/sections/group-settings-page.jsx | |
index 83a352a..ae17348 100644 | |
--- a/static-site/src/sections/group-settings-page.jsx | |
+++ b/static-site/src/sections/group-settings-page.jsx | |
@@ -1,4 +1,5 @@ | |
import React, { useEffect, useState } from "react"; | |
+import href from "../../../src/href.js"; | |
import * as splashStyles from "../components/splash/styles"; | |
import { ErrorBanner } from "../components/splash/errorMessages"; | |
import GenericPage from "../layouts/generic-page"; | |
@@ -40,7 +41,7 @@ const EditGroupSettingsPage = ({ location, groupName }) => { | |
return ( | |
<GenericPage location={location}> | |
<FlexGridRight> | |
- <splashStyles.Button to={`/groups/${encodeURIComponent(groupName)}`}> | |
+ <splashStyles.Button to={href`/groups/${groupName}`}> | |
Return to "{groupName}" Page | |
</splashStyles.Button> | |
</FlexGridRight> | |
@@ -73,7 +74,7 @@ const EditGroupSettingsPage = ({ location, groupName }) => { | |
export const canUserEditGroupSettings = async (groupName) => { | |
try { | |
- const groupOverviewOptions = await fetch(`/groups/${encodeURIComponent(groupName)}/settings/overview`, { method: "OPTIONS" }); | |
+ const groupOverviewOptions = await fetch(href`/groups/${groupName}/settings/overview`, { method: "OPTIONS" }); | |
const allowedMethods = new Set(groupOverviewOptions.headers.get("Allow")?.split(/\s*,\s*/)); | |
const editMethods = ["PUT", "DELETE"]; | |
return editMethods.every((method) => allowedMethods.has(method)); | |
diff --git a/static-site/src/sections/individual-group-page.jsx b/static-site/src/sections/individual-group-page.jsx | |
index 3c82657..8d03a86 100644 | |
--- a/static-site/src/sections/individual-group-page.jsx | |
+++ b/static-site/src/sections/individual-group-page.jsx | |
@@ -1,5 +1,6 @@ | |
import React from "react"; | |
import ScrollableAnchor, { configureAnchors } from "react-scrollable-anchor"; | |
+import href from "../../../src/href.js"; | |
import { HugeSpacer, FlexGridRight } from "../layouts/generalComponents"; | |
import * as splashStyles from "../components/splash/styles"; | |
import DatasetSelect from "../components/Datasets/dataset-select"; | |
@@ -36,8 +37,8 @@ class Index extends React.Component { | |
const groupName = this.props["groupName"]; | |
try { | |
const [sourceInfo, availableData] = await Promise.all([ | |
- fetchAndParseJSON(`/charon/getSourceInfo?prefix=/groups/${encodeURIComponent(groupName)}/`), | |
- fetchAndParseJSON(`/charon/getAvailable?prefix=/groups/${encodeURIComponent(groupName)}/`) | |
+ fetchAndParseJSON(href`/charon/getSourceInfo?prefix=/groups/${groupName}/`), | |
+ fetchAndParseJSON(href`/charon/getAvailable?prefix=/groups/${groupName}/`) | |
]); | |
this.setState({ | |
-- | |
2.40.1 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment