diff --git a/.changeset/pink-candies-attack.md b/.changeset/pink-candies-attack.md new file mode 100644 index 00000000000..401860fcccc --- /dev/null +++ b/.changeset/pink-candies-attack.md @@ -0,0 +1,10 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +'@tanstack/start-plugin-core': patch +'@tanstack/nitro-v2-vite-plugin': patch +--- + +Use URL.canParse for absolute URL checks in links, navigation, redirects, and build configuration. Preserve a URL constructor fallback for older browsers. diff --git a/packages/nitro-v2-vite-plugin/src/index.ts b/packages/nitro-v2-vite-plugin/src/index.ts index d8ae5009de1..9adf8d8577c 100644 --- a/packages/nitro-v2-vite-plugin/src/index.ts +++ b/packages/nitro-v2-vite-plugin/src/index.ts @@ -7,15 +7,6 @@ import type { NitroConfig } from 'nitropack' let ssrBundle: Rollup.OutputBundle let ssrEntryFile: string -function isFullUrl(str: string): boolean { - try { - new URL(str) - return true - } catch { - return false - } -} - export function nitroV2Plugin(nitroConfig?: NitroConfig): Array { let resolvedConfig: ResolvedConfig return [ @@ -94,7 +85,7 @@ export function nitroV2Plugin(nitroConfig?: NitroConfig): Array { await builder.build(server) const virtualEntry = '#tanstack/start/entry' - const baseURL = !isFullUrl(resolvedConfig.base) + const baseURL = !URL.canParse(resolvedConfig.base) ? resolvedConfig.base : undefined const config: NitroConfig = { diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index a0d69689867..0d99dd7a1ed 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -7,6 +7,7 @@ import { exactPathTest, functionalUpdate, hasKeys, + isAbsoluteUrl, isDangerousProtocol, preloadWarning, removeTrailingSlash, @@ -61,7 +62,7 @@ function compareLinkState(a: LinkState, b: LinkState) { function resolveExternalLink( hrefOption: { href: string; external?: boolean } | undefined, - to: unknown, + to: string | undefined, protocolAllowlist: AnyRouter['protocolAllowlist'], ): string | undefined { if (hrefOption?.external) { @@ -74,23 +75,16 @@ function resolveExternalLink( } return hrefOption.href } - if (isSafeInternal(to)) { - return undefined - } - if (typeof to !== 'string' || to.indexOf(':') === -1) { - return undefined - } - try { - new URL(to) + if (!isSafeInternal(to) && isAbsoluteUrl(to)) { // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(to, protocolAllowlist)) { + if (isDangerousProtocol(to!, protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } return undefined } return to - } catch {} + } return undefined } @@ -170,7 +164,7 @@ export function useLinkProps< activeProps, inactiveProps, activeOptions, - to, + to: toOption, preload: userPreload, preloadDelay: userPreloadDelay, preloadIntentProximity: _preloadIntentProximity, @@ -204,6 +198,7 @@ export function useLinkProps< _fromLocation, ...propsSafeToSpread } = options + const to = toOption as string | undefined // ========================================================================== // SERVER EARLY RETURN @@ -223,42 +218,32 @@ export function useLinkProps< // If `to` is obviously an absolute URL, treat as external and avoid // computing the internal location via `buildLocation`. - if ( - typeof to === 'string' && - !safeInternal && - // Quick checks to avoid `new URL` in common internal-like cases - to.indexOf(':') > -1 - ) { - try { - new URL(to) - if (isDangerousProtocol(to, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${to}`) - } - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - href: undefined, - ...(children && { children }), - ...(target && { target }), - ...(disabled && { disabled }), - ...(style && { style }), - ...(className && { className }), - } + if (!safeInternal && isAbsoluteUrl(to)) { + if (isDangerousProtocol(to!, router.protocolAllowlist)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${to}`) } - return { ...propsSafeToSpread, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - href: to, + href: undefined, ...(children && { children }), ...(target && { target }), ...(disabled && { disabled }), ...(style && { style }), ...(className && { className }), } - } catch { - // Not an absolute URL + } + + return { + ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + href: to, + ...(children && { children }), + ...(target && { target }), + ...(disabled && { disabled }), + ...(style && { style }), + ...(className && { className }), } } @@ -294,20 +279,14 @@ export function useLinkProps< return hrefOption.href } - if (safeInternal) return undefined - - // Only attempt URL parsing when it looks like an absolute URL. - if (typeof to === 'string' && to.indexOf(':') > -1) { - try { - new URL(to) - if (isDangerousProtocol(to, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${to}`) - } - return undefined + if (!safeInternal && isAbsoluteUrl(to)) { + if (isDangerousProtocol(to!, router.protocolAllowlist)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${to}`) } - return to - } catch {} + return undefined + } + return to } return undefined diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 617f7143483..19b2cc7b602 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -320,6 +320,7 @@ export { DEFAULT_PROTOCOL_ALLOWLIST, escapeHtml, isDangerousProtocol, + isAbsoluteUrl, buildDevStylesUrl, } from './utils' export type { diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts index 4485bc8adf9..a9c869b8f75 100644 --- a/packages/router-core/src/redirect.ts +++ b/packages/router-core/src/redirect.ts @@ -1,3 +1,4 @@ +import { isAbsoluteUrl } from './utils' import type { NavigateOptions } from './link' import type { AnyRouter, RegisteredRouter } from './router' @@ -110,11 +111,12 @@ export function redirect< ): Redirect { opts.statusCode = opts.statusCode || opts.code || 307 - if (!opts.reloadDocument && typeof opts.href === 'string') { - try { - new URL(opts.href) - opts.reloadDocument = true - } catch {} + if ( + !opts.reloadDocument && + typeof opts.href === 'string' && + isAbsoluteUrl(opts.href) + ) { + opts.reloadDocument = true } const headers = new Headers(opts.headers) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index fa4168b9ce1..274353acd12 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -8,6 +8,7 @@ import { findLast, functionalUpdate, hasKeys, + isAbsoluteUrl, isDangerousProtocol, last, nullReplaceEqualDeep, @@ -2265,14 +2266,7 @@ export class RouterCore< publicHref, ...rest }) => { - let hrefIsUrl = false - - if (href) { - try { - new URL(`${href}`) - hrefIsUrl = true - } catch {} - } + const hrefIsUrl = !!href && isAbsoluteUrl(`${href}`) if (hrefIsUrl && !reloadDocument) { reloadDocument = true diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 997c661bb52..f4286d746e7 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -2,6 +2,22 @@ import { isServer } from '@tanstack/router-core/isServer' import type { RouteIds } from './routeInfo' import type { AnyRouter } from './router' +/** @internal */ +export function isAbsoluteUrl(url: string | undefined): boolean { + // Both URL APIs stringify undefined and reject it without a base URL. + if (URL.canParse) { + return URL.canParse(url!) + } + + // Older browsers do not support URL.canParse. + try { + new URL(url!) + return true + } catch { + return false + } +} + export type Awaitable = T | Promise export type NoInfer = [T][T extends any ? 0 : never] export type IsAny = 1 extends 0 & TValue diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index ffa17aa7fa0..1f70fa8af93 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -7,6 +7,7 @@ import { exactPathTest, functionalUpdate, hasKeys, + isAbsoluteUrl, isDangerousProtocol, preloadWarning, removeTrailingSlash, @@ -175,20 +176,16 @@ export function useLinkProps< return _href.href } const to = options.to - const safeInternal = isSafeInternal(to) - if (safeInternal) return undefined - if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined - try { - new URL(to as any) + if (!isSafeInternal(to) && isAbsoluteUrl(to)) { // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(to, router.protocolAllowlist)) { + if (isDangerousProtocol(to!, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } return undefined } return to - } catch {} + } return undefined }) diff --git a/packages/start-plugin-core/src/planning.ts b/packages/start-plugin-core/src/planning.ts index 102a6df93c0..11f25d14d3f 100644 --- a/packages/start-plugin-core/src/planning.ts +++ b/packages/start-plugin-core/src/planning.ts @@ -23,7 +23,7 @@ export interface ResolvedStartEntryPlan { export function normalizePublicBase(base: string | undefined): string { const resolvedBase = base ?? '/' - if (isFullUrl(resolvedBase)) { + if (URL.canParse(resolvedBase)) { return resolvedBase } @@ -38,7 +38,7 @@ export function deriveRouterBasepath(opts: { return opts.configuredBasepath } - if (isFullUrl(opts.publicBase)) { + if (URL.canParse(opts.publicBase)) { return '/' } @@ -140,12 +140,3 @@ export function resolveStartEntryPlan(opts: { }, } } - -function isFullUrl(str: string): boolean { - try { - new URL(str) - return true - } catch { - return false - } -} diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index e778b8a1b47..4c2a04e4894 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -3,6 +3,7 @@ import { deepEqual, exactPathTest, hasKeys, + isAbsoluteUrl, isDangerousProtocol, preloadWarning, removeTrailingSlash, @@ -116,12 +117,7 @@ function useLinkPropsImpl( // Determine if the link is external or internal const type = Vue.computed(() => { const options = getOptions() - try { - new URL(`${options.to}`) - return 'external' - } catch { - return 'internal' - } + return isAbsoluteUrl(`${options.to}`) ? 'external' : 'internal' }) const ref = Vue.ref(null)