diff --git a/eslint.config.js b/eslint.config.js index e508e8159..1c119faef 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', @@ -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', }], }, }, { diff --git a/packages/client/src/plugins/batch.ts b/packages/client/src/plugins/batch.ts index 3239a07b6..f1698a1f9 100644 --- a/packages/client/src/plugins/batch.ts +++ b/packages/client/src/plugins/batch.ts @@ -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' @@ -287,7 +287,7 @@ export class BatchLinkPlugin 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 ?? ''}` diff --git a/packages/node/src/static-file-handler-plugin.ts b/packages/node/src/static-file-handler-plugin.ts index 5a7748c8e..d5131431c 100644 --- a/packages/node/src/static-file-handler-plugin.ts +++ b/packages/node/src/static-file-handler-plugin.ts @@ -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' @@ -268,7 +268,7 @@ export class StaticFileHandlerPlugin 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 @@ -334,7 +334,7 @@ export class StaticFileHandlerPlugin 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, diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts index cc503207f..e550d5036 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -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 { @@ -205,14 +205,14 @@ export class OpenAPILinkCodec 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 { @@ -220,10 +220,10 @@ export class OpenAPILinkCodec implements StandardLinkCo 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)) } } } @@ -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) diff --git a/packages/openapi/src/adapters/standard/openapi-matcher.ts b/packages/openapi/src/adapters/standard/openapi-matcher.ts index 185f3fbf8..5aeaead04 100644 --- a/packages/openapi/src/adapters/standard/openapi-matcher.ts +++ b/packages/openapi/src/adapters/standard/openapi-matcher.ts @@ -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' @@ -214,5 +214,5 @@ function toRou3PrefixMatcher(path: `/${string}`): RegExp { } function decodeParams(params: Record): Record { - return Object.fromEntries(Object.entries(params).map(([key, val]) => [key, tryDecodeURIComponent(val)])) + return Object.fromEntries(Object.entries(params).map(([key, val]) => [key, safeDecodeURIComponent(val)])) } diff --git a/packages/shared/src/http.test.ts b/packages/shared/src/http.test.ts index 77fcb41ed..37adc75e3 100644 --- a/packages/shared/src/http.test.ts +++ b/packages/shared/src/http.test.ts @@ -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') }) diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index b037e89c0..0f1e9b035 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -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}` { @@ -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}` { diff --git a/packages/shared/src/uri.test.ts b/packages/shared/src/uri.test.ts index fc0f9b248..74529aef8 100644 --- a/packages/shared/src/uri.test.ts +++ b/packages/shared/src/uri.test.ts @@ -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 + }) }) diff --git a/packages/shared/src/uri.ts b/packages/shared/src/uri.ts index 448f05506..d0e2e6758 100644 --- a/packages/shared/src/uri.ts +++ b/packages/shared/src/uri.ts @@ -1,6 +1,29 @@ -export function tryDecodeURIComponent(value: string): string { +const LONE_SURROGATE_REGEX = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?