From e015be1f4788f60c88c95eeb29ee6737c2c592e0 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 10 Sep 2026 13:51:50 +0100 Subject: [PATCH 1/3] fix: url encode path parameters Backport of #282 to the api-v3 line. Refs INTER-2499 --- .changeset/encode-path-parameters.md | 5 + src/urlUtils.ts | 55 ++++++++--- .../pathParamEncodingTests.spec.ts | 86 ++++++++++++++++ tests/unit-tests/urlUtilsTests.spec.ts | 99 +++++++++++++++++++ 4 files changed, 233 insertions(+), 12 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..b18c446c --- /dev/null +++ b/.changeset/encode-path-parameters.md @@ -0,0 +1,5 @@ +--- +'@fingerprintjs/fingerprintjs-pro-server-api': patch +--- + +URL-encode path parameters; reject "." and ".." path parameters with a TypeError. diff --git a/src/urlUtils.ts b/src/urlUtils.ts index 5c07864c..12340ea5 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -52,6 +52,39 @@ function serializeQueryStringParams(params: QueryStringParameters): string { return urlSearchParams.toString() } +/** + * 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 { + // 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 (cause) { + throw new TypeError(`Invalid path parameter for ${placeholder}`, { cause }) + } + + if (param === '') { + throw new TypeError(`Missing path parameter for ${placeholder}`) + } + + if (param === '.' || param === '..') { + throw new TypeError(`Invalid path parameter for ${placeholder}: ${param}`) + } + + return encoded +} + function getServerApiUrl(region: Region): string { switch (region) { case Region.EU: @@ -123,18 +156,12 @@ export function getRequestPath): 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 = path - placeholders.forEach((placeholder, index) => { - if (pathParams?.[index]) { - formattedPath = formattedPath.replace(`{${placeholder}}`, pathParams[index]) - } else { - throw new Error(`Missing path parameter for ${placeholder}`) - } - }) + // 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: string = path.replace(/{(.*?)}/g, (_, placeholder: string) => + encodePathParam(placeholder, pathParams?.[index++]) + ) const queryStringParameters: QueryStringParameters = { ...(queryParams ?? {}), @@ -148,5 +175,9 @@ export function getRequestPath { + const apiKey = 'dummy_api_key' + const ii = `ii=${encodeURIComponent(getIntegrationInfo())}` + + const client = new FingerprintJsServerApiClient({ region: Region.EU, apiKey }) + + const emptyResponse = () => new Response(undefined, { headers: { 'content-length': '0' } }) + + beforeEach(() => { + mockFetch.mockClear() + }) + + const operations = [ + { + name: 'getEvent', + prefix: 'events', + placeholder: 'request_id', + missingParamMessage: 'requestId is not set', + call: (param: string) => client.getEvent(param), + }, + { + name: 'updateEvent', + prefix: 'events', + placeholder: 'request_id', + missingParamMessage: 'requestId is not set', + call: (param: string) => client.updateEvent({ suspect: true }, param), + }, + { + name: 'getVisits', + prefix: 'visitors', + placeholder: 'visitor_id', + missingParamMessage: 'VisitorId is not set', + call: (param: string) => client.getVisits(param), + }, + { + name: 'deleteVisitorData', + prefix: 'visitors', + placeholder: 'visitor_id', + missingParamMessage: 'VisitorId is not set', + call: (param: string) => client.deleteVisitorData(param), + }, + ] as const + + describe.each(operations)('$name', ({ prefix, placeholder, missingParamMessage, 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'], + ['evil.com', 'evil.com'], + ['//evil.com', '%2F%2Fevil.com'], + ])('requests a single path segment for %j', async (param, encoded) => { + mockFetch.mockReturnValue(Promise.resolve(emptyResponse())) + + // The empty body makes some methods reject; only the requested URL matters here. + 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}: ${param}`)) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('does not send a request for an empty parameter', async () => { + await expect(call('')).rejects.toThrow(new TypeError(missingParamMessage)) + + expect(mockFetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index 7ce8cfc8..14985e03 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -237,3 +237,102 @@ describe('Delete visitor path', () => { expect(actualPath).toEqual(expectedPath) }) }) + +describe('getRequestPath', () => { + it('disallows normalized path segments', () => { + expect(() => { + getRequestPath({ + path: '/visitors/../events' as '/events/{request_id}', + method: 'get', + pathParams: [requestId], + region: Region.Global, + }) + }).toThrow('Invalid path: path changed during normalization') + }) +}) + +// 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 eventPath = (param: unknown) => + getRequestPath({ + path: '/events/{request_id}', + method: 'get', + pathParams: [param] as string[], + region: Region.Global, + }) + + it.each([ + ['../events', '..%2Fevents'], + ['../', '..%2F'], + ['/../../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. + ['{request_id}', '%7Brequest_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/events/${encoded}?${ii}`) + }) + + it.each([ + ['.', '.', 'Invalid path parameter for request_id: .'], + ['..', '..', 'Invalid path parameter for request_id: ..'], + ['a String object', new String('..'), 'Invalid path parameter for request_id: ..'], + ['an object with a toString', { toString: () => '..' }, 'Invalid path parameter for request_id: ..'], + ['an array', ['..'], 'Invalid path parameter for request_id: ..'], + // A lone surrogate makes `encodeURIComponent` throw a `URIError` + ['a lone surrogate', '\ud800', 'Invalid path parameter for request_id'], + // These have no primitive representation, so `String` itself throws + ['an object without a prototype', Object.create(null), 'Invalid path parameter for request_id'], + [ + 'an object whose toString throws', + { + toString: () => { + throw new Error('boom') + }, + }, + 'Invalid path parameter for request_id', + ], + ])('rejects %s', (_, param, message) => { + // Asserted separately from the message because some of these carry a `cause`, which + // `toThrow(new TypeError(...))` would compare too. + expect(() => eventPath(param)).toThrow(TypeError) + expect(() => eventPath(param)).toThrow(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 request_id', + cause, + }) + ) + }) + + it.each([ + ['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 request_id')) + }) +}) From 24ad01b6d4f0f56ebd52023bb65f6f8191ee210f Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Thu, 10 Sep 2026 13:54:00 +0100 Subject: [PATCH 2/3] test: match rejection messages exactly --- tests/unit-tests/urlUtilsTests.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index 14985e03..be7d1265 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -305,10 +305,10 @@ describe('path parameter encoding', () => { 'Invalid path parameter for request_id', ], ])('rejects %s', (_, param, message) => { - // Asserted separately from the message because some of these carry a `cause`, which - // `toThrow(new TypeError(...))` would compare too. + // The message is matched through `objectContaining` because some of these carry a + // `cause`, which `toThrow(new TypeError(...))` would compare too. expect(() => eventPath(param)).toThrow(TypeError) - expect(() => eventPath(param)).toThrow(message) + expect(() => eventPath(param)).toThrow(expect.objectContaining({ message })) }) it('preserves the cause when string coercion fails', () => { From bbed302ea139956fa84f162053531c273aa08cc5 Mon Sep 17 00:00:00 2001 From: Juraj Uhlar Date: Fri, 11 Sep 2026 10:38:10 +0100 Subject: [PATCH 3/3] fix: enforce leading slash requirement for request paths --- src/urlUtils.ts | 6 ++++++ tests/unit-tests/urlUtilsTests.spec.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/urlUtils.ts b/src/urlUtils.ts index 12340ea5..e7da0782 100644 --- a/src/urlUtils.ts +++ b/src/urlUtils.ts @@ -156,6 +156,12 @@ export function getRequestPath): string { + // `url.pathname` below is always slash-prefixed, so the normalization check needs a path + // that is too. Every generated path key is; this guards a caller that casts past the type. + if (!path.startsWith('/')) { + throw new TypeError(`Invalid path: ${path} does not start with a slash`) + } + // 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 diff --git a/tests/unit-tests/urlUtilsTests.spec.ts b/tests/unit-tests/urlUtilsTests.spec.ts index be7d1265..3c8b4347 100644 --- a/tests/unit-tests/urlUtilsTests.spec.ts +++ b/tests/unit-tests/urlUtilsTests.spec.ts @@ -249,6 +249,17 @@ describe('getRequestPath', () => { }) }).toThrow('Invalid path: path changed during normalization') }) + + it('disallows a path without a leading slash', () => { + expect(() => { + getRequestPath({ + path: 'events/{request_id}' as '/events/{request_id}', + method: 'get', + pathParams: [requestId], + region: Region.Global, + }) + }).toThrow('Invalid path: events/{request_id} does not start with a slash') + }) }) // Encoding does not depend on which parameter is being replaced, so these run against one