From 9bace3ed870c0c2737368b010d84e98737d3628b Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 7 Sep 2026 14:51:46 +0100 Subject: [PATCH 1/9] fix: url encode path parameters Refs INTER-2499 --- .changeset/encode-path-parameters.md | 5 ++ src/urlUtils.ts | 21 +++++- .../pathParamEncodingTests.spec.ts | 71 +++++++++++++++++++ tests/unit-tests/urlUtilsTests.spec.ts | 41 +++++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 .changeset/encode-path-parameters.md create mode 100644 tests/mocked-responses-tests/pathParamEncodingTests.spec.ts diff --git a/.changeset/encode-path-parameters.md b/.changeset/encode-path-parameters.md new file mode 100644 index 00000000..9090ca85 --- /dev/null +++ b/.changeset/encode-path-parameters.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/node-sdk': patch +--- + +URL-encode path parameters diff --git a/src/urlUtils.ts b/src/urlUtils.ts index e38ec6eb..71de871c 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -43,6 +43,23 @@ function serializeQueryStringParams(params: QueryStringParameters): string { return urlSearchParams.toString() } +/** + * Encodes a value so that it is confined to a single URL path segment. + * + * `encodeURIComponent` escapes `/`, `?`, `#` and `%`, but leaves `.` alone, which matters + * because the Server API does not URL-decode path parameters and event IDs contain a dot. + * A value of `.` or `..` is rejected, because `new URL()` drops such a segment even when the + * dots are encoded: https://url.spec.whatwg.org/#double-dot-path-segment + */ +function encodePathParam(placeholder: string, value: string): string { + if (value === '.' || value === '..') { + // TypeError to match the invalid-argument guards in `FingerprintServerApiClient` + throw new TypeError(`Invalid path parameter for ${placeholder}`) + } + + return encodeURIComponent(value) +} + function getServerApiUrl(region: Region): string { switch (region) { case Region.EU: @@ -67,7 +84,7 @@ export interface GetRequestPathOptions { } /** - * Formats a URL for the FingerprintJS server API by replacing placeholders and + * Formats a URL for the Fingerprint Server API by replacing placeholders and * appending query string parameters. * * @internal @@ -100,7 +117,7 @@ export function getRequestPath({ placeholders.forEach((placeholder, index) => { const param = pathParams?.[index] if (param !== undefined && param !== '') { - formattedPath = formattedPath.replace(`{${placeholder}}`, param) + formattedPath = formattedPath.replace(`{${placeholder}}`, encodePathParam(placeholder, param)) } else { throw new Error(`Missing path parameter for ${placeholder}`) } diff --git a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts new file mode 100644 index 00000000..93235d33 --- /dev/null +++ b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts @@ -0,0 +1,71 @@ +import { FingerprintServerApiClient, Region } from '../../src' +import { getIntegrationInfo } from '../../src/urlUtils' +import { describe, expect, it } from 'vitest' +import { mockFetch } from './mockFetch' + +/** + * A path parameter must never be able to change which endpoint or which host the SDK talks + * to, no matter what the caller passes in. + */ +describe('[Mocked response] Path parameter encoding', () => { + const apiKey = 'dummy_api_key' + const ii = `ii=${encodeURIComponent(getIntegrationInfo())}` + + const client = new FingerprintServerApiClient({ region: Region.EU, apiKey }) + + const emptyResponse = () => new Response(undefined, { headers: { 'content-length': '0' } }) + + const operations = [ + { + name: 'getEvent', + prefix: 'v4/events', + placeholder: 'event_id', + paramName: 'eventId', + call: (param: string) => client.getEvent(param), + }, + { + name: 'updateEvent', + prefix: 'v4/events', + placeholder: 'event_id', + paramName: 'eventId', + call: (param: string) => client.updateEvent(param, { suspect: true }), + }, + { + name: 'deleteVisitorData', + prefix: 'v4/visitors', + placeholder: 'visitor_id', + paramName: 'visitorId', + call: (param: string) => client.deleteVisitorData(param), + }, + ] as const + + describe.each(operations)('$name', ({ prefix, placeholder, paramName, call }) => { + it.each([ + ['../events', '..%2Fevents'], + ['/../../events', '%2F..%2F..%2Fevents'], + ['evil.com', 'evil.com'], + ['//evil.com', '%2F%2Fevil.com'], + ['https://evil.com', 'https%3A%2F%2Fevil.com'], + ])('requests a single path segment for %j', async (param, encoded) => { + mockFetch.mockReturnValue(Promise.resolve(emptyResponse())) + + await call(param).catch(() => undefined) + + const requestedUrl = mockFetch.mock.calls[0]?.[0] as string + expect(requestedUrl).toEqual(`https://eu.api.fpjs.io/${prefix}/${encoded}?${ii}`) + expect(new URL(requestedUrl).host).toEqual('eu.api.fpjs.io') + }) + + it.each(['.', '..'])('does not send a request for %j', async (param) => { + await expect(call(param)).rejects.toThrow(new TypeError(`Invalid path parameter for ${placeholder}`)) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('does not send a request for an empty parameter', async () => { + await expect(call('')).rejects.toThrow(new TypeError(`${paramName} is not set`)) + + expect(mockFetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index f281a13a..d8c2f9f1 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -197,3 +197,44 @@ describe('getRequestPath', () => { expect(actual).toEqual(expected) }) }) + +describe('path parameter encoding', () => { + const pathsWithParams = [ + { path: '/events/{event_id}', method: 'get', prefix: 'v4/events' }, + { path: '/visitors/{visitor_id}', method: 'delete', prefix: 'v4/visitors' }, + ] as const + + describe.each(pathsWithParams)('$path', ({ path, method, prefix }) => { + it.each([ + ['../events', '..%2Fevents'], + ['/../../events', '%2F..%2F..%2Fevents'], + ['evil.com', 'evil.com'], + ['//evil.com', '%2F%2Fevil.com'], + ['https://evil.com', 'https%3A%2F%2Fevil.com'], + ['a b#c?d', 'a%20b%23c%3Fd'], + ['%2e%2e', '%252e%252e'], + ['..%2fevents', '..%252fevents'], + ['..\\..', '..%5C..'], + ['$&', '%24%26'], + ['...', '...'], + ['1626550679751.cVc5Pm', '1626550679751.cVc5Pm'], + ])('keeps %j inside a single path segment', (param, encoded) => { + const actual = getRequestPath({ path, method, pathParams: [param], region: Region.Global }) + + expect(actual).toEqual(`https://api.fpjs.io/${prefix}/${encoded}?${ii}`) + expect(new URL(actual).host).toEqual('api.fpjs.io') + }) + + it.each(['.', '..'])('rejects the dot segment %j', (param) => { + expect(() => getRequestPath({ path, method, pathParams: [param], region: Region.Global })).toThrow( + /^Invalid path parameter for / + ) + }) + + it('rejects an empty parameter', () => { + expect(() => getRequestPath({ path, method, pathParams: [''], region: Region.Global })).toThrow( + /^Missing path parameter for / + ) + }) + }) +}) From 83e57e11a8e9d2fa229355f5d4f28988f88e2e81 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 7 Sep 2026 14:56:08 +0100 Subject: [PATCH 2/9] docs: describe the dot segment rejection in the changeset Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .changeset/encode-path-parameters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/encode-path-parameters.md b/.changeset/encode-path-parameters.md index 9090ca85..2a89b612 100644 --- a/.changeset/encode-path-parameters.md +++ b/.changeset/encode-path-parameters.md @@ -2,4 +2,4 @@ '@fingerprint/node-sdk': patch --- -URL-encode path parameters +URL-encode path parameters; reject "." and ".." path parameters with a TypeError. From b341d9eea28a3295e5218f9f10ce00f91f7a7521 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 7 Sep 2026 15:23:25 +0100 Subject: [PATCH 3/9] fix: coerce path parameters to primitive strings An untyped caller could pass a value that is not a string but stringifies to a dot segment, bypassing the rejection. --- src/urlUtils.ts | 12 ++++++--- tests/unit-tests/urlUtilsTests.spec.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/urlUtils.ts b/src/urlUtils.ts index 71de871c..b85c4bb6 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -115,12 +115,16 @@ export function getRequestPath({ // Step 2: Replace the placeholders with provided pathParams let formattedPath: string = `${apiVersion}${path}` placeholders.forEach((placeholder, index) => { - const param = pathParams?.[index] - if (param !== undefined && param !== '') { - formattedPath = formattedPath.replace(`{${placeholder}}`, encodePathParam(placeholder, param)) - } else { + // Coerce to a primitive before validating. An untyped caller can pass something that is + // not a string but stringifies to one, and it would pass a strict comparison. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- runtime validation + const param = String(pathParams?.[index] ?? '') + + if (param === '') { throw new Error(`Missing path parameter for ${placeholder}`) } + + formattedPath = formattedPath.replace(`{${placeholder}}`, encodePathParam(placeholder, param)) }) const queryStringParameters: QueryStringParameters = { diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index d8c2f9f1..d8764a9c 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -236,5 +236,40 @@ describe('path parameter encoding', () => { /^Missing path parameter for / ) }) + + // An untyped caller can pass a value that is not a string but stringifies to one. + describe('non-string parameters', () => { + const asPathParams = (param: unknown) => [param] as unknown as string[] + + it.each([ + ['a String object', new String('..')], + ['an object with a toString', { toString: () => '..' }], + ['an array', ['..']], + ])('rejects the dot segment from %s', (_, param) => { + expect(() => getRequestPath({ path, method, pathParams: asPathParams(param), region: Region.Global })).toThrow( + /^Invalid path parameter for / + ) + }) + + it.each([ + ['an empty String object', new String('')], + ['null', null], + ])('rejects %s as missing', (_, param) => { + expect(() => getRequestPath({ path, method, pathParams: asPathParams(param), region: Region.Global })).toThrow( + /^Missing path parameter for / + ) + }) + + it('encodes a stringified value that is not a dot segment', () => { + const actual = getRequestPath({ + path, + method, + pathParams: asPathParams(new String('../events')), + region: Region.Global, + }) + + expect(actual).toEqual(`https://api.fpjs.io/${prefix}/..%2Fevents?${ii}`) + }) + }) }) }) From 612035a7766c16fa9b8f10afad062d0192c39843 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 7 Sep 2026 15:36:29 +0100 Subject: [PATCH 4/9] fix: reject path parameters that cannot be encoded encodeURIComponent throws URIError on a lone surrogate, which escaped as a third error type at the public boundary. Also unifies the missing-parameter throw on TypeError. --- .changeset/encode-path-parameters.md | 2 +- src/urlUtils.ts | 19 +++++++++++-------- .../pathParamEncodingTests.spec.ts | 5 +++-- tests/unit-tests/urlUtilsTests.spec.ts | 16 +++++++++++++--- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.changeset/encode-path-parameters.md b/.changeset/encode-path-parameters.md index 2a89b612..33e99a88 100644 --- a/.changeset/encode-path-parameters.md +++ b/.changeset/encode-path-parameters.md @@ -2,4 +2,4 @@ '@fingerprint/node-sdk': patch --- -URL-encode path parameters; reject "." and ".." path parameters with a TypeError. +URL-encode path parameters, so an `eventId` or `visitorId` can no longer change which endpoint is requested. Do not pre-encode these values yourself, as they are now encoded for you. A parameter that is `.`, `..`, `null` or cannot be encoded is rejected with a `TypeError` instead of being sent. diff --git a/src/urlUtils.ts b/src/urlUtils.ts index b85c4bb6..ca216907 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -44,20 +44,23 @@ function serializeQueryStringParams(params: QueryStringParameters): string { } /** - * Encodes a value so that it is confined to a single URL path segment. + * Confines a value to a single URL path segment. `.` is deliberately left unencoded, because + * the Server API does not decode path parameters and valid event IDs contain a dot. * - * `encodeURIComponent` escapes `/`, `?`, `#` and `%`, but leaves `.` alone, which matters - * because the Server API does not URL-decode path parameters and event IDs contain a dot. - * A value of `.` or `..` is rejected, because `new URL()` drops such a segment even when the - * dots are encoded: https://url.spec.whatwg.org/#double-dot-path-segment + * A value of `.` or `..` is rejected: `new URL()` drops such a segment even when the dots are + * encoded, so it cannot be expressed. See https://url.spec.whatwg.org/#double-dot-path-segment */ function encodePathParam(placeholder: string, value: string): string { if (value === '.' || value === '..') { - // TypeError to match the invalid-argument guards in `FingerprintServerApiClient` throw new TypeError(`Invalid path parameter for ${placeholder}`) } - return encodeURIComponent(value) + try { + return encodeURIComponent(value) + } catch { + // `encodeURIComponent` throws `URIError` on a lone surrogate + throw new TypeError(`Invalid path parameter for ${placeholder}`) + } } function getServerApiUrl(region: Region): string { @@ -121,7 +124,7 @@ export function getRequestPath({ const param = String(pathParams?.[index] ?? '') if (param === '') { - throw new Error(`Missing path parameter for ${placeholder}`) + throw new TypeError(`Missing path parameter for ${placeholder}`) } formattedPath = formattedPath.replace(`{${placeholder}}`, encodePathParam(placeholder, param)) diff --git a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts index 93235d33..9b941a28 100644 --- a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts +++ b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts @@ -40,15 +40,16 @@ describe('[Mocked response] Path parameter encoding', () => { ] as const describe.each(operations)('$name', ({ prefix, placeholder, paramName, call }) => { + // The full encoding table lives in the unit tests; these are the cases INTER-2499 asks to + // be pinned at the wire level for every method. it.each([ ['../events', '..%2Fevents'], - ['/../../events', '%2F..%2F..%2Fevents'], ['evil.com', 'evil.com'], ['//evil.com', '%2F%2Fevil.com'], - ['https://evil.com', 'https%3A%2F%2Fevil.com'], ])('requests a single path segment for %j', async (param, encoded) => { mockFetch.mockReturnValue(Promise.resolve(emptyResponse())) + // `getEvent` rejects on the empty body; only the requested URL matters here. await call(param).catch(() => undefined) const requestedUrl = mockFetch.mock.calls[0]?.[0] as string diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index d8764a9c..4c17b5ff 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -200,11 +200,11 @@ describe('getRequestPath', () => { describe('path parameter encoding', () => { const pathsWithParams = [ - { path: '/events/{event_id}', method: 'get', prefix: 'v4/events' }, - { path: '/visitors/{visitor_id}', method: 'delete', prefix: 'v4/visitors' }, + { path: '/events/{event_id}', method: 'get', prefix: 'v4/events', placeholder: 'event_id' }, + { path: '/visitors/{visitor_id}', method: 'delete', prefix: 'v4/visitors', placeholder: 'visitor_id' }, ] as const - describe.each(pathsWithParams)('$path', ({ path, method, prefix }) => { + describe.each(pathsWithParams)('$path', ({ path, method, prefix, placeholder }) => { it.each([ ['../events', '..%2Fevents'], ['/../../events', '%2F..%2F..%2Fevents'], @@ -217,6 +217,9 @@ describe('path parameter encoding', () => { ['..\\..', '..%5C..'], ['$&', '%24%26'], ['...', '...'], + // Guards the assumption that a placeholder in a parameter cannot reach the next + // replacement, which would matter for a path with two placeholders. + ['{event_id}', '%7Bevent_id%7D'], ['1626550679751.cVc5Pm', '1626550679751.cVc5Pm'], ])('keeps %j inside a single path segment', (param, encoded) => { const actual = getRequestPath({ path, method, pathParams: [param], region: Region.Global }) @@ -237,6 +240,13 @@ describe('path parameter encoding', () => { ) }) + it('rejects a value that cannot be encoded', () => { + // A lone surrogate makes `encodeURIComponent` throw a `URIError` + expect(() => getRequestPath({ path, method, pathParams: ['\ud800'], region: Region.Global })).toThrow( + new TypeError(`Invalid path parameter for ${placeholder}`) + ) + }) + // An untyped caller can pass a value that is not a string but stringifies to one. describe('non-string parameters', () => { const asPathParams = (param: unknown) => [param] as unknown as string[] From 1db392831afe155fcdd9a78f7c7abcc4c21f36da Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 7 Sep 2026 16:19:16 +0100 Subject: [PATCH 5/9] refactor: simplify path parameter handling Replace placeholders in one pass with a function replacement, move all validation into encodePathParam, and drop test rows that were duplicated across both paths. --- .changeset/encode-path-parameters.md | 2 +- src/urlUtils.ts | 38 ++++---- tests/unit-tests/urlUtilsTests.spec.ts | 124 +++++++++---------------- 3 files changed, 65 insertions(+), 99 deletions(-) diff --git a/.changeset/encode-path-parameters.md b/.changeset/encode-path-parameters.md index 33e99a88..2a89b612 100644 --- a/.changeset/encode-path-parameters.md +++ b/.changeset/encode-path-parameters.md @@ -2,4 +2,4 @@ '@fingerprint/node-sdk': patch --- -URL-encode path parameters, so an `eventId` or `visitorId` can no longer change which endpoint is requested. Do not pre-encode these values yourself, as they are now encoded for you. A parameter that is `.`, `..`, `null` or cannot be encoded is rejected with a `TypeError` instead of being sent. +URL-encode path parameters; reject "." and ".." path parameters with a TypeError. diff --git a/src/urlUtils.ts b/src/urlUtils.ts index ca216907..431a3c29 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -50,13 +50,22 @@ function serializeQueryStringParams(params: QueryStringParameters): string { * A value of `.` or `..` is rejected: `new URL()` drops such a segment even when the dots are * encoded, so it cannot be expressed. See https://url.spec.whatwg.org/#double-dot-path-segment */ -function encodePathParam(placeholder: string, value: string): string { - if (value === '.' || value === '..') { +function encodePathParam(placeholder: string, value: unknown): string { + // Coerce first: an untyped caller can pass something that is not a string but stringifies + // to one, which would slip past a strict comparison. + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- runtime validation + const param = String(value ?? '') + + if (param === '') { + throw new TypeError(`Missing path parameter for ${placeholder}`) + } + + if (param === '.' || param === '..') { throw new TypeError(`Invalid path parameter for ${placeholder}`) } try { - return encodeURIComponent(value) + return encodeURIComponent(param) } catch { // `encodeURIComponent` throws `URIError` on a lone surrogate throw new TypeError(`Invalid path parameter for ${placeholder}`) @@ -112,23 +121,12 @@ export function getRequestPath({ // eslint-disable-next-line @typescript-eslint/no-unused-vars method: _, }: GetRequestPathOptions): string { - // Step 1: Extract the path parameters (placeholders) from the path - const placeholders = Array.from(path.matchAll(/{(.*?)}/g)).map((match) => match[1]) - - // Step 2: Replace the placeholders with provided pathParams - let formattedPath: string = `${apiVersion}${path}` - placeholders.forEach((placeholder, index) => { - // Coerce to a primitive before validating. An untyped caller can pass something that is - // not a string but stringifies to one, and it would pass a strict comparison. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion -- runtime validation - const param = String(pathParams?.[index] ?? '') - - if (param === '') { - throw new TypeError(`Missing path parameter for ${placeholder}`) - } - - formattedPath = formattedPath.replace(`{${placeholder}}`, encodePathParam(placeholder, param)) - }) + // Replace each `{placeholder}` with its path parameter. The replacement is a function so + // that `$&` and friends in a parameter are not read as replacement patterns. + let index = 0 + const formattedPath = `${apiVersion}${path}`.replace(/{(.*?)}/g, (_, placeholder: string) => + encodePathParam(placeholder, pathParams?.[index++]) + ) const queryStringParameters: QueryStringParameters = { ...(queryParams ?? {}), diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index 4c17b5ff..192bb2e8 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -198,88 +198,56 @@ describe('getRequestPath', () => { }) }) +// Encoding does not depend on which parameter is being replaced, so these run against one +// path. That every operation routes through it is covered by the mocked-response tests. describe('path parameter encoding', () => { - const pathsWithParams = [ - { path: '/events/{event_id}', method: 'get', prefix: 'v4/events', placeholder: 'event_id' }, - { path: '/visitors/{visitor_id}', method: 'delete', prefix: 'v4/visitors', placeholder: 'visitor_id' }, - ] as const - - describe.each(pathsWithParams)('$path', ({ path, method, prefix, placeholder }) => { - it.each([ - ['../events', '..%2Fevents'], - ['/../../events', '%2F..%2F..%2Fevents'], - ['evil.com', 'evil.com'], - ['//evil.com', '%2F%2Fevil.com'], - ['https://evil.com', 'https%3A%2F%2Fevil.com'], - ['a b#c?d', 'a%20b%23c%3Fd'], - ['%2e%2e', '%252e%252e'], - ['..%2fevents', '..%252fevents'], - ['..\\..', '..%5C..'], - ['$&', '%24%26'], - ['...', '...'], - // Guards the assumption that a placeholder in a parameter cannot reach the next - // replacement, which would matter for a path with two placeholders. - ['{event_id}', '%7Bevent_id%7D'], - ['1626550679751.cVc5Pm', '1626550679751.cVc5Pm'], - ])('keeps %j inside a single path segment', (param, encoded) => { - const actual = getRequestPath({ path, method, pathParams: [param], region: Region.Global }) - - expect(actual).toEqual(`https://api.fpjs.io/${prefix}/${encoded}?${ii}`) - expect(new URL(actual).host).toEqual('api.fpjs.io') - }) - - it.each(['.', '..'])('rejects the dot segment %j', (param) => { - expect(() => getRequestPath({ path, method, pathParams: [param], region: Region.Global })).toThrow( - /^Invalid path parameter for / - ) - }) - - it('rejects an empty parameter', () => { - expect(() => getRequestPath({ path, method, pathParams: [''], region: Region.Global })).toThrow( - /^Missing path parameter for / - ) - }) - - it('rejects a value that cannot be encoded', () => { - // A lone surrogate makes `encodeURIComponent` throw a `URIError` - expect(() => getRequestPath({ path, method, pathParams: ['\ud800'], region: Region.Global })).toThrow( - new TypeError(`Invalid path parameter for ${placeholder}`) - ) + const eventPath = (param: unknown) => + getRequestPath({ + path: '/events/{event_id}', + method: 'get', + pathParams: [param] as string[], + region: Region.Global, }) - // An untyped caller can pass a value that is not a string but stringifies to one. - describe('non-string parameters', () => { - const asPathParams = (param: unknown) => [param] as unknown as string[] - - it.each([ - ['a String object', new String('..')], - ['an object with a toString', { toString: () => '..' }], - ['an array', ['..']], - ])('rejects the dot segment from %s', (_, param) => { - expect(() => getRequestPath({ path, method, pathParams: asPathParams(param), region: Region.Global })).toThrow( - /^Invalid path parameter for / - ) - }) - - it.each([ - ['an empty String object', new String('')], - ['null', null], - ])('rejects %s as missing', (_, param) => { - expect(() => getRequestPath({ path, method, pathParams: asPathParams(param), region: Region.Global })).toThrow( - /^Missing path parameter for / - ) - }) + it.each([ + ['../events', '..%2Fevents'], + ['/../../events', '%2F..%2F..%2Fevents'], + ['evil.com', 'evil.com'], + ['//evil.com', '%2F%2Fevil.com'], + ['https://evil.com', 'https%3A%2F%2Fevil.com'], + ['a b#c?d', 'a%20b%23c%3Fd'], + ['%2e%2e', '%252e%252e'], + ['..%2fevents', '..%252fevents'], + ['..\\..', '..%5C..'], + ['$&', '%24%26'], + ['...', '...'], + // A placeholder in a parameter must not reach the next replacement, which would matter + // for a path with two placeholders. + ['{event_id}', '%7Bevent_id%7D'], + ['1626550679751.cVc5Pm', '1626550679751.cVc5Pm'], + // An untyped caller can pass something that is not a string but stringifies to one. + [new String('../events'), '..%2Fevents'], + ])('keeps %j inside a single path segment', (param, encoded) => { + expect(eventPath(param)).toEqual(`https://api.fpjs.io/v4/events/${encoded}?${ii}`) + }) - it('encodes a stringified value that is not a dot segment', () => { - const actual = getRequestPath({ - path, - method, - pathParams: asPathParams(new String('../events')), - region: Region.Global, - }) + it.each([ + ['.', '.'], + ['..', '..'], + ['a String object', new String('..')], + ['an object with a toString', { toString: () => '..' }], + ['an array', ['..']], + // A lone surrogate makes `encodeURIComponent` throw a `URIError` + ['a lone surrogate', '\ud800'], + ])('rejects %s', (_, param) => { + expect(() => eventPath(param)).toThrow(new TypeError('Invalid path parameter for event_id')) + }) - expect(actual).toEqual(`https://api.fpjs.io/${prefix}/..%2Fevents?${ii}`) - }) - }) + it.each([ + ['an empty string', ''], + ['an empty String object', new String('')], + ['null', null], + ])('rejects %s as missing', (_, param) => { + expect(() => eventPath(param)).toThrow(new TypeError('Missing path parameter for event_id')) }) }) From 2cf857eb6861d9b37ed473623f3f69a985fcf3c7 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Mon, 7 Sep 2026 16:38:11 +0100 Subject: [PATCH 6/9] fix: catch coercion errors in path parameter validation String() throws for values with no primitive representation, which leaked as its own error type. --- src/urlUtils.ts | 28 ++++++++++++++++---------- tests/unit-tests/urlUtilsTests.spec.ts | 10 +++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/urlUtils.ts b/src/urlUtils.ts index 431a3c29..0baca9db 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -51,25 +51,31 @@ function serializeQueryStringParams(params: QueryStringParameters): string { * encoded, so it cannot be expressed. See https://url.spec.whatwg.org/#double-dot-path-segment */ function encodePathParam(placeholder: string, value: unknown): string { - // Coerce first: an untyped caller can pass something that is not a string but stringifies - // to one, which would slip past a strict comparison. - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- runtime validation - const param = String(value ?? '') + const invalid = () => new TypeError(`Invalid path parameter for ${placeholder}`) + + // Coerce before comparing, because an untyped caller can pass something that is not a string + // but stringifies to one. Both conversions throw on values only such a caller could pass: + // `String` when the value has no primitive representation, `encodeURIComponent` on a lone + // surrogate. Neither should escape as its own error type. + let param: string + let encoded: string + try { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- runtime validation + param = String(value ?? '') + encoded = encodeURIComponent(param) + } catch { + throw invalid() + } if (param === '') { throw new TypeError(`Missing path parameter for ${placeholder}`) } if (param === '.' || param === '..') { - throw new TypeError(`Invalid path parameter for ${placeholder}`) + throw invalid() } - try { - return encodeURIComponent(param) - } catch { - // `encodeURIComponent` throws `URIError` on a lone surrogate - throw new TypeError(`Invalid path parameter for ${placeholder}`) - } + return encoded } function getServerApiUrl(region: Region): string { diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index 192bb2e8..c8454aa5 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -239,6 +239,16 @@ describe('path parameter encoding', () => { ['an array', ['..']], // A lone surrogate makes `encodeURIComponent` throw a `URIError` ['a lone surrogate', '\ud800'], + // These have no primitive representation, so `String` itself throws + ['an object without a prototype', Object.create(null)], + [ + 'an object whose toString throws', + { + toString: () => { + throw new Error('boom') + }, + }, + ], ])('rejects %s', (_, param) => { expect(() => eventPath(param)).toThrow(new TypeError('Invalid path parameter for event_id')) }) From 8aa1f66d8464e500f5acef10b7e155bd99fe12b5 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 10 Sep 2026 10:24:54 +0100 Subject: [PATCH 7/9] fix: improve tests Co-authored-by: Dan McNulty <212590662+mcnulty-fp@users.noreply.github.com> --- tests/unit-tests/urlUtilsTests.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index c8454aa5..e9070fc1 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -211,6 +211,7 @@ describe('path parameter encoding', () => { it.each([ ['../events', '..%2Fevents'], + ['../', '..%2F'], ['/../../events', '%2F..%2F..%2Fevents'], ['evil.com', 'evil.com'], ['//evil.com', '%2F%2Fevil.com'], From 991f7b5ce8f9f14973ad02cc204245a36314fa1b Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 10 Sep 2026 10:25:16 +0100 Subject: [PATCH 8/9] fix: improve tests Co-authored-by: Dan McNulty <212590662+mcnulty-fp@users.noreply.github.com> --- tests/unit-tests/urlUtilsTests.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index e9070fc1..bc5181d5 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -258,6 +258,7 @@ describe('path parameter encoding', () => { ['an empty string', ''], ['an empty String object', new String('')], ['null', null], + ['undefined', undefined] ])('rejects %s as missing', (_, param) => { expect(() => eventPath(param)).toThrow(new TypeError('Missing path parameter for event_id')) }) From 66c2cb4664c154314fe4876e4d5d16d8e0628f6f Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 10 Sep 2026 10:47:28 +0100 Subject: [PATCH 9/9] fix: review improvments --- src/urlUtils.ts | 16 ++++--- .../pathParamEncodingTests.spec.ts | 2 +- tests/unit-tests/urlUtilsTests.spec.ts | 48 +++++++++++++++---- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/urlUtils.ts b/src/urlUtils.ts index 0baca9db..bad0810a 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -44,15 +44,13 @@ function serializeQueryStringParams(params: QueryStringParameters): string { } /** - * Confines a value to a single URL path segment. `.` is deliberately left unencoded, because - * the Server API does not decode path parameters and valid event IDs contain a dot. + * Confines a value to a single URL path segment. `.` is deliberately left unencoded because + * the Server API does not decode path parameters, and valid parameter values can contain dots. * * A value of `.` or `..` is rejected: `new URL()` drops such a segment even when the dots are * encoded, so it cannot be expressed. See https://url.spec.whatwg.org/#double-dot-path-segment */ function encodePathParam(placeholder: string, value: unknown): string { - const invalid = () => new TypeError(`Invalid path parameter for ${placeholder}`) - // Coerce before comparing, because an untyped caller can pass something that is not a string // but stringifies to one. Both conversions throw on values only such a caller could pass: // `String` when the value has no primitive representation, `encodeURIComponent` on a lone @@ -63,8 +61,8 @@ function encodePathParam(placeholder: string, value: unknown): string { // eslint-disable-next-line @typescript-eslint/no-base-to-string -- runtime validation param = String(value ?? '') encoded = encodeURIComponent(param) - } catch { - throw invalid() + } catch (cause) { + throw new TypeError(`Invalid path parameter for ${placeholder}`, { cause }) } if (param === '') { @@ -72,7 +70,7 @@ function encodePathParam(placeholder: string, value: unknown): string { } if (param === '.' || param === '..') { - throw invalid() + throw new TypeError(`Invalid path parameter for ${placeholder}: ${param}`) } return encoded @@ -143,5 +141,9 @@ export function getRequestPath({ url.pathname = formattedPath url.search = serializeQueryStringParams(queryStringParameters) + if (url.pathname !== `/${formattedPath}`) { + throw new TypeError('Invalid path: path changed during normalization') + } + return url.toString() } diff --git a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts index 9b941a28..25508ad4 100644 --- a/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts +++ b/tests/mocked-responses-tests/pathParamEncodingTests.spec.ts @@ -58,7 +58,7 @@ describe('[Mocked response] Path parameter encoding', () => { }) it.each(['.', '..'])('does not send a request for %j', async (param) => { - await expect(call(param)).rejects.toThrow(new TypeError(`Invalid path parameter for ${placeholder}`)) + await expect(call(param)).rejects.toThrow(new TypeError(`Invalid path parameter for ${placeholder}: ${param}`)) expect(mockFetch).not.toHaveBeenCalled() }) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index bc5181d5..a26d8939 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Region, SearchEventsFilter } from '../../src' import { version } from '../../package.json' +import type { paths } from '../../src/generatedApiTypes' import { getRequestPath } from '../../src/urlUtils' const visitorId = 'TaDnMBz9XCpZNuSzFUqP' @@ -184,6 +185,16 @@ describe('getRequestPath', () => { }).toThrow('Missing path parameter for event_id') }) + it('disallows normalized path segments', () => { + expect(() => { + getRequestPath({ + path: '/visitors/../events' as keyof paths, + method: 'get', + pathParams: [], + }) + }).toThrow('Invalid path: path changed during normalization') + }) + it('encodes special characters', () => { const actual = getRequestPath({ path: '/events', @@ -233,15 +244,15 @@ describe('path parameter encoding', () => { }) it.each([ - ['.', '.'], - ['..', '..'], - ['a String object', new String('..')], - ['an object with a toString', { toString: () => '..' }], - ['an array', ['..']], + ['.', '.', 'Invalid path parameter for event_id: .'], + ['..', '..', 'Invalid path parameter for event_id: ..'], + ['a String object', new String('..'), 'Invalid path parameter for event_id: ..'], + ['an object with a toString', { toString: () => '..' }, 'Invalid path parameter for event_id: ..'], + ['an array', ['..'], 'Invalid path parameter for event_id: ..'], // A lone surrogate makes `encodeURIComponent` throw a `URIError` - ['a lone surrogate', '\ud800'], + ['a lone surrogate', '\ud800', 'Invalid path parameter for event_id'], // These have no primitive representation, so `String` itself throws - ['an object without a prototype', Object.create(null)], + ['an object without a prototype', Object.create(null), 'Invalid path parameter for event_id'], [ 'an object whose toString throws', { @@ -249,16 +260,33 @@ describe('path parameter encoding', () => { throw new Error('boom') }, }, + 'Invalid path parameter for event_id', ], - ])('rejects %s', (_, param) => { - expect(() => eventPath(param)).toThrow(new TypeError('Invalid path parameter for event_id')) + ])('rejects %s', (_, param, message) => { + expect(() => eventPath(param)).toThrow(new TypeError(message)) + }) + + it('preserves the cause when string coercion fails', () => { + const cause = new Error('boom') + const param = { + toString: () => { + throw cause + }, + } + + expect(() => eventPath(param)).toThrow( + expect.objectContaining({ + message: 'Invalid path parameter for event_id', + cause, + }) + ) }) it.each([ ['an empty string', ''], ['an empty String object', new String('')], ['null', null], - ['undefined', undefined] + ['undefined', undefined], ])('rejects %s as missing', (_, param) => { expect(() => eventPath(param)).toThrow(new TypeError('Missing path parameter for event_id')) })