Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 38 additions & 43 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 @@ -74,14 +75,12 @@ 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) &&
typeof to === 'string' &&
to.indexOf(':') > -1 &&
isAbsoluteUrl(to)
) {
// Block dangerous protocols like javascript:, blob:, data:
if (isDangerousProtocol(to, protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
Expand All @@ -90,7 +89,7 @@ function resolveExternalLink(
return undefined
}
return to
} catch {}
}
return undefined
}

Expand Down Expand Up @@ -226,39 +225,35 @@ export function useLinkProps<
if (
typeof to === 'string' &&
!safeInternal &&
// Quick checks to avoid `new URL` in common internal-like cases
to.indexOf(':') > -1
// Quick checks to avoid URL parsing in common internal-like cases
to.indexOf(':') > -1 &&
isAbsoluteUrl(to)
) {
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 (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 +289,20 @@ 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 &&
typeof to === 'string' &&
to.indexOf(':') > -1 &&
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
15 changes: 15 additions & 0 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import { isServer } from '@tanstack/router-core/isServer'
import type { RouteIds } from './routeInfo'
import type { AnyRouter } from './router'

/** @internal */
export function isAbsoluteUrl(url: string): boolean {
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
14 changes: 8 additions & 6 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,11 +176,12 @@ 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) &&
typeof to === 'string' &&
to.indexOf(':') > -1 &&
isAbsoluteUrl(to)
) {
// Block dangerous protocols like javascript:, blob:, data:
if (isDangerousProtocol(to, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
Expand All @@ -188,7 +190,7 @@ export function useLinkProps<
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