Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/pink-candies-attack.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 1 addition & 10 deletions packages/nitro-v2-vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginOption> {
let resolvedConfig: ResolvedConfig
return [
Expand Down Expand Up @@ -94,7 +85,7 @@ export function nitroV2Plugin(nitroConfig?: NitroConfig): Array<PluginOption> {
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 = {
Expand Down
81 changes: 30 additions & 51 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
exactPathTest,
functionalUpdate,
hasKeys,
isAbsoluteUrl,
isDangerousProtocol,
preloadWarning,
removeTrailingSlash,
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}

Expand Down Expand Up @@ -170,7 +164,7 @@ export function useLinkProps<
activeProps,
inactiveProps,
activeOptions,
to,
to: toOption,
preload: userPreload,
preloadDelay: userPreloadDelay,
preloadIntentProximity: _preloadIntentProximity,
Expand Down Expand Up @@ -204,6 +198,7 @@ export function useLinkProps<
_fromLocation,
...propsSafeToSpread
} = options
const to = toOption as string | undefined

// ==========================================================================
// SERVER EARLY RETURN
Expand All @@ -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)
Comment thread
Sheraff marked this conversation as resolved.
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 }),
}
}

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ export {
DEFAULT_PROTOCOL_ALLOWLIST,
escapeHtml,
isDangerousProtocol,
isAbsoluteUrl,
buildDevStylesUrl,
} from './utils'
export type {
Expand Down
12 changes: 7 additions & 5 deletions packages/router-core/src/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isAbsoluteUrl } from './utils'
import type { NavigateOptions } from './link'
import type { AnyRouter, RegisteredRouter } from './router'

Expand Down Expand Up @@ -110,11 +111,12 @@ export function redirect<
): Redirect<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> {
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)
Expand Down
10 changes: 2 additions & 8 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
findLast,
functionalUpdate,
hasKeys,
isAbsoluteUrl,
isDangerousProtocol,
last,
nullReplaceEqualDeep,
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = T | Promise<T>
export type NoInfer<T> = [T][T extends any ? 0 : never]
export type IsAny<TValue, TYesResult, TNoResult = TValue> = 1 extends 0 & TValue
Expand Down
11 changes: 4 additions & 7 deletions packages/solid-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
exactPathTest,
functionalUpdate,
hasKeys,
isAbsoluteUrl,
isDangerousProtocol,
preloadWarning,
removeTrailingSlash,
Expand Down Expand Up @@ -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
})

Expand Down
13 changes: 2 additions & 11 deletions packages/start-plugin-core/src/planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -38,7 +38,7 @@ export function deriveRouterBasepath(opts: {
return opts.configuredBasepath
}

if (isFullUrl(opts.publicBase)) {
if (URL.canParse(opts.publicBase)) {
return '/'
}

Expand Down Expand Up @@ -140,12 +140,3 @@ export function resolveStartEntryPlan(opts: {
},
}
}

function isFullUrl(str: string): boolean {
try {
new URL(str)
return true
} catch {
return false
}
}
8 changes: 2 additions & 6 deletions packages/vue-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
deepEqual,
exactPathTest,
hasKeys,
isAbsoluteUrl,
isDangerousProtocol,
preloadWarning,
removeTrailingSlash,
Expand Down Expand Up @@ -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<Element | null>(null)
Expand Down
Loading