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: 6 additions & 4 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ export default antfu({
name: ['*', 'bytes'],
message: 'Request/Blob/Response/... .bytes is not widely supported, use readAsBuffer instead',
},
{
name: 'decodeURIComponent',
message: 'decodeURIComponent can throw an error, use tryDecodeURIComponent instead',
},
{
name: ['AbortSignal', 'any'],
message: 'Use anyAbortSignal instead',
Expand Down Expand Up @@ -62,6 +58,12 @@ export default antfu({
}, {
name: 'AbortSignal',
message: 'AbortSignal is not a global in every runtime, read it only behind a typeof guard',
}, {
name: 'encodeURIComponent',
message: 'encodeURIComponent throws on lone surrogates, use safeEncodeURIComponent from @orpc/shared instead',
}, {
name: 'decodeURIComponent',
message: 'decodeURIComponent throws on malformed input, use safeDecodeURIComponent from @orpc/shared instead',
}],
},
}, {
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/plugins/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { StandardHeaders, StandardLazyResponse, StandardRequest, StandardUr
import type { ClientPeerSendMessage } from '@standard-server/peer'
import type { StandardLinkOptions, StandardLinkPlugin, StandardLinkTransportInterceptor, StandardLinkTransportInterceptorOptions } from '../adapters/standard'
import type { ClientContext } from '../types'
import { defer, isAsyncIteratorObject, loadBytes, once, splitInHalf, stringifyJSON, toArray, value } from '@orpc/shared'
import { defer, isAsyncIteratorObject, loadBytes, once, safeEncodeURIComponent, splitInHalf, stringifyJSON, toArray, value } from '@orpc/shared'
import { parseStandardUrl } from '@standard-server/core'
import { ClientPeer, decodePeerMessage, isServerPeerSendMessage } from '@standard-server/peer'

Expand Down Expand Up @@ -287,7 +287,7 @@ export class BatchLinkPlugin<T extends ClientContext> implements StandardLinkPlu

if (method === 'GET') {
const [pathname, search, hash] = parseStandardUrl(url)
const dataParam = `data=${encodeURIComponent(stringifyJSON(pendingMessages))}`
const dataParam = `data=${safeEncodeURIComponent(stringifyJSON(pendingMessages))}`
const newUrl: StandardUrl = search
? `${pathname}${search}&${dataParam}${hash ?? ''}`
: `${pathname}?${dataParam}${hash ?? ''}`
Expand Down
6 changes: 3 additions & 3 deletions packages/node/src/static-file-handler-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Stats } from 'node:fs'
import { createReadStream } from 'node:fs'
import { realpath, stat } from 'node:fs/promises'
import path from 'node:path'
import { getTracer, isCompressibleContentType, matchesHttpPathPrefix, mergeHttpPath, parseAcceptEncodingQualities, toArray, tryDecodeURIComponent } from '@orpc/shared'
import { getTracer, isCompressibleContentType, matchesHttpPathPrefix, mergeHttpPath, parseAcceptEncodingQualities, safeDecodeURIComponent, safeEncodeURIComponent, toArray } from '@orpc/shared'
import { flattenStandardHeader, parseStandardUrl } from '@standard-server/core'
import { toWebReadableStream } from '@standard-server/node'
import mime from 'mime'
Expand Down Expand Up @@ -268,7 +268,7 @@ export class StaticFileHandlerPlugin<T extends Context> implements StandardHandl
const segments: string[] = []

for (const rawSegment of pathname.slice(base.length).split('/')) {
const segment = rawSegment.includes('%') ? tryDecodeURIComponent(rawSegment) : rawSegment
const segment = safeDecodeURIComponent(rawSegment)

if (segment === '' || segment === '.') {
continue
Expand Down Expand Up @@ -334,7 +334,7 @@ export class StaticFileHandlerPlugin<T extends Context> implements StandardHandl
* so the location can never be protocol relative or carry dot segments.
* Redirected so relative links inside the index file resolve correctly.
*/
const location = `${base === '/' ? '' : base}${segments.map(segment => `/${encodeURIComponent(segment)}`).join('')}/`
const location = `${base === '/' ? '' : base}${segments.map(segment => `/${safeEncodeURIComponent(segment)}`).join('')}/`

return {
status: 301,
Expand Down
12 changes: 6 additions & 6 deletions packages/openapi/src/adapters/standard/openapi-link-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { OpenAPIMeta } from '../../meta'
import { createORPCErrorFromJson, createORPCErrorFromMalformedResponse, isORPCErrorJson } from '@orpc/client'
import { getRouterContract, ProcedureContract } from '@orpc/contract'
import { unlazy } from '@orpc/server'
import { isTypescriptObject, mergeHttpPath, pathToHttpPath, stringifyJSON, value } from '@orpc/shared'
import { isTypescriptObject, mergeHttpPath, pathToHttpPath, safeEncodeURIComponent, stringifyJSON, value } from '@orpc/shared'
import { mergeStandardHeaders, parseStandardUrl } from '@standard-server/core'
import { toStandardHeaders } from '@standard-server/fetch'
import {
Expand Down Expand Up @@ -205,25 +205,25 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
encoded = val
.map(val => this.serializer.serialize(val))
.filter(val => val !== undefined && val !== null)
.map(val => encodeURIComponent(String(val)))
.map(val => safeEncodeURIComponent(String(val)))
.join(',')
}
else if (style === 'comma-delimited-object' && isTypescriptObject(val)) {
encoded = Object.entries(val)
.map(([key, val]) => [key, this.serializer.serialize(val)])
.filter(([, val]) => val !== undefined && val !== null)
.map(([key, val]) => `${encodeURIComponent(String(key))},${encodeURIComponent(String(val))}`)
.map(([key, val]) => `${safeEncodeURIComponent(String(key))},${safeEncodeURIComponent(String(val))}`)
.join(',')
}
else {
const serialized = this.serializer.serialize(val)

if (serialized !== undefined && serialized !== null) {
if (param.allowsSlash) {
encoded = String(serialized).split('/').map(encodeURIComponent).join('/')
encoded = String(serialized).split('/').map(safeEncodeURIComponent).join('/')
}
else {
encoded = encodeURIComponent(String(serialized))
encoded = safeEncodeURIComponent(String(serialized))
}
}
}
Expand Down Expand Up @@ -482,7 +482,7 @@ function isValidDetailedInput(

/**
* Encode a query parameter value using URLSearchParams semantics.
* Prefer this over encodeURIComponent for query-string values.
* Prefer this over safeEncodeURIComponent for query-string values.
*/
function encodeURLSearchParamComponent(value: string): string {
return new URLSearchParams({ '': value }).toString().slice(1)
Expand Down
4 changes: 2 additions & 2 deletions packages/openapi/src/adapters/standard/openapi-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AnyProcedureContract } from '@orpc/contract'
import type { AnyProcedure, AnyRouter, WalkProcedureContractsLazyResult } from '@orpc/server'
import type { Value } from '@orpc/shared'
import { createContractProcedure, getRouter, Procedure, unlazy, walkProcedureContractsSync } from '@orpc/server'
import { mergeHttpPath, normalizeHttpPath, pathToHttpPath, tryDecodeURIComponent, value } from '@orpc/shared'
import { mergeHttpPath, normalizeHttpPath, pathToHttpPath, safeDecodeURIComponent, value } from '@orpc/shared'
import { addRoute, createRouter, findRoute, routeToRegExp } from 'rou3'
import { DEFAULT_OPENAPI_METHOD } from '../../constants'
import { getOpenAPIMeta } from '../../meta'
Expand Down Expand Up @@ -214,5 +214,5 @@ function toRou3PrefixMatcher(path: `/${string}`): RegExp {
}

function decodeParams(params: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(params).map(([key, val]) => [key, tryDecodeURIComponent(val)]))
return Object.fromEntries(Object.entries(params).map(([key, val]) => [key, safeDecodeURIComponent(val)]))
}
2 changes: 1 addition & 1 deletion packages/shared/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('normalizeHttpPath', () => {
})

it('handles malformed percent sequences gracefully', () => {
// tryDecodeURIComponent falls back to the raw string on failure
// safeDecodeURIComponent falls back to the raw string on failure
expect(normalizeHttpPath('/bad%GGvalue')).toBe('/bad%25GGvalue')
})

Expand Down
6 changes: 3 additions & 3 deletions packages/shared/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { tryDecodeURIComponent } from './uri'
import { safeDecodeURIComponent, safeEncodeURIComponent } from './uri'

export function pathToHttpPath(path: readonly string[]): `/${string}` {
return `/${path.map(encodeURIComponent).join('/')}`
return `/${path.map(safeEncodeURIComponent).join('/')}`
}

export function normalizeHttpPath(path: string): `/${string}` {
Expand All @@ -11,7 +11,7 @@ export function normalizeHttpPath(path: string): `/${string}` {
paths.shift()
}

return pathToHttpPath(paths.map(tryDecodeURIComponent))
return pathToHttpPath(paths.map(safeDecodeURIComponent))
}

export function mergeHttpPath(a: `/${string}`, b: `/${string}`): `/${string}` {
Expand Down
66 changes: 58 additions & 8 deletions packages/shared/src/uri.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,59 @@
import { tryDecodeURIComponent } from './uri'

it('tryDecodeURIComponent', () => {
expect(tryDecodeURIComponent('test')).toBe('test')
expect(tryDecodeURIComponent('test%20value')).toBe('test value')
expect(tryDecodeURIComponent('invalid%20value%')).toBe('invalid%20value%')
expect(tryDecodeURIComponent('%E0%A4%A')).toBe('%E0%A4%A') // Invalid UTF-8 sequence
expect(tryDecodeURIComponent('')).toBe('')
import { safeDecodeURIComponent, safeEncodeURIComponent } from './uri'

describe('safeEncodeURIComponent', () => {
it('matches encodeURIComponent for well-formed input', () => {
for (const value of ['', 'test', 'a b', '/?#&=+', 'a-_.!~*\'()b', 'xin chào thế giới', '😀', '😀']) {
expect(safeEncodeURIComponent(value)).toBe(encodeURIComponent(value))
}
})

it('matches encodeURIComponent for every BMP code point outside the surrogate range', () => {
for (let code = 0; code <= 0xFFFF; code++) {
if (code >= 0xD800 && code <= 0xDFFF) {
continue
}

const value = String.fromCharCode(code)
expect(safeEncodeURIComponent(value)).toBe(encodeURIComponent(value))
}
})

it('encodes lone surrogates as U+FFFD instead of throwing', () => {
expect(() => encodeURIComponent('\uD800')).toThrow(URIError)

expect(safeEncodeURIComponent('\uD800')).toBe('%EF%BF%BD')
expect(safeEncodeURIComponent('\uDC00')).toBe('%EF%BF%BD')
expect(safeEncodeURIComponent('a\uD800b')).toBe('a%EF%BF%BDb')
expect(safeEncodeURIComponent('a\uDC00b')).toBe('a%EF%BF%BDb')
// a valid pair next to a lone surrogate stays intact
expect(safeEncodeURIComponent('😀\uD83D')).toBe('%F0%9F%98%80%EF%BF%BD')
expect(safeEncodeURIComponent('\uDE00😀')).toBe('%EF%BF%BD%F0%9F%98%80')
expect(safeEncodeURIComponent('\uDC00\uD800')).toBe('%EF%BF%BD%EF%BF%BD')
})
})

describe('safeDecodeURIComponent', () => {
it('decodes valid input', () => {
expect(safeDecodeURIComponent('test%20value')).toBe('test value')
expect(safeDecodeURIComponent('a%2Fb')).toBe('a/b')
expect(safeDecodeURIComponent('%E2%9C%93')).toBe('✓')
expect(safeDecodeURIComponent('%F0%9F%98%80')).toBe('😀')
})

it('returns input without a percent sign as is', () => {
for (const value of ['', 'test', 'xin chào', 'a+b']) {
expect(safeDecodeURIComponent(value)).toBe(value)
}
})

it('returns malformed input unchanged instead of throwing', () => {
expect(() => decodeURIComponent('%')).toThrow(URIError)

expect(safeDecodeURIComponent('%')).toBe('%')
expect(safeDecodeURIComponent('%GG')).toBe('%GG')
expect(safeDecodeURIComponent('invalid%20value%')).toBe('invalid%20value%')
expect(safeDecodeURIComponent('%E0%A4%A')).toBe('%E0%A4%A') // truncated UTF-8 sequence
expect(safeDecodeURIComponent('%FF')).toBe('%FF') // invalid UTF-8 byte
expect(safeDecodeURIComponent('%ED%A0%80')).toBe('%ED%A0%80') // UTF-8 encoded surrogate
})
})
27 changes: 25 additions & 2 deletions packages/shared/src/uri.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
export function tryDecodeURIComponent(value: string): string {
const LONE_SURROGATE_REGEX = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g

/**
* `encodeURIComponent` that never throws, lone surrogates become U+FFFD.
*/
export function safeEncodeURIComponent(value: string): string {
try {
// eslint-disable-next-line ban/ban
// eslint-disable-next-line no-restricted-globals
return encodeURIComponent(value)
}
catch {
// eslint-disable-next-line no-restricted-globals
return encodeURIComponent(value.replace(LONE_SURROGATE_REGEX, '�'))
}
}

/**
* `decodeURIComponent` that never throws, malformed input is returned unchanged.
*/
export function safeDecodeURIComponent(value: string): string {
if (!value.includes('%')) {
return value
}

try {
// eslint-disable-next-line no-restricted-globals
return decodeURIComponent(value)
}
catch {
Expand Down
Loading