{pathname}
+{String(hasTrailingSlash)}
+ Go back home +{pathname}
+{String(hasTrailingSlash)}
+ Go back home +SPA Mode Enabled
+About page in SPA mode
+{JSON.stringify(useParams())};
@@ -209,4 +217,72 @@ describe("useParams", () => {
`);
});
});
+
+ test("maintains compatibility with generatePath", () => {
+ let tests = [
+ {
+ path: "/books/42",
+ url: "/books/42",
+ params: {},
+ },
+ {
+ path: "/books/:id",
+ url: "/books/42",
+ params: { id: "42" },
+ },
+ {
+ path: "/books/:id.json",
+ url: "/books/42.json",
+ params: { id: "42" },
+ },
+ {
+ path: "/books/:id/comments",
+ url: "/books/42/comments",
+ params: { id: "42" },
+ },
+ {
+ path: "/books/:id.json/comments",
+ url: "/books/42.json/comments",
+ params: { id: "42" },
+ },
+ ];
+
+ function ShowParamsAndPath({ path }: { path: string }) {
+ return (
+ <>
+ {JSON.stringify(useParams())}
+{useLocation().pathname}
+{generatePath(path, useParams())}
+ > + ); + } + + for (let { path, url, params } of tests) { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( ++ ${JSON.stringify(params)} +
, ++ ${url} +
, ++ ${url} +
, + ] + `); + } + }); }); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 249baffc01..17ac647117 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -349,7 +349,9 @@ export type { /** @internal */ export { + createMemoryHistory as UNSAFE_createMemoryHistory, createBrowserHistory as UNSAFE_createBrowserHistory, + createHashHistory as UNSAFE_createHashHistory, invariant as UNSAFE_invariant, } from "./lib/router/history"; diff --git a/packages/react-router/lib/actions.ts b/packages/react-router/lib/actions.ts new file mode 100644 index 0000000000..0118cbc2fb --- /dev/null +++ b/packages/react-router/lib/actions.ts @@ -0,0 +1,122 @@ +export function throwIfPotentialCSRFAttack( + headers: Headers, + allowedActionOrigins: string[] | undefined, +) { + let originHeader = headers.get("origin"); + let originDomain = + typeof originHeader === "string" && originHeader !== "null" + ? new URL(originHeader).host + : originHeader; + let host = parseHostHeader(headers); + + if (originDomain && (!host || originDomain !== host.value)) { + if (!isAllowedOrigin(originDomain, allowedActionOrigins)) { + if (host) { + // This seems to be an CSRF attack. We should not proceed with the action. + throw new Error( + `${host.type} header does not match \`origin\` header from a forwarded ` + + `action request. Aborting the action.`, + ); + } else { + // This is an attack. We should not proceed with the action. + throw new Error( + "`x-forwarded-host` or `host` headers are not provided. One of these " + + "is needed to compare the `origin` header from a forwarded action " + + "request. Aborting the action.", + ); + } + } + } +} + +// Implementation of micromatch by Next.js https://github.com/vercel/next.js/blob/ea927b583d24f42e538001bf13370e38c91d17bf/packages/next/src/server/app-render/csrf-protection.ts#L6 +function matchWildcardDomain(domain: string, pattern: string) { + const domainParts = domain.split("."); + const patternParts = pattern.split("."); + + if (patternParts.length < 1) { + // pattern is empty and therefore invalid to match against + return false; + } + + if (domainParts.length < patternParts.length) { + // domain has too few segments and thus cannot match + return false; + } + + // Prevent wildcards from matching entire domains (e.g. '**' or '*.com') + // This ensures wildcards can only match subdomains, not the main domain + if ( + patternParts.length === 1 && + (patternParts[0] === "*" || patternParts[0] === "**") + ) { + return false; + } + + while (patternParts.length) { + const patternPart = patternParts.pop(); + const domainPart = domainParts.pop(); + + switch (patternPart) { + case "": { + // invalid pattern. pattern segments must be non empty + return false; + } + case "*": { + // wildcard matches anything so we continue if the domain part is non-empty + if (domainPart) { + continue; + } else { + return false; + } + } + case "**": { + // if this is not the last item in the pattern the pattern is invalid + if (patternParts.length > 0) { + return false; + } + // recursive wildcard matches anything so we terminate here if the domain part is non empty + return domainPart !== undefined; + } + case undefined: + default: { + if (domainPart !== patternPart) { + return false; + } + } + } + } + + // We exhausted the pattern. If we also exhausted the domain we have a match + return domainParts.length === 0; +} + +function isAllowedOrigin( + originDomain: string, + allowedActionOrigins: string[] | undefined = [], +) { + return allowedActionOrigins.some( + (allowedOrigin) => + allowedOrigin && + (allowedOrigin === originDomain || + matchWildcardDomain(originDomain, allowedOrigin)), + ); +} + +function parseHostHeader(headers: Headers) { + let forwardedHostHeader = headers.get("x-forwarded-host"); + let forwardedHostValue = forwardedHostHeader?.split(",")[0]?.trim(); + let hostHeader = headers.get("host"); + + return forwardedHostValue + ? { + type: "x-forwarded-host", + value: forwardedHostValue, + } + : hostHeader + ? { + type: "host", + value: hostHeader, + } + : undefined; +} diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 19c2c1c691..e2dc320796 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -186,6 +186,7 @@ function createHydratedRouter({ ssrInfo.routeModules, ssrInfo.context.ssr, ssrInfo.context.basename, + ssrInfo.context.future.unstable_trailingSlashAwareDataRequests, ), patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( ssrInfo.manifest, diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 4c8a514d37..39bc64675f 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -97,6 +97,7 @@ import { } from "../hooks"; import type { SerializeFrom } from "../types/route-data"; import type { unstable_ClientInstrumentation } from "../router/instrumentation"; +import { escapeHtml } from "./ssr/markup"; //////////////////////////////////////////////////////////////////////////////// //#region Global Stuff @@ -2033,9 +2034,9 @@ export function ScrollRestoration({ {...props} suppressHydrationWarning dangerouslySetInnerHTML={{ - __html: `(${restoreScroll})(${JSON.stringify( - storageKey || SCROLL_RESTORATION_STORAGE_KEY, - )}, ${JSON.stringify(ssrKey)})`, + __html: `(${restoreScroll})(${escapeHtml( + JSON.stringify(storageKey || SCROLL_RESTORATION_STORAGE_KEY), + )}, ${escapeHtml(JSON.stringify(ssrKey))})`, }} /> ); diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index 7653755af3..b054950bc2 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -35,6 +35,7 @@ import { ViewTransitionContext, } from "../context"; import { useRoutesImpl } from "../hooks"; +import { escapeHtml } from "./ssr/markup"; /** * @category Types @@ -187,7 +188,7 @@ export function StaticRouterProvider({ // up parsing on the client. Dual-stringify is needed to ensure all quotes // are properly escaped in the resulting string. See: // https://v8.dev/blog/cost-of-javascript-2019#json - let json = htmlEscape(JSON.stringify(JSON.stringify(data))); + let json = escapeHtml(JSON.stringify(JSON.stringify(data))); hydrateScript = `window.__staticRouterHydrationData = JSON.parse(${json});`; } @@ -520,19 +521,3 @@ function encodeLocation(to: To): Path { } const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; - -// This utility is based on https://github.com/zertosh/htmlescape -// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE -const ESCAPE_LOOKUP: { [match: string]: string } = { - "&": "\\u0026", - ">": "\\u003e", - "<": "\\u003c", - "\u2028": "\\u2028", - "\u2029": "\\u2029", -}; - -const ESCAPE_REGEX = /[&><\u2028\u2029]/g; - -function htmlEscape(str: string): string { - return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); -} diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 57edd93007..48fb544fb3 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -371,7 +371,7 @@ function PrefetchPageLinksImpl({ matches: AgnosticDataRouteMatch[]; }) { let location = useLocation(); - let { manifest, routeModules } = useFrameworkContext(); + let { future, manifest, routeModules } = useFrameworkContext(); let { basename } = useDataRouterContext(); let { loaderData, matches } = useDataRouterStateContext(); @@ -435,7 +435,12 @@ function PrefetchPageLinksImpl({ return []; } - let url = singleFetchUrl(page, basename, "data"); + let url = singleFetchUrl( + page, + basename, + future.unstable_trailingSlashAwareDataRequests, + "data", + ); // When one or more routes have opted out, we add a _routes param to // limit the loaders to those that have a server loader and did not // opt out @@ -452,6 +457,7 @@ function PrefetchPageLinksImpl({ return [url.pathname + url.search]; }, [ basename, + future.unstable_trailingSlashAwareDataRequests, loaderData, location, manifest, @@ -901,6 +907,7 @@ import(${JSON.stringify(manifest.entry.module)});`; <> {typeof manifest.sri === "object" ? (