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 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 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/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 f501266..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'; @@ -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/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 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 b30a84e..0f63480 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; @@ -16,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(); @@ -23,21 +27,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'], ); } @@ -133,6 +137,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(); @@ -234,13 +257,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