From a59e81bb229c76c7b3c1ed28cf96dfa08219908a Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:18:06 +0200 Subject: [PATCH 1/8] Strip CR and LF from the exposed x-sendy headers --- src/Connection.php | 14 +++++++++++++- tests/ConnectionTest.php | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Connection.php b/src/Connection.php index f501266..075af42 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -428,14 +428,26 @@ private function extractRateLimits(Response $response): void /** * Extract the x-sendy-* headers from the response. + * + * CR and LF are stripped from the values so consumers can safely emit + * these headers in their own HTTP responses without risking header + * injection. */ private function extractSendyHeaders(Response $response): void { - $this->sendyHeaders = array_filter( + $headers = array_filter( $response->getHeaders(), fn(string $key) => substr($key, 0, 8) === 'x-sendy-', ARRAY_FILTER_USE_KEY, ); + + $this->sendyHeaders = array_map( + fn(array $values) => array_map( + fn(string $value) => str_replace(["\r", "\n"], '', $value), + $values, + ), + $headers, + ); } /** diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index b30a84e..d2dc6e8 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -133,6 +133,25 @@ public function testParseResponseExtractsMeta(): void $this->assertInstanceOf(Meta::class, $connection->meta); } + public function testParseResponseExposesOnlySendyHeadersWithSanitizedValues(): void + { + $connection = new Connection(); + + $response = new Response(200, [ + 'content-type' => ['application/json'], + 'X-Sendy-Token' => ["secret\r\nx-injected: value"], + 'x-sendy-shipment-id' => ['123'], + 'x-ratelimit-remaining' => ['59'], + ], json_encode(['data' => []])); + + $connection->parseResponse($response, new Request('GET', '/foo')); + + $this->assertEquals([ + 'x-sendy-token' => ['secretx-injected: value'], + 'x-sendy-shipment-id' => ['123'], + ], $connection->sendyHeaders); + } + public function testParseResponseUnwrapsData(): void { $connection = new Connection(); From b0ff7fd6c131b6d0ba35f3945942c1c9d78d425f Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:18:06 +0200 Subject: [PATCH 2/8] Add Dependabot configuration --- .github/dependabot.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca50ddd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + groups: + composer-minor: + update-types: + - minor + - patch + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly From a43b17d9ab4c935e8c826e89209a33c9bb3de4d3 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:28:20 +0200 Subject: [PATCH 3/8] Update PHPStan --- composer.json | 2 +- src/Connection.php | 4 ++-- src/Http/Transport/MockTransport.php | 2 +- tests/ConnectionTest.php | 10 +++------- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index 4e20523..bd01e90 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ }, "require-dev": { "phpunit/phpunit": "^9.0", - "phpstan/phpstan": "^1", + "phpstan/phpstan": "^2.2", "mockery/mockery": "^1.5", "php-cs-fixer/shim": "^3.87", "psr/http-client": "^1.0", diff --git a/src/Connection.php b/src/Connection.php index 075af42..dfb03aa 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -63,8 +63,8 @@ class Connection /** @var mixed */ private $state = null; - /** @var callable($this) */ - private $tokenUpdateCallback; + /** @var (callable(self): void)|null */ + private $tokenUpdateCallback = null; private bool $oauthClient = false; diff --git a/src/Http/Transport/MockTransport.php b/src/Http/Transport/MockTransport.php index 15ea0b5..1c00337 100644 --- a/src/Http/Transport/MockTransport.php +++ b/src/Http/Transport/MockTransport.php @@ -7,7 +7,7 @@ class MockTransport implements TransportInterface { - private ?Response $response; + private Response $response; /** * @var list diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index d2dc6e8..050e11d 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -6,7 +6,6 @@ use Sendy\Api\ApiException; use Sendy\Api\Connection; use Sendy\Api\Exceptions\ClientException; -use Sendy\Api\Exceptions\SendyException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; use Sendy\Api\Http\Transport\MockTransport; @@ -253,13 +252,10 @@ public function testRevokedRefreshTokenIsHandled(): void $connection->setRefreshToken('RefreshToken'); $connection->setTokenExpires(time() + 5); - try { - $connection->checkOrAcquireAccessToken(); - } catch (SendyException $exception) { - $this->fail($exception->getMessage()); - } + $connection->checkOrAcquireAccessToken(); - $this->assertTrue(true); + $this->assertSame('accessToken', $connection->getAccessToken()); + $this->assertSame('RefreshToken', $connection->getRefreshToken()); } public function testUnexpectedExceptionWhenRefreshingTokensAreHandled(): void From e905cf7ca0c9cf6b1a032063662f35d16f8a4c63 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:40:17 +0200 Subject: [PATCH 4/8] Sanitize the header values in the Response constructor --- src/Connection.php | 14 +------------- src/Http/Response.php | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Connection.php b/src/Connection.php index dfb03aa..222aa1d 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -428,26 +428,14 @@ private function extractRateLimits(Response $response): void /** * Extract the x-sendy-* headers from the response. - * - * CR and LF are stripped from the values so consumers can safely emit - * these headers in their own HTTP responses without risking header - * injection. */ private function extractSendyHeaders(Response $response): void { - $headers = array_filter( + $this->sendyHeaders = array_filter( $response->getHeaders(), fn(string $key) => substr($key, 0, 8) === 'x-sendy-', ARRAY_FILTER_USE_KEY, ); - - $this->sendyHeaders = array_map( - fn(array $values) => array_map( - fn(string $value) => str_replace(["\r", "\n"], '', $value), - $values, - ), - $headers, - ); } /** diff --git a/src/Http/Response.php b/src/Http/Response.php index 0717c9e..5a81f29 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -88,11 +88,24 @@ final class Response public function __construct(int $statusCode, array $headers, string $body) { $this->statusCode = $statusCode; - $this->headers = array_map( - fn($value) => is_array($value) ? $value : [$value], + $this->headers = $this->normalizeHeaders($headers); + $this->body = $body; + } + + /** + * Lowercase the header names and strip CR and LF from the values, so + * consumers can safely emit headers in their own HTTP responses without + * risking header injection. + * + * @param array|string> $headers + * @return array> + */ + private function normalizeHeaders(array $headers): array + { + return array_map( + fn($value) => str_replace(["\r", "\n"], '', is_array($value) ? $value : [$value]), array_change_key_case($headers, CASE_LOWER), ); - $this->body = $body; } public function getStatusCode(): int From d2b878b6f46a4a31105d6d2f809d0e8a57fd2e22 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:43:31 +0200 Subject: [PATCH 5/8] Add PHP 8.5 to the test matrix --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b6ec473..6ee9820 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: needs: [ install_dependencies ] strategy: matrix: - php-versions: [ "7.4", "8.0", "8.1", "8.2", "8.3", "8.4" ] + php-versions: [ "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5" ] runs-on: ubuntu-latest From 312114d076dd2dd22ece9dccf4ef6a96c8230fcc Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:46:29 +0200 Subject: [PATCH 6/8] 3.0.1 --- src/Connection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Connection.php b/src/Connection.php index 222aa1d..16133b3 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -24,7 +24,7 @@ */ class Connection { - public const VERSION = '3.0.0'; + public const VERSION = '3.0.1'; public const BASE_URL = 'https://app.sendy.nl'; From 8008ea34aeaaab13f7625f75be5db53df01c52c6 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:49:50 +0200 Subject: [PATCH 7/8] Build the user agent assertions from the version constant --- tests/ConnectionTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 050e11d..0eceb8b 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -22,21 +22,21 @@ public function testUserAgentIsSet(): void $connection = $this->createConnection(); $this->assertEquals( - "SendySDK/3.0.0 PHP/{$phpVersion} curl/{$curlVersion}", + "SendySDK/" . Connection::VERSION . " PHP/{$phpVersion} curl/{$curlVersion}", $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); $connection = $this->createConnection(); $connection->setUserAgentAppendix('WooCommerce/6.2'); $this->assertEquals( - "SendySDK/3.0.0 PHP/{$phpVersion} curl/{$curlVersion} WooCommerce/6.2", + "SendySDK/" . Connection::VERSION . " PHP/{$phpVersion} curl/{$curlVersion} WooCommerce/6.2", $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); $connection = $this->createConnection(); $connection->setOauthClient(true); $this->assertEquals( - "SendySDK/3.0.0 PHP/{$phpVersion} OAuth/2.0 curl/{$curlVersion}", + "SendySDK/" . Connection::VERSION . " PHP/{$phpVersion} OAuth/2.0 curl/{$curlVersion}", $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); } From 872713dd02308f2b2a12ffac67c2c07a6f903c9e Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 15 Jul 2026 11:58:40 +0200 Subject: [PATCH 8/8] Guard the version number format with a test --- bump_version.sh | 2 +- tests/ConnectionTest.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/bump_version.sh b/bump_version.sh index 8872e6e..a0e3169 100755 --- a/bump_version.sh +++ b/bump_version.sh @@ -17,7 +17,7 @@ new_version=${1#v} echo "New version: $new_version" -if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9](-[a-z]+\.[0-9]+)?$ ]] +if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$ ]] then echo "Invalid version format. Please use semantic versioning (https://semver.org/)." exit 1 diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 0eceb8b..0f63480 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -15,6 +15,11 @@ class ConnectionTest extends TestCase { + public function testVersionUsesSemverFormat(): void + { + $this->assertMatchesRegularExpression('/^\d+\.\d+\.\d+(-[a-z]+\.\d+)?$/', Connection::VERSION); + } + public function testUserAgentIsSet(): void { $phpVersion = phpversion();