From 7963a2abd22275f6eb962bd2af11a71c5728ffbb Mon Sep 17 00:00:00 2001 From: Orkun Date: Thu, 10 Sep 2026 14:01:08 +0300 Subject: [PATCH 1/6] test: add tests for path traversal and invalid ID encoding in request methods --- test/Api/FingerprintApiTest.php | 97 +++++++++++++++++++++++++++++++++ test/ObjectSerializerTest.php | 33 +++++++++++ 2 files changed, 130 insertions(+) diff --git a/test/Api/FingerprintApiTest.php b/test/Api/FingerprintApiTest.php index d08c461ec..b992e952d 100644 --- a/test/Api/FingerprintApiTest.php +++ b/test/Api/FingerprintApiTest.php @@ -1514,6 +1514,103 @@ public function testUpdateEventNon2xxStatusCode(): void $this->api->updateEvent('test', new EventUpdate()); } + /** + * Verifies getEventRequest encodes a path-traversal event_id into a single + * path segment rather than letting it escape to a sibling resource. + */ + public function testGetEventRequestEncodesPathTraversalEventId(): void + { + $request = $this->api->getEventRequest('../events'); + + $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); + $this->assertSame('/v4/events/..%2Fevents', $request->getUri()->getPath()); + } + + /** + * Verifies a hostname-shaped event_id is treated as an opaque path segment + * and never changes the request's target host. + */ + public function testGetEventRequestDoesNotRedirectHostForEvilEventId(): void + { + $request = $this->api->getEventRequest('domain.tld'); + + $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); + $this->assertSame('/v4/events/domain.tld', $request->getUri()->getPath()); + } + + /** + * Verifies an empty event_id still produces a distinct trailing segment + * instead of collapsing onto the bare /events collection endpoint. + */ + public function testGetEventRequestWithEmptyEventIdDoesNotCallCollectionEndpoint(): void + { + $request = $this->api->getEventRequest(''); + + $this->assertNotSame('/v4/events', $request->getUri()->getPath()); + $this->assertSame('/v4/events/', $request->getUri()->getPath()); + } + + /** + * Verifies updateEventRequest encodes a path-traversal event_id the same + * way as the read path. + */ + public function testUpdateEventRequestEncodesPathTraversalEventId(): void + { + $request = $this->api->updateEventRequest('../events', new EventUpdate()); + + $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); + $this->assertSame('/v4/events/..%2Fevents', $request->getUri()->getPath()); + } + + /** + * Verifies a hostname-shaped event_id passed to updateEvent never changes + * the request's target host. + */ + public function testUpdateEventRequestDoesNotRedirectHostForEvilEventId(): void + { + $request = $this->api->updateEventRequest('domain.tld', new EventUpdate()); + + $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); + $this->assertSame('/v4/events/domain.tld', $request->getUri()->getPath()); + } + + /** + * Verifies deleteVisitorDataRequest encodes a path-traversal visitor_id + * into a single path segment rather than letting it escape to a sibling + * resource. + */ + public function testDeleteVisitorDataRequestEncodesPathTraversalVisitorId(): void + { + $request = $this->api->deleteVisitorDataRequest('../visitors'); + + $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); + $this->assertSame('/v4/visitors/..%2Fvisitors', $request->getUri()->getPath()); + } + + /** + * Verifies a hostname-shaped visitor_id is treated as an opaque path + * segment and never changes the request's target host. + */ + public function testDeleteVisitorDataRequestDoesNotRedirectHostForEvilVisitorId(): void + { + $request = $this->api->deleteVisitorDataRequest('domain.tld'); + + $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); + $this->assertSame('/v4/visitors/domain.tld', $request->getUri()->getPath()); + } + + /** + * Verifies an empty visitor_id still produces a distinct trailing segment + * instead of collapsing onto the bare /visitors collection endpoint. + */ + public function testDeleteVisitorDataRequestWithEmptyVisitorIdDoesNotCallCollectionEndpoint(): void + { + $request = $this->api->deleteVisitorDataRequest(''); + + $this->assertNotSame('/v4/visitors', $request->getUri()->getPath()); + $this->assertSame('/v4/visitors/', $request->getUri()->getPath()); + } + private function parseQueryString(string $query): array { $queryArray = []; diff --git a/test/ObjectSerializerTest.php b/test/ObjectSerializerTest.php index 4039d9e1f..1cbdd03c2 100644 --- a/test/ObjectSerializerTest.php +++ b/test/ObjectSerializerTest.php @@ -302,6 +302,39 @@ public function testToPathValue(): void $this->assertSame('simple', ObjectSerializer::toPathValue('simple')); } + /** + * Verifies path traversal sequences are encoded so a single path segment + * cannot escape into a sibling resource (e.g. `../events`). + */ + public function testToPathValueEncodesPathTraversal(): void + { + $this->assertSame('..%2Fevents', ObjectSerializer::toPathValue('../events')); + $this->assertSame('..%2F..%2Fevents', ObjectSerializer::toPathValue('../../events')); + } + + /** + * Verifies slashes are always encoded, since an un-encoded slash would let + * a path parameter inject extra path segments. + */ + public function testToPathValueEncodesSlash(): void + { + $this->assertSame('abc%2Fdef', ObjectSerializer::toPathValue('abc/def')); + } + + /** + * A value that looks like a hostname must not be able to redirect the + * request elsewhere; it is just an encoded path segment. + */ + public function testToPathValueDoesNotDecodeHostLookingValue(): void + { + $this->assertSame('domain.tld', ObjectSerializer::toPathValue('domain.tld')); + } + + public function testToPathValueWithEmptyString(): void + { + $this->assertSame('', ObjectSerializer::toPathValue('')); + } + // -- toHeaderValue -- public function testToHeaderValueWithString(): void From 607bdb6e6b01cba2a968affecf3cdf736cd348c5 Mon Sep 17 00:00:00 2001 From: Orkun Date: Thu, 10 Sep 2026 14:14:48 +0300 Subject: [PATCH 2/6] test: update tests to handle absolute URL encoding in path values and event IDs --- test/Api/FingerprintApiTest.php | 22 +++++++++++----------- test/ObjectSerializerTest.php | 7 ++++--- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/test/Api/FingerprintApiTest.php b/test/Api/FingerprintApiTest.php index b992e952d..baa12c3fc 100644 --- a/test/Api/FingerprintApiTest.php +++ b/test/Api/FingerprintApiTest.php @@ -1527,15 +1527,15 @@ public function testGetEventRequestEncodesPathTraversalEventId(): void } /** - * Verifies a hostname-shaped event_id is treated as an opaque path segment - * and never changes the request's target host. + * Verifies an absolute-URL-shaped event_id is treated as an opaque path + * segment and never changes the request's target host. */ public function testGetEventRequestDoesNotRedirectHostForEvilEventId(): void { - $request = $this->api->getEventRequest('domain.tld'); + $request = $this->api->getEventRequest('https://domain.tld/evil'); $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/events/domain.tld', $request->getUri()->getPath()); + $this->assertSame('/v4/events/https%3A%2F%2Fdomain.tld%2Fevil', $request->getUri()->getPath()); } /** @@ -1563,15 +1563,15 @@ public function testUpdateEventRequestEncodesPathTraversalEventId(): void } /** - * Verifies a hostname-shaped event_id passed to updateEvent never changes - * the request's target host. + * Verifies an absolute-URL-shaped event_id passed to updateEvent never + * changes the request's target host. */ public function testUpdateEventRequestDoesNotRedirectHostForEvilEventId(): void { - $request = $this->api->updateEventRequest('domain.tld', new EventUpdate()); + $request = $this->api->updateEventRequest('https://domain.tld/evil', new EventUpdate()); $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/events/domain.tld', $request->getUri()->getPath()); + $this->assertSame('/v4/events/https%3A%2F%2Fdomain.tld%2Fevil', $request->getUri()->getPath()); } /** @@ -1588,15 +1588,15 @@ public function testDeleteVisitorDataRequestEncodesPathTraversalVisitorId(): voi } /** - * Verifies a hostname-shaped visitor_id is treated as an opaque path + * Verifies an absolute-URL-shaped visitor_id is treated as an opaque path * segment and never changes the request's target host. */ public function testDeleteVisitorDataRequestDoesNotRedirectHostForEvilVisitorId(): void { - $request = $this->api->deleteVisitorDataRequest('domain.tld'); + $request = $this->api->deleteVisitorDataRequest('https://domain.tld/evil'); $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/visitors/domain.tld', $request->getUri()->getPath()); + $this->assertSame('/v4/visitors/https%3A%2F%2Fdomain.tld%2Fevil', $request->getUri()->getPath()); } /** diff --git a/test/ObjectSerializerTest.php b/test/ObjectSerializerTest.php index 1cbdd03c2..508b5cbfa 100644 --- a/test/ObjectSerializerTest.php +++ b/test/ObjectSerializerTest.php @@ -322,12 +322,13 @@ public function testToPathValueEncodesSlash(): void } /** - * A value that looks like a hostname must not be able to redirect the - * request elsewhere; it is just an encoded path segment. + * A value that looks like an absolute URL must not be able to redirect + * the request elsewhere; its scheme and slashes are encoded so it stays + * a single, inert path segment. */ public function testToPathValueDoesNotDecodeHostLookingValue(): void { - $this->assertSame('domain.tld', ObjectSerializer::toPathValue('domain.tld')); + $this->assertSame('https%3A%2F%2Fdomain.tld%2Fevil', ObjectSerializer::toPathValue('https://domain.tld/evil')); } public function testToPathValueWithEmptyString(): void From 1b59cae26012a1b88ca3deb5eb3def7c3ec0ed96 Mon Sep 17 00:00:00 2001 From: Orkun Date: Thu, 10 Sep 2026 14:29:58 +0300 Subject: [PATCH 3/6] test: add tests for empty event ID handling and absolute URL encoding in paths --- test/Api/FingerprintApiTest.php | 13 +++++++++++++ test/ObjectSerializerTest.php | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/test/Api/FingerprintApiTest.php b/test/Api/FingerprintApiTest.php index baa12c3fc..3d1a4cc6d 100644 --- a/test/Api/FingerprintApiTest.php +++ b/test/Api/FingerprintApiTest.php @@ -1574,6 +1574,19 @@ public function testUpdateEventRequestDoesNotRedirectHostForEvilEventId(): void $this->assertSame('/v4/events/https%3A%2F%2Fdomain.tld%2Fevil', $request->getUri()->getPath()); } + /** + * Verifies an empty event_id passed to updateEvent still produces a + * distinct trailing segment instead of collapsing onto the bare /events + * collection endpoint. + */ + public function testUpdateEventRequestWithEmptyEventIdDoesNotCallCollectionEndpoint(): void + { + $request = $this->api->updateEventRequest('', new EventUpdate()); + + $this->assertNotSame('/v4/events', $request->getUri()->getPath()); + $this->assertSame('/v4/events/', $request->getUri()->getPath()); + } + /** * Verifies deleteVisitorDataRequest encodes a path-traversal visitor_id * into a single path segment rather than letting it escape to a sibling diff --git a/test/ObjectSerializerTest.php b/test/ObjectSerializerTest.php index 508b5cbfa..c60a8b1f0 100644 --- a/test/ObjectSerializerTest.php +++ b/test/ObjectSerializerTest.php @@ -326,7 +326,7 @@ public function testToPathValueEncodesSlash(): void * the request elsewhere; its scheme and slashes are encoded so it stays * a single, inert path segment. */ - public function testToPathValueDoesNotDecodeHostLookingValue(): void + public function testToPathValueEncodesAbsoluteUrlValue(): void { $this->assertSame('https%3A%2F%2Fdomain.tld%2Fevil', ObjectSerializer::toPathValue('https://domain.tld/evil')); } From 7bab66960edeb1ccbbeb697c634f814d2ac67110 Mon Sep 17 00:00:00 2001 From: Orkun Date: Fri, 11 Sep 2026 14:45:38 +0300 Subject: [PATCH 4/6] fix: ensure and path parameters are correctly percent-encoded to prevent URL normalization issues --- ...fix-dot-segment-path-parameter-encoding.md | 5 + src/Api/FingerprintApi.php | 12 +- src/ObjectSerializer.php | 13 +- template/ObjectSerializer.mustache | 13 +- template/api.mustache | 12 +- test/Api/FingerprintApiTest.php | 149 ++++++++++++++++++ test/ObjectSerializerTest.php | 29 ++++ test/Support/RawRequestCapture.php | 128 +++++++++++++++ test/Support/raw_request_listener.php | 50 ++++++ 9 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-dot-segment-path-parameter-encoding.md create mode 100644 test/Support/RawRequestCapture.php create mode 100644 test/Support/raw_request_listener.php diff --git a/.changeset/fix-dot-segment-path-parameter-encoding.md b/.changeset/fix-dot-segment-path-parameter-encoding.md new file mode 100644 index 000000000..df7f25ef0 --- /dev/null +++ b/.changeset/fix-dot-segment-path-parameter-encoding.md @@ -0,0 +1,5 @@ +--- +"@fingerprint/php-sdk": patch +--- + +Fixed `event_id`/`visitor_id` values of exactly `.` or `..` being collapsed by curl's URL normalization before the request is sent, causing `getEvent`, `updateEvent`, and `deleteVisitorData` to hit the wrong endpoint instead of the requested resource. diff --git a/src/Api/FingerprintApi.php b/src/Api/FingerprintApi.php index 45c127c04..ef5f01727 100644 --- a/src/Api/FingerprintApi.php +++ b/src/Api/FingerprintApi.php @@ -1690,7 +1690,17 @@ public function updateEventRequest(string $event_id, EventUpdate $event_update): */ protected function createHttpClientOption(): array { - $options = []; + // Path parameters are percent-encoded (see ObjectSerializer::toPathValue()), + // but curl still decodes and collapses RFC 3986 dot-segments (e.g. a + // literal '.' or '..' path parameter) before the request is sent unless + // explicitly told not to. CURLOPT_PATH_AS_IS makes curl transmit the URL + // exactly as built, so an event/visitor ID can never be misrouted to a + // different endpoint via path normalization. + $options = [ + 'curl' => [ + \CURLOPT_PATH_AS_IS => true, + ], + ]; if ($this->config->getDebug()) { $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a'); if (!$options[RequestOptions::DEBUG]) { diff --git a/src/ObjectSerializer.php b/src/ObjectSerializer.php index c3a5a822c..0655275fa 100644 --- a/src/ObjectSerializer.php +++ b/src/ObjectSerializer.php @@ -145,7 +145,18 @@ public static function sanitizeTimestamp(string $timestamp): string */ public static function toPathValue(string $value): string { - return rawurlencode(self::toString($value)); + $encoded = rawurlencode(self::toString($value)); + + // '.' and '..' are RFC 3986 dot-segments: rawurlencode() leaves the + // literal dots untouched, but URL normalizers (e.g. curl, before the + // request ever hits the wire) collapse a path segment consisting + // solely of dots into '' or 'up one level'. Escaping the dots here + // keeps the segment inert without changing any other encoded value. + if ('.' === $encoded || '..' === $encoded) { + $encoded = str_replace('.', '%2E', $encoded); + } + + return $encoded; } /** diff --git a/template/ObjectSerializer.mustache b/template/ObjectSerializer.mustache index 2f5207b3b..b16d842f3 100644 --- a/template/ObjectSerializer.mustache +++ b/template/ObjectSerializer.mustache @@ -132,7 +132,18 @@ class ObjectSerializer */ public static function toPathValue(string $value): string { - return rawurlencode(self::toString($value)); + $encoded = rawurlencode(self::toString($value)); + + // '.' and '..' are RFC 3986 dot-segments: rawurlencode() leaves the + // literal dots untouched, but URL normalizers (e.g. curl, before the + // request ever hits the wire) collapse a path segment consisting + // solely of dots into '' or 'up one level'. Escaping the dots here + // keeps the segment inert without changing any other encoded value. + if ('.' === $encoded || '..' === $encoded) { + $encoded = str_replace('.', '%2E', $encoded); + } + + return $encoded; } /** diff --git a/template/api.mustache b/template/api.mustache index 7c2f20259..ab6b7296a 100644 --- a/template/api.mustache +++ b/template/api.mustache @@ -474,7 +474,17 @@ use {{invokerPackage}}\ObjectSerializer; */ protected function createHttpClientOption(): array { - $options = []; + // Path parameters are percent-encoded (see ObjectSerializer::toPathValue()), + // but curl still decodes and collapses RFC 3986 dot-segments (e.g. a + // literal '.' or '..' path parameter) before the request is sent unless + // explicitly told not to. CURLOPT_PATH_AS_IS makes curl transmit the URL + // exactly as built, so an event/visitor ID can never be misrouted to a + // different endpoint via path normalization. + $options = [ + 'curl' => [ + \CURLOPT_PATH_AS_IS => true, + ], + ]; if ($this->config->getDebug()) { $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a'); if (!$options[RequestOptions::DEBUG]) { diff --git a/test/Api/FingerprintApiTest.php b/test/Api/FingerprintApiTest.php index 3d1a4cc6d..6e5103e4e 100644 --- a/test/Api/FingerprintApiTest.php +++ b/test/Api/FingerprintApiTest.php @@ -26,6 +26,7 @@ use Fingerprint\ServerSdk\Model\SearchEventsVpnConfidence; use Fingerprint\ServerSdk\Model\SupplementaryIDHighRecall; use Fingerprint\ServerSdk\Test\MockHelper; +use Fingerprint\ServerSdk\Test\Support\RawRequestCapture; use GuzzleHttp\Client; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\GuzzleException; @@ -35,6 +36,7 @@ use GuzzleHttp\Psr7\Response; use GuzzleHttp\Utils; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; @@ -1624,6 +1626,153 @@ public function testDeleteVisitorDataRequestWithEmptyVisitorIdDoesNotCallCollect $this->assertSame('/v4/visitors/', $request->getUri()->getPath()); } + /** + * Verifies an event_id of exactly '.' or '..' (an RFC 3986 dot-segment) + * is percent-encoded rather than left as a literal dot, which URL + * normalizers would otherwise be free to collapse. + */ + public function testGetEventRequestEncodesDotSegmentEventId(): void + { + $this->assertSame('/v4/events/%2E', $this->api->getEventRequest('.')->getUri()->getPath()); + $this->assertSame('/v4/events/%2E%2E', $this->api->getEventRequest('..')->getUri()->getPath()); + } + + /** + * Verifies a visitor_id of exactly '.' or '..' is percent-encoded the + * same way as event_id. + */ + public function testDeleteVisitorDataRequestEncodesDotSegmentVisitorId(): void + { + $this->assertSame('/v4/visitors/%2E', $this->api->deleteVisitorDataRequest('.')->getUri()->getPath()); + $this->assertSame('/v4/visitors/%2E%2E', $this->api->deleteVisitorDataRequest('..')->getUri()->getPath()); + } + + /** + * Verifies updateEventRequest encodes a dot-segment event_id the same + * way as the read path. + */ + public function testUpdateEventRequestEncodesDotSegmentEventId(): void + { + $path = $this->api->updateEventRequest('.', new EventUpdate())->getUri()->getPath(); + $this->assertSame('/v4/events/%2E', $path); + } + + /** + * Regression test for the actual wire-level bug: a PSR-7 Uri never + * normalizes dot-segments (asserting on $request->getUri()->getPath() + * alone would pass even without ObjectSerializer's encoding fix), but + * curl decodes and collapses them just before sending unless + * CURLOPT_PATH_AS_IS is set. This spins up a real local TCP listener and + * checks the literal bytes a real Guzzle+curl request puts on the wire, + * so it fails if either half of the fix (percent-encoding in + * ObjectSerializer::toPathValue, or CURLOPT_PATH_AS_IS in + * createHttpClientOption) is reverted. + */ + #[Group('wire')] + public function testGetEventDoesNotCollapseDotEventIdOnTheWire(): void + { + $capture = RawRequestCapture::start(); + + try { + $config = new Configuration('test-api-key'); + $config->setHost($capture->baseUri().'/v4'); + $api = new FingerprintApi($config, new Client(['timeout' => 2])); + + try { + $api->getEvent('.'); + } catch (\Throwable $e) { + // Only the request line on the wire matters for this test. + } + + $requestLine = $capture->requestLine(); + $this->assertNotNull($requestLine); + $this->assertStringStartsWith('GET /v4/events/%2E?', $requestLine); + } finally { + $capture->stop(); + } + } + + /** + * @see testGetEventDoesNotCollapseDotEventIdOnTheWire + */ + #[Group('wire')] + public function testGetEventDoesNotCollapseDotDotEventIdOnTheWire(): void + { + $capture = RawRequestCapture::start(); + + try { + $config = new Configuration('test-api-key'); + $config->setHost($capture->baseUri().'/v4'); + $api = new FingerprintApi($config, new Client(['timeout' => 2])); + + try { + $api->getEvent('..'); + } catch (\Throwable $e) { + // Only the request line on the wire matters for this test. + } + + $requestLine = $capture->requestLine(); + $this->assertNotNull($requestLine); + $this->assertStringStartsWith('GET /v4/events/%2E%2E?', $requestLine); + } finally { + $capture->stop(); + } + } + + /** + * @see testGetEventDoesNotCollapseDotEventIdOnTheWire + */ + #[Group('wire')] + public function testDeleteVisitorDataDoesNotCollapseDotVisitorIdOnTheWire(): void + { + $capture = RawRequestCapture::start(); + + try { + $config = new Configuration('test-api-key'); + $config->setHost($capture->baseUri().'/v4'); + $api = new FingerprintApi($config, new Client(['timeout' => 2])); + + try { + $api->deleteVisitorData('.'); + } catch (\Throwable $e) { + // Only the request line on the wire matters for this test. + } + + $requestLine = $capture->requestLine(); + $this->assertNotNull($requestLine); + $this->assertStringStartsWith('DELETE /v4/visitors/%2E?', $requestLine); + } finally { + $capture->stop(); + } + } + + /** + * @see testGetEventDoesNotCollapseDotEventIdOnTheWire + */ + #[Group('wire')] + public function testUpdateEventDoesNotCollapseDotEventIdOnTheWire(): void + { + $capture = RawRequestCapture::start(); + + try { + $config = new Configuration('test-api-key'); + $config->setHost($capture->baseUri().'/v4'); + $api = new FingerprintApi($config, new Client(['timeout' => 2])); + + try { + $api->updateEvent('.', new EventUpdate()); + } catch (\Throwable $e) { + // Only the request line on the wire matters for this test. + } + + $requestLine = $capture->requestLine(); + $this->assertNotNull($requestLine); + $this->assertStringStartsWith('PATCH /v4/events/%2E?', $requestLine); + } finally { + $capture->stop(); + } + } + private function parseQueryString(string $query): array { $queryArray = []; diff --git a/test/ObjectSerializerTest.php b/test/ObjectSerializerTest.php index c60a8b1f0..96f4dc793 100644 --- a/test/ObjectSerializerTest.php +++ b/test/ObjectSerializerTest.php @@ -336,6 +336,35 @@ public function testToPathValueWithEmptyString(): void $this->assertSame('', ObjectSerializer::toPathValue('')); } + /** + * A path parameter of exactly '.' or '..' is an RFC 3986 dot-segment: + * left as a literal dot, URL normalizers (including curl, before the + * request ever reaches the wire — see RawRequestCapture-based tests in + * FingerprintApiTest) collapse it into the parent/current path instead + * of treating it as an opaque resource identifier. The dots must be + * percent-encoded so no normalizer can mistake the segment for one. + */ + public function testToPathValueEncodesDotSegment(): void + { + $this->assertSame('%2E', ObjectSerializer::toPathValue('.')); + } + + public function testToPathValueEncodesDotDotSegment(): void + { + $this->assertSame('%2E%2E', ObjectSerializer::toPathValue('..')); + } + + /** + * Only a segment consisting solely of dots is special under RFC 3986; + * anything else containing a dot (e.g. a real event ID) must pass + * through unencoded, since '.' is otherwise a safe, unreserved character. + */ + public function testToPathValueDoesNotEncodeDotsInOtherwiseNormalValues(): void + { + $this->assertSame('1708102555327.NLOjmg', ObjectSerializer::toPathValue('1708102555327.NLOjmg')); + $this->assertSame('...', ObjectSerializer::toPathValue('...')); + } + // -- toHeaderValue -- public function testToHeaderValueWithString(): void diff --git a/test/Support/RawRequestCapture.php b/test/Support/RawRequestCapture.php new file mode 100644 index 000000000..2287765fa --- /dev/null +++ b/test/Support/RawRequestCapture.php @@ -0,0 +1,128 @@ +getUri()->getPath() cannot reveal that curl collapses a bare + * '.' or '..' path segment before the bytes leave the process. This spins + * up a plain TCP listener in a child process and hands back the raw + * request line it received, so tests can assert on what the transport + * actually sent rather than what the PHP object model says it sent. + * + * @internal + */ +final class RawRequestCapture +{ + private $process; + + /** @var resource */ + private $stdout; + + private string $captureFile; + + private int $port; + + /** + * @param resource $process + * @param resource $stdout + */ + private function __construct($process, $stdout, string $captureFile, int $port) + { + $this->process = $process; + $this->stdout = $stdout; + $this->captureFile = $captureFile; + $this->port = $port; + } + + public static function start(): self + { + $port = self::reserveFreePort(); + $captureFile = tempnam(sys_get_temp_dir(), 'fp_raw_req_'); + $listenerScript = __DIR__.'/raw_request_listener.php'; + + $process = proc_open( + [PHP_BINARY, $listenerScript, (string) $port, $captureFile], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + + if (!\is_resource($process)) { + unlink($captureFile); + + throw new \RuntimeException('Failed to start raw request listener.'); + } + + foreach ($pipes as $pipe) { + stream_set_blocking($pipe, false); + } + + $capture = new self($process, $pipes[1], $captureFile, $port); + $capture->waitUntilListening(); + + return $capture; + } + + public function baseUri(): string + { + return "http://127.0.0.1:{$this->port}"; + } + + /** + * Returns the raw request line (e.g. "GET /v4/events/. HTTP/1.1") + * once the listener has accepted and read one request. + */ + public function requestLine(): ?string + { + $deadline = microtime(true) + 2.0; + while (microtime(true) < $deadline) { + $contents = @file_get_contents($this->captureFile); + if (false !== $contents && '' !== $contents) { + return strtok($contents, "\r\n"); + } + usleep(10_000); + } + + return null; + } + + public function stop(): void + { + if (\is_resource($this->process)) { + proc_terminate($this->process); + proc_close($this->process); + } + if (file_exists($this->captureFile)) { + unlink($this->captureFile); + } + } + + private function waitUntilListening(): void + { + $deadline = microtime(true) + 2.0; + $buffer = ''; + while (microtime(true) < $deadline) { + $buffer .= (string) fread($this->stdout, 8192); + if (str_contains($buffer, "READY\n")) { + return; + } + usleep(10_000); + } + + throw new \RuntimeException('Raw request listener did not start listening in time.'); + } + + private static function reserveFreePort(): int + { + $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + if (!\is_resource($server)) { + throw new \RuntimeException("Failed to reserve a free port: {$errstr}"); + } + $name = stream_socket_get_name($server, false); + fclose($server); + + return (int) substr($name, strrpos($name, ':') + 1); + } +} diff --git a/test/Support/raw_request_listener.php b/test/Support/raw_request_listener.php new file mode 100644 index 000000000..cd9feb3db --- /dev/null +++ b/test/Support/raw_request_listener.php @@ -0,0 +1,50 @@ + Date: Tue, 15 Sep 2026 09:18:36 +0300 Subject: [PATCH 5/6] refactor: streamline request listener and path handling logic, consolidate path encoding tests --- test/Api/FingerprintApiTest.php | 248 ++++---------------------- test/Support/RawRequestCapture.php | 90 +++++----- test/Support/raw_request_listener.php | 27 ++- 3 files changed, 95 insertions(+), 270 deletions(-) diff --git a/test/Api/FingerprintApiTest.php b/test/Api/FingerprintApiTest.php index 6e5103e4e..148671a19 100644 --- a/test/Api/FingerprintApiTest.php +++ b/test/Api/FingerprintApiTest.php @@ -36,6 +36,7 @@ use GuzzleHttp\Psr7\Response; use GuzzleHttp\Utils; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; @@ -1517,144 +1518,42 @@ public function testUpdateEventNon2xxStatusCode(): void } /** - * Verifies getEventRequest encodes a path-traversal event_id into a single - * path segment rather than letting it escape to a sibling resource. + * Verifies a malformed path parameter (path traversal, an absolute URL, + * a bare dot-segment, or an empty string) is always percent-encoded into + * a single opaque path segment rather than being interpreted as part of + * the path structure, across every operation that takes an ID in the path. */ - public function testGetEventRequestEncodesPathTraversalEventId(): void + #[DataProvider('pathParameterEncodingProvider')] + public function testPathParameterIsEncodedAsSingleOpaqueSegment(\Closure $buildRequest, string $value, string $expectedPath): void { - $request = $this->api->getEventRequest('../events'); + $request = $buildRequest($this->api, $value); $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/events/..%2Fevents', $request->getUri()->getPath()); + $this->assertSame($expectedPath, $request->getUri()->getPath()); } - /** - * Verifies an absolute-URL-shaped event_id is treated as an opaque path - * segment and never changes the request's target host. - */ - public function testGetEventRequestDoesNotRedirectHostForEvilEventId(): void - { - $request = $this->api->getEventRequest('https://domain.tld/evil'); - - $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/events/https%3A%2F%2Fdomain.tld%2Fevil', $request->getUri()->getPath()); - } - - /** - * Verifies an empty event_id still produces a distinct trailing segment - * instead of collapsing onto the bare /events collection endpoint. - */ - public function testGetEventRequestWithEmptyEventIdDoesNotCallCollectionEndpoint(): void - { - $request = $this->api->getEventRequest(''); - - $this->assertNotSame('/v4/events', $request->getUri()->getPath()); - $this->assertSame('/v4/events/', $request->getUri()->getPath()); - } - - /** - * Verifies updateEventRequest encodes a path-traversal event_id the same - * way as the read path. - */ - public function testUpdateEventRequestEncodesPathTraversalEventId(): void + public static function pathParameterEncodingProvider(): iterable { - $request = $this->api->updateEventRequest('../events', new EventUpdate()); - - $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/events/..%2Fevents', $request->getUri()->getPath()); - } - - /** - * Verifies an absolute-URL-shaped event_id passed to updateEvent never - * changes the request's target host. - */ - public function testUpdateEventRequestDoesNotRedirectHostForEvilEventId(): void - { - $request = $this->api->updateEventRequest('https://domain.tld/evil', new EventUpdate()); - - $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/events/https%3A%2F%2Fdomain.tld%2Fevil', $request->getUri()->getPath()); - } - - /** - * Verifies an empty event_id passed to updateEvent still produces a - * distinct trailing segment instead of collapsing onto the bare /events - * collection endpoint. - */ - public function testUpdateEventRequestWithEmptyEventIdDoesNotCallCollectionEndpoint(): void - { - $request = $this->api->updateEventRequest('', new EventUpdate()); - - $this->assertNotSame('/v4/events', $request->getUri()->getPath()); - $this->assertSame('/v4/events/', $request->getUri()->getPath()); - } - - /** - * Verifies deleteVisitorDataRequest encodes a path-traversal visitor_id - * into a single path segment rather than letting it escape to a sibling - * resource. - */ - public function testDeleteVisitorDataRequestEncodesPathTraversalVisitorId(): void - { - $request = $this->api->deleteVisitorDataRequest('../visitors'); - - $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/visitors/..%2Fvisitors', $request->getUri()->getPath()); - } - - /** - * Verifies an absolute-URL-shaped visitor_id is treated as an opaque path - * segment and never changes the request's target host. - */ - public function testDeleteVisitorDataRequestDoesNotRedirectHostForEvilVisitorId(): void - { - $request = $this->api->deleteVisitorDataRequest('https://domain.tld/evil'); - - $this->assertSame('api.fpjs.io', $request->getUri()->getHost()); - $this->assertSame('/v4/visitors/https%3A%2F%2Fdomain.tld%2Fevil', $request->getUri()->getPath()); - } - - /** - * Verifies an empty visitor_id still produces a distinct trailing segment - * instead of collapsing onto the bare /visitors collection endpoint. - */ - public function testDeleteVisitorDataRequestWithEmptyVisitorIdDoesNotCallCollectionEndpoint(): void - { - $request = $this->api->deleteVisitorDataRequest(''); - - $this->assertNotSame('/v4/visitors', $request->getUri()->getPath()); - $this->assertSame('/v4/visitors/', $request->getUri()->getPath()); - } - - /** - * Verifies an event_id of exactly '.' or '..' (an RFC 3986 dot-segment) - * is percent-encoded rather than left as a literal dot, which URL - * normalizers would otherwise be free to collapse. - */ - public function testGetEventRequestEncodesDotSegmentEventId(): void - { - $this->assertSame('/v4/events/%2E', $this->api->getEventRequest('.')->getUri()->getPath()); - $this->assertSame('/v4/events/%2E%2E', $this->api->getEventRequest('..')->getUri()->getPath()); - } + $endpoints = [ + 'getEventRequest' => ['/v4/events/', static fn (FingerprintApi $api, string $id) => $api->getEventRequest($id)], + 'updateEventRequest' => ['/v4/events/', static fn (FingerprintApi $api, string $id) => $api->updateEventRequest($id, new EventUpdate())], + 'deleteVisitorDataRequest' => ['/v4/visitors/', static fn (FingerprintApi $api, string $id) => $api->deleteVisitorDataRequest($id)], + ]; - /** - * Verifies a visitor_id of exactly '.' or '..' is percent-encoded the - * same way as event_id. - */ - public function testDeleteVisitorDataRequestEncodesDotSegmentVisitorId(): void - { - $this->assertSame('/v4/visitors/%2E', $this->api->deleteVisitorDataRequest('.')->getUri()->getPath()); - $this->assertSame('/v4/visitors/%2E%2E', $this->api->deleteVisitorDataRequest('..')->getUri()->getPath()); - } + $values = [ + 'path traversal' => ['../events', '..%2Fevents'], + 'nested path traversal' => ['../../events', '..%2F..%2Fevents'], + 'absolute url' => ['https://domain.tld/evil', 'https%3A%2F%2Fdomain.tld%2Fevil'], + 'dot segment' => ['.', '%2E'], + 'parent dot segment' => ['..', '%2E%2E'], + 'empty' => ['', ''], + ]; - /** - * Verifies updateEventRequest encodes a dot-segment event_id the same - * way as the read path. - */ - public function testUpdateEventRequestEncodesDotSegmentEventId(): void - { - $path = $this->api->updateEventRequest('.', new EventUpdate())->getUri()->getPath(); - $this->assertSame('/v4/events/%2E', $path); + foreach ($endpoints as $endpoint => [$prefix, $call]) { + foreach ($values as $case => [$value, $encoded]) { + yield "{$endpoint}: {$case}" => [$call, $value, $prefix.$encoded]; + } + } } /** @@ -1668,109 +1567,38 @@ public function testUpdateEventRequestEncodesDotSegmentEventId(): void * ObjectSerializer::toPathValue, or CURLOPT_PATH_AS_IS in * createHttpClientOption) is reverted. */ + #[DataProvider('dotSegmentWireProvider')] #[Group('wire')] - public function testGetEventDoesNotCollapseDotEventIdOnTheWire(): void - { - $capture = RawRequestCapture::start(); - - try { - $config = new Configuration('test-api-key'); - $config->setHost($capture->baseUri().'/v4'); - $api = new FingerprintApi($config, new Client(['timeout' => 2])); - - try { - $api->getEvent('.'); - } catch (\Throwable $e) { - // Only the request line on the wire matters for this test. - } - - $requestLine = $capture->requestLine(); - $this->assertNotNull($requestLine); - $this->assertStringStartsWith('GET /v4/events/%2E?', $requestLine); - } finally { - $capture->stop(); - } - } - - /** - * @see testGetEventDoesNotCollapseDotEventIdOnTheWire - */ - #[Group('wire')] - public function testGetEventDoesNotCollapseDotDotEventIdOnTheWire(): void - { - $capture = RawRequestCapture::start(); - - try { - $config = new Configuration('test-api-key'); - $config->setHost($capture->baseUri().'/v4'); - $api = new FingerprintApi($config, new Client(['timeout' => 2])); - - try { - $api->getEvent('..'); - } catch (\Throwable $e) { - // Only the request line on the wire matters for this test. - } - - $requestLine = $capture->requestLine(); - $this->assertNotNull($requestLine); - $this->assertStringStartsWith('GET /v4/events/%2E%2E?', $requestLine); - } finally { - $capture->stop(); - } - } - - /** - * @see testGetEventDoesNotCollapseDotEventIdOnTheWire - */ - #[Group('wire')] - public function testDeleteVisitorDataDoesNotCollapseDotVisitorIdOnTheWire(): void + public function testDotSegmentIsNotCollapsedOnTheWire(\Closure $call, string $expectedPrefix): void { $capture = RawRequestCapture::start(); try { $config = new Configuration('test-api-key'); $config->setHost($capture->baseUri().'/v4'); - $api = new FingerprintApi($config, new Client(['timeout' => 2])); + $api = new FingerprintApi($config, new Client(['timeout' => 10])); try { - $api->deleteVisitorData('.'); + $call($api); } catch (\Throwable $e) { // Only the request line on the wire matters for this test. } - $requestLine = $capture->requestLine(); - $this->assertNotNull($requestLine); - $this->assertStringStartsWith('DELETE /v4/visitors/%2E?', $requestLine); + $this->assertStringStartsWith($expectedPrefix, $capture->requestLine()); } finally { $capture->stop(); } } - /** - * @see testGetEventDoesNotCollapseDotEventIdOnTheWire - */ - #[Group('wire')] - public function testUpdateEventDoesNotCollapseDotEventIdOnTheWire(): void + public static function dotSegmentWireProvider(): iterable { - $capture = RawRequestCapture::start(); + yield 'getEvent: dot segment' => [static fn (FingerprintApi $api) => $api->getEvent('.'), 'GET /v4/events/%2E?']; - try { - $config = new Configuration('test-api-key'); - $config->setHost($capture->baseUri().'/v4'); - $api = new FingerprintApi($config, new Client(['timeout' => 2])); + yield 'getEvent: parent dot segment' => [static fn (FingerprintApi $api) => $api->getEvent('..'), 'GET /v4/events/%2E%2E?']; - try { - $api->updateEvent('.', new EventUpdate()); - } catch (\Throwable $e) { - // Only the request line on the wire matters for this test. - } + yield 'updateEvent: dot segment' => [static fn (FingerprintApi $api) => $api->updateEvent('.', new EventUpdate()), 'PATCH /v4/events/%2E?']; - $requestLine = $capture->requestLine(); - $this->assertNotNull($requestLine); - $this->assertStringStartsWith('PATCH /v4/events/%2E?', $requestLine); - } finally { - $capture->stop(); - } + yield 'deleteVisitorData: dot segment' => [static fn (FingerprintApi $api) => $api->deleteVisitorData('.'), 'DELETE /v4/visitors/%2E?']; } private function parseQueryString(string $query): array diff --git a/test/Support/RawRequestCapture.php b/test/Support/RawRequestCapture.php index 2287765fa..d3188487d 100644 --- a/test/Support/RawRequestCapture.php +++ b/test/Support/RawRequestCapture.php @@ -21,37 +21,34 @@ final class RawRequestCapture /** @var resource */ private $stdout; - private string $captureFile; + /** @var resource */ + private $stderr; + + private string $buffer = ''; private int $port; /** * @param resource $process * @param resource $stdout + * @param resource $stderr */ - private function __construct($process, $stdout, string $captureFile, int $port) + private function __construct($process, $stdout, $stderr) { $this->process = $process; $this->stdout = $stdout; - $this->captureFile = $captureFile; - $this->port = $port; + $this->stderr = $stderr; } public static function start(): self { - $port = self::reserveFreePort(); - $captureFile = tempnam(sys_get_temp_dir(), 'fp_raw_req_'); - $listenerScript = __DIR__.'/raw_request_listener.php'; - $process = proc_open( - [PHP_BINARY, $listenerScript, (string) $port, $captureFile], + [PHP_BINARY, __DIR__.'/raw_request_listener.php'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes ); if (!\is_resource($process)) { - unlink($captureFile); - throw new \RuntimeException('Failed to start raw request listener.'); } @@ -59,8 +56,19 @@ public static function start(): self stream_set_blocking($pipe, false); } - $capture = new self($process, $pipes[1], $captureFile, $port); - $capture->waitUntilListening(); + $capture = new self($process, $pipes[1], $pipes[2]); + + try { + $ready = $capture->readLine(10.0); + if (!str_starts_with($ready, 'READY ')) { + throw new \RuntimeException("Unexpected listener handshake: {$ready}"); + } + $capture->port = (int) substr($ready, 6); + } catch (\Throwable $e) { + $capture->stop(); + + throw $e; + } return $capture; } @@ -74,18 +82,9 @@ public function baseUri(): string * Returns the raw request line (e.g. "GET /v4/events/. HTTP/1.1") * once the listener has accepted and read one request. */ - public function requestLine(): ?string + public function requestLine(): string { - $deadline = microtime(true) + 2.0; - while (microtime(true) < $deadline) { - $contents = @file_get_contents($this->captureFile); - if (false !== $contents && '' !== $contents) { - return strtok($contents, "\r\n"); - } - usleep(10_000); - } - - return null; + return $this->readLine(10.0); } public function stop(): void @@ -94,35 +93,38 @@ public function stop(): void proc_terminate($this->process); proc_close($this->process); } - if (file_exists($this->captureFile)) { - unlink($this->captureFile); - } } - private function waitUntilListening(): void + private function readLine(float $timeout): string { - $deadline = microtime(true) + 2.0; - $buffer = ''; - while (microtime(true) < $deadline) { - $buffer .= (string) fread($this->stdout, 8192); - if (str_contains($buffer, "READY\n")) { - return; + $deadline = microtime(true) + $timeout; + + while (false === ($eol = strpos($this->buffer, "\n"))) { + $chunk = fread($this->stdout, 8192); + if (false !== $chunk && '' !== $chunk) { + $this->buffer .= $chunk; + + continue; } - usleep(10_000); + if (feof($this->stdout)) { + throw new \RuntimeException('Raw request listener exited early. stderr: '.$this->stderrTail()); + } + if (microtime(true) >= $deadline) { + throw new \RuntimeException('Timed out reading from the raw request listener. stderr: '.$this->stderrTail()); + } + usleep(5000); } - throw new \RuntimeException('Raw request listener did not start listening in time.'); + $line = substr($this->buffer, 0, $eol); + $this->buffer = substr($this->buffer, $eol + 1); + + return rtrim($line, "\r"); } - private static function reserveFreePort(): int + private function stderrTail(): string { - $server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); - if (!\is_resource($server)) { - throw new \RuntimeException("Failed to reserve a free port: {$errstr}"); - } - $name = stream_socket_get_name($server, false); - fclose($server); + $stderr = (string) @stream_get_contents($this->stderr); - return (int) substr($name, strrpos($name, ':') + 1); + return '' === $stderr ? '(empty)' : $stderr; } } diff --git a/test/Support/raw_request_listener.php b/test/Support/raw_request_listener.php index cd9feb3db..5ba564a77 100644 --- a/test/Support/raw_request_listener.php +++ b/test/Support/raw_request_listener.php @@ -3,41 +3,36 @@ /** * Standalone one-shot TCP listener used by RawRequestCapture. * - * Accepts a single connection, writes the raw request line + headers it - * received to the given file, replies with a minimal 200 so the client - * doesn't hang, then exits. + * Binds an ephemeral port, announces it on stdout, then accepts a single + * connection, echoes the raw request line it received back on stdout, + * replies with a minimal 200 so the client doesn't hang, and exits. * * @internal */ -[, $port, $captureFile] = $argv; -$server = stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr); +$server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr); if (!\is_resource($server)) { fwrite(STDERR, "listen failed: {$errstr}\n"); - exit(1); } -// Signal readiness over stdout rather than via a throwaway TCP connection, -// since this listener only ever accepts a single (the real) connection. -fwrite(STDOUT, "READY\n"); +$name = stream_socket_get_name($server, false); +fwrite(STDOUT, 'READY '.substr($name, strrpos($name, ':') + 1)."\n"); fflush(STDOUT); $conn = @stream_socket_accept($server, 5); if (\is_resource($conn)) { stream_set_timeout($conn, 2); - $request = ''; + + fwrite(STDOUT, rtrim((string) fgets($conn), "\r\n")."\n"); + fflush(STDOUT); + while (!feof($conn)) { $line = fgets($conn); - if (false === $line) { - break; - } - $request .= $line; - if ("\r\n" === $line || "\n" === $line) { + if (false === $line || "\r\n" === $line || "\n" === $line) { break; } } - file_put_contents($captureFile, $request); $body = '{}'; fwrite( From 6240b52d2c114ec0b3aaecffe4327c04d2ba5fe2 Mon Sep 17 00:00:00 2001 From: Orkun Date: Tue, 15 Sep 2026 09:19:18 +0300 Subject: [PATCH 6/6] refactor: linter --- test/Support/raw_request_listener.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Support/raw_request_listener.php b/test/Support/raw_request_listener.php index 5ba564a77..aa9835357 100644 --- a/test/Support/raw_request_listener.php +++ b/test/Support/raw_request_listener.php @@ -9,10 +9,10 @@ * * @internal */ - -$server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr); +$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); if (!\is_resource($server)) { fwrite(STDERR, "listen failed: {$errstr}\n"); + exit(1); }