From 23d329c45f12a7235deda145e643c9a83c591537 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 8 Oct 2026 21:23:17 -0700 Subject: [PATCH 1/3] Fix SendCloud request construction and add audited SDK 2.0 test matrix --- .github/workflows/tests.yml | 37 +++++ CHANGELOG.md | 29 ++++ README.md | 58 +++++++- composer.json | 8 +- src/SendCloud.php | 84 +++++++++-- tests/lint.php | 12 ++ tests/run.php | 279 ++++++++++++++++++++++++++++++++++++ 7 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 tests/lint.php create mode 100644 tests/run.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d9b66ae --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + php: ['7.2', '7.4', '8.2', '8.4', '8.5'] + dependencies: [lowest, highest] + name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }} + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json, simplexml, libxml + tools: composer:v2 + coverage: none + - run: composer validate --strict + - name: Install dependencies + run: | + if [ '${{ matrix.dependencies }}' = 'lowest' ]; then + composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction + else + composer update --prefer-stable --prefer-dist --no-interaction + fi + - run: composer audit + - run: composer lint + - run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3302d53 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## 2.0.0 (unreleased) + +### Breaking changes + +- Require PHP 7.2.5+ (PHP 7 or 8) and Guzzle 7.15.2+. The previous dependency + range included Guzzle 6 and older PHP releases. Current Composer advisory + checks cannot resolve that legacy combination without exceptions. +- Require `overtrue/http` 1.2.3+, the first 1.x release containing both the + PHP 8 collection compatibility and resource-upload corrections used here. +- Endpoint paths beginning with a single slash now resolve under the configured + API base path, matching the documented `/mail/send` usage. Callers that need an + origin-root path must supply an absolute URL. + +### Fixed + +- Use HTTPS by default and honor an explicitly configured base URI. +- Add POST authentication to URL-encoded form bodies or multipart fields. +- Preserve existing query parameters and form values, including duplicate and + encoded values; use correctly encoded authentication values. +- Remove the obsolete PSR-7 `modify_request()` dependency. +- Retain response casting, API-level failure responses, transport/HTTP exceptions, + and asynchronous behavior. + +### Tests + +- Add mocked outgoing-request regressions, syntax checks, dependency audits, and + lowest/latest dependency CI for PHP 7.2, 7.4, 8.2, 8.4, and 8.5. diff --git a/README.md b/README.md index 1317c5c..53dd7a4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ $apiKey = 'UWoBGa2sgxyxxxxxxxx'; $client = new SendCloud($apiUser, $apiKey); -$result = $client->post('/mail/send', [ +$result = $client->post('mail/send', [ 'from' => 'overtrue@domain.sendcloud.org', 'to' => 'demo@easywechat.com', 'subject' => '来自 SendCloud 的第一封邮件!', @@ -40,6 +40,62 @@ var_dump($result); ``` +## Requirements and compatibility + +Version 2 requires PHP 7.2.5+ (PHP 7 or 8), Guzzle 7.15.2+, and +`overtrue/http` 1.2.3+. Use a currently supported PHP release for production. +The PHP minimum and Guzzle 7 requirement are breaking changes from version 1. + +Requests use `https://api.sendcloud.net/apiv2/` by default. Endpoint paths such +as `mail/send` and `/mail/send` are resolved under this base path. An explicitly +configured `base_uri` is honored; include its trailing slash. Absolute URLs +retain Guzzle's normal behavior. Only use trusted endpoints with your API keys. + +```php +$client = new SendCloud($apiUser, $apiKey, [ + 'base_uri' => 'https://api.sendcloud.net/apiv2/', + 'response_type' => 'array', +]); +``` + +POST form requests carry authentication in the form body. Use `upload()` for +attachments; authentication is added as multipart fields without URL-encoding +those field values. GET authentication is added to the query without removing +other parameters. Non-form request bodies are left unchanged and authentication +is added to their query. Response types and exception behavior remain those of +`overtrue/http`; API-level failures are not converted into exceptions. + +```php +$result = $client->upload('mail/send', [ + 'attachments' => '/path/to/report.pdf', +], [ + 'from' => 'sender@example.com', + 'to' => 'recipient@example.com', + 'subject' => 'Report', + 'html' => 'Please see the attachment.', +]); + +$result = $client->postAsync('mail/send', $message)->wait(); +``` + +See SendCloud's [delivery contract](https://www.sendcloud.net/doc/en/email_v2/send_email/) +and [GET API contract](https://www.sendcloud.net/doc/en/email_v2/apiuser_do/). + +## Tests + +```shell +composer install +composer validate --strict +composer test +composer lint +composer audit +``` + +The dependency-free regression runner uses Guzzle's `MockHandler` and synthetic +credentials. It retains the real authentication middleware and sends no email or +live API requests. CI tests lowest and latest installable dependencies without +disabling Composer's advisory checks. + ## License MIT diff --git a/composer.json b/composer.json index e46fa61..7544a48 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,12 @@ } }, "require": { - "overtrue/http": "^1.0" + "php": "^7.2.5 || ^8.0", + "guzzlehttp/guzzle": "^7.15.2", + "overtrue/http": "^1.2.3" + }, + "scripts": { + "test": "@php tests/run.php", + "lint": "@php tests/lint.php" } } diff --git a/src/SendCloud.php b/src/SendCloud.php index 84d4100..f9f4674 100644 --- a/src/SendCloud.php +++ b/src/SendCloud.php @@ -10,10 +10,13 @@ namespace Overtrue\SendCloud; +use GuzzleHttp\Psr7\AppendStream; +use GuzzleHttp\Psr7\LimitStream; +use GuzzleHttp\Psr7\MultipartStream; +use GuzzleHttp\Psr7\Stream; use Overtrue\Http\Client; use Overtrue\Http\Config; use Psr\Http\Message\RequestInterface; -use function GuzzleHttp\Psr7\modify_request; /** * Class SendCloud. @@ -22,7 +25,7 @@ */ class SendCloud extends Client { - const BASE_URI = 'http://api.sendcloud.net/apiv2/'; + const BASE_URI = 'https://api.sendcloud.net/apiv2/'; /** * @var string @@ -46,7 +49,7 @@ public function __construct(string $apiUser, string $apiKey, array $config = []) $this->apiUser = $apiUser; $this->apiKey = $apiKey; - $config['base_uri'] = self::BASE_URI; + $config += ['base_uri' => self::BASE_URI]; parent::__construct(new Config($config)); @@ -60,19 +63,82 @@ public function authorizationParamsMiddleware() { return function (callable $handler) { return function (RequestInterface $request, array $options) use ($handler) { - $credential = http_build_query([ + $credentials = [ 'apiUser' => $this->apiUser, - 'apiKey' => $this->apiKey, - ]); + 'apiKey' => $this->apiKey, + ]; + $contentType = strtolower(explode(';', $request->getHeaderLine('Content-Type'))[0]); + $body = $request->getBody(); - if ('post' == $request->getMethod()) { - $request = modify_request($request, ['body' => $request->getBody()->getContents().'&'.$credential]); + if ('POST' === strtoupper($request->getMethod()) && $body instanceof MultipartStream) { + $parts = []; + foreach ($credentials as $name => $contents) { + $parts[] = compact('name', 'contents'); + } + $auth = new MultipartStream($parts, $body->getBoundary()); + // Keep the original multipart stream and its closing boundary intact. + $prefix = new LimitStream($auth, $auth->getSize() - strlen('--'.$body->getBoundary()."--\r\n")); + $request = $request->withBody(new AppendStream([$prefix, $body])); + $request = $this->withBodyLength($request); + } elseif ('POST' === strtoupper($request->getMethod()) + && ('application/x-www-form-urlencoded' === $contentType || ('' === $contentType && 0 === $body->getSize()))) { + $stream = new Stream(fopen('php://temp', 'r+')); + $stream->write($this->withCredentials((string) $body, $credentials)); + $stream->rewind(); + $request = $request->withBody($stream)->withHeader('Content-Type', 'application/x-www-form-urlencoded'); + $request = $this->withBodyLength($request); } else { - $request = $request->withUri($request->getUri()->withQuery($credential)); + // Leave non-form bodies untouched, preserving unrelated query parameters. + $request = $request->withUri($request->getUri()->withQuery( + $this->withCredentials($request->getUri()->getQuery(), $credentials) + )); } return $handler($request, $options); }; }; } + + /** + * Treat API endpoint paths with a leading slash like their relative form. + */ + public function requestRaw(string $uri, string $method = 'GET', array $options = [], bool $async = false) + { + if (isset($uri[0]) && '/' === $uri[0] && substr($uri, 0, 2) !== '//') { + $uri = ltrim($uri, '/'); + } + + if ('POST' === strtoupper($method) && isset($options['multipart'])) { + $options['multipart'] = array_values(array_filter($options['multipart'], function ($part) { + return !isset($part['name']) || !in_array($part['name'], ['apiUser', 'apiKey'], true); + })); + } + + return parent::requestRaw($uri, $method, $options, $async); + } + + /** + * Preserve encoded values and repeated parameters while replacing credentials. + */ + protected function withCredentials(string $query, array $credentials): string + { + $parameters = array_filter(explode('&', $query), function ($parameter) use ($credentials) { + return '' !== $parameter && !array_key_exists(urldecode(explode('=', $parameter, 2)[0]), $credentials); + }); + $parameters[] = http_build_query($credentials, '', '&', PHP_QUERY_RFC3986); + + return implode('&', $parameters); + } + + protected function withBodyLength(RequestInterface $request): RequestInterface + { + $request = $request->withoutHeader('Content-Length'); + $size = $request->getBody()->getSize(); + + if (null !== $size && !$request->hasHeader('Transfer-Encoding')) { + $request = $request->withHeader('Content-Length', (string) $size); + } + + return $request; + } } diff --git a/tests/lint.php b/tests/lint.php new file mode 100644 index 0000000..fabba85 --- /dev/null +++ b/tests/lint.php @@ -0,0 +1,12 @@ +isFile() && 'php' === $file->getExtension()) { + passthru(escapeshellarg(PHP_BINARY).' -l '.escapeshellarg($file->getPathname()), $status); + $failed = $failed || 0 !== $status; + } + } +} +exit($failed ? 1 : 0); diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..7c71ec8 --- /dev/null +++ b/tests/run.php @@ -0,0 +1,279 @@ +getHandlerStack()->setHandler(new MockHandler($responses ?: [new Response(200, [], '{"result":true}')])); + $client->getHandlerStack()->push(Middleware::history($history)); + + return $client; +} + +function form($request) +{ + parse_str((string) $request->getBody(), $form); + return $form; +} + +function auth(array $parameters) +{ + same(API_USER, $parameters['apiUser']); + same(API_KEY, $parameters['apiKey']); +} + +function lengthMatches($request) +{ + same((string) strlen((string) $request->getBody()), $request->getHeaderLine('Content-Length')); +} + +$tests = []; +$tests['HTTPS, documented path and POST body authentication'] = function () { + $c = client($history); + same(['result' => true], $c->post('/mail/send?trace=one', ['subject' => '你好 +&= 100%', 'html' => 'hello'])); + $r = $history[0]['request']; + same('https://api.sendcloud.net/apiv2/mail/send?trace=one', (string) $r->getUri()); + same('POST', $r->getMethod()); + auth(form($r)); + same('你好 +&= 100%', form($r)['subject']); + same('hello', form($r)['html']); + same('application/x-www-form-urlencoded', $r->getHeaderLine('Content-Type')); + lengthMatches($r); +}; +$tests['relative paths and custom base URI'] = function () { + $c = client($history, ['base_uri' => 'https://example.test/custom/']); + $c->post('mail/send', []); + same('https://example.test/custom/mail/send', (string) $history[0]['request']->getUri()); + auth(form($history[0]['request'])); +}; +$tests['absolute URLs retain Guzzle resolution'] = function () { + $c = client($history); + $c->get('https://example.test/exact?x=1'); + same('example.test', $history[0]['request']->getUri()->getHost()); + same('/exact', $history[0]['request']->getUri()->getPath()); +}; +$tests['GET preserves repeated and encoded parameters'] = function () { + $c = client($history); + $c->get('/label/list?x=1&x=2&value=a%2Bb%20c&apiUser=wrong&apiKey=wrong'); + $q = $history[0]['request']->getUri()->getQuery(); + check(0 === strpos($q, 'x=1&x=2&value=a%2Bb%20c&'), 'Query bytes changed'); + parse_str($q, $query); + auth($query); + same(1, substr_count($q, 'apiUser=')); + same(1, substr_count($q, 'apiKey=')); +}; +$tests['Guzzle query option retains data'] = function () { + $c = client($history); + $c->get('label/list', ['query' => ['labelId' => 42, 'name' => '测试 +']]); + parse_str($history[0]['request']->getUri()->getQuery(), $query); + auth($query); + same('42', $query['labelId']); + same('测试 +', $query['name']); +}; +$tests['form credentials replaced without losing duplicate values'] = function () { + $c = client($history); + $c->request('mail/send', 'post', ['body' => 'x=1&x=2&api%55ser=wrong&apiKey=wrong', 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8']]); + $r = $history[0]['request']; + check(0 === strpos((string) $r->getBody(), 'x=1&x=2&'), 'Duplicate form values lost'); + auth(form($r)); + same(1, substr_count((string) $r->getBody(), 'apiUser=')); + lengthMatches($r); +}; +$tests['empty POST becomes authenticated form'] = function () { + $c = client($history); + $c->requestRaw('mail/send', 'POST'); + auth(form($history[0]['request'])); + same('', $history[0]['request']->getUri()->getQuery()); +}; +$tests['multipart attachment and credentials use one valid boundary'] = function () { + $c = client($history); + $file = fopen('php://temp', 'r+'); + fwrite($file, "binary\0attachment\r\n你好"); + rewind($file); + $c->upload('/mail/send?trace=1', ['attachments' => $file], ['subject' => '你好', 'apiUser' => 'wrong', 'apiKey' => 'wrong']); + $r = $history[0]['request']; + $body = (string) $r->getBody(); + preg_match('/boundary=(.*)/', $r->getHeaderLine('Content-Type'), $match); + $boundary = $match[1]; + same(1, substr_count($body, '--'.$boundary."--\r\n")); + same(1, substr_count($body, 'name="apiUser"')); + same(1, substr_count($body, 'name="apiKey"')); + check(false !== strpos($body, "\r\n\r\n".API_USER."\r\n"), 'Missing raw multipart user'); + check(false !== strpos($body, "\r\n\r\n".API_KEY."\r\n"), 'Missing raw multipart key'); + check(false !== strpos($body, "binary\0attachment\r\n你好"), 'Attachment changed'); + same('trace=1', $r->getUri()->getQuery()); + lengthMatches($r); + fclose($file); +}; +$tests['JSON remains unchanged, authentication preserves query'] = function () { + $c = client($history); + $c->request('custom?trace=1', 'POST', ['json' => ['value' => '你好']]); + $r = $history[0]['request']; + same(['value' => '你好'], json_decode((string) $r->getBody(), true)); + parse_str($r->getUri()->getQuery(), $q); + auth($q); + same('1', $q['trace']); +}; +$tests['repeated calls never accumulate authentication or lose payload'] = function () { + $c = client($history, [], [new Response(200, [], '{}'), new Response(200, [], '{}')]); + for ($i = 0; $i < 2; ++$i) { + $c->post('/mail/send', ['subject' => 'repeat']); + $r = $history[$i]['request']; + auth(form($r)); + same('repeat', form($r)['subject']); + same(1, substr_count((string) $r->getBody(), 'apiKey=')); + } + same((string) $history[0]['request']->getBody(), (string) $history[1]['request']->getBody()); +}; +foreach (['array', 'object', 'collection', 'raw'] as $type) { + $tests['response type '.$type.' remains unchanged'] = function () use ($type) { + $c = client($history, ['response_type' => $type]); + $result = $c->post('mail/send'); + if ('array' === $type) { + same(['result' => true], $result); + } elseif ('object' === $type) { + same(true, $result->result); + } elseif ('collection' === $type) { + check($result instanceof Collection, 'Not a collection'); + same(true, $result['result']); + } else { + check($result instanceof ResponseInterface, 'Not a raw response'); + same('{"result":true}', (string) $result->getBody()); + } + }; +} +$tests['API-level failure stays a response'] = function () { + $payload = ['result' => false, 'statusCode' => 40001, 'message' => 'synthetic failure']; + $c = client($history, [], [new Response(200, [], json_encode($payload))]); + same($payload, $c->post('mail/send')); +}; +$tests['HTTP failure propagates'] = function () { + $c = client($history, [], [new Response(400, [], '{"result":false}')]); + try { + $c->post('mail/send'); + } catch (ClientException $e) { + same(400, $e->getResponse()->getStatusCode()); + return; + } + throw new RuntimeException('Expected ClientException'); +}; +$tests['http_errors option remains configurable'] = function () { + $c = client($history, [], [new Response(400, [], '{"result":false}')]); + same(['result' => false], $c->post('mail/send', [], ['http_errors' => false])); +}; +$tests['transport errors propagate'] = function () { + $error = new ConnectException('synthetic connection failure', new Request('POST', 'https://example.test/')); + $c = client($history, [], [$error]); + try { + $c->post('mail/send'); + } catch (ConnectException $e) { + same($error, $e); + return; + } + throw new RuntimeException('Expected ConnectException'); +}; +$tests['async POST returns promise and authenticates'] = function () { + $c = client($history); + $promise = $c->postAsync('/mail/send', ['subject' => 'async']); + check($promise instanceof PromiseInterface, 'Not a promise'); + same(['result' => true], $promise->wait()); + auth(form($history[0]['request'])); +}; +$tests['async GET and raw response'] = function () { + $c = client($history, ['response_type' => 'raw']); + $result = $c->getAsync('/label/list?labelId=1')->wait(); + check($result instanceof ResponseInterface, 'Not a raw response'); + parse_str($history[0]['request']->getUri()->getQuery(), $q); + auth($q); + same('1', $q['labelId']); +}; +$tests['async HTTP errors reject'] = function () { + $c = client($history, [], [new Response(400)]); + try { + $c->postAsync('mail/send')->wait(); + } catch (ClientException $e) { + same(400, $e->getResponse()->getStatusCode()); + return; + } + throw new RuntimeException('Expected async rejection'); +}; + +$tests['async multipart preserves form and file bytes'] = function () { + $c = client($history); + $file = fopen('php://temp', 'r+'); + fwrite($file, 'async attachment'); + rewind($file); + same(['result' => true], $c->uploadAsync('mail/send', ['attachments' => $file], ['subject' => 'async'])->wait()); + $r = $history[0]['request']; + check(false !== strpos((string) $r->getBody(), 'async attachment'), 'Async file missing'); + check(false !== strpos((string) $r->getBody(), API_KEY), 'Async multipart authentication missing'); + same('', $r->getUri()->getQuery()); + lengthMatches($r); + fclose($file); +}; +$tests['partially read form streams retain the full encoded body'] = function () { + $c = client($history); + $stream = new GuzzleHttp\Psr7\Stream(fopen('php://temp', 'r+')); + $stream->write('subject=hello&x=1'); + $stream->rewind(); + $stream->read(5); + $c->request('mail/send', 'POST', ['body' => $stream, 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded']]); + $r = $history[0]['request']; + auth(form($r)); + same('hello', form($r)['subject']); + same('1', form($r)['x']); + lengthMatches($r); +}; + +$failures = 0; +foreach ($tests as $name => $test) { + try { + $test(); + echo "PASS: $name\n"; + } catch (Throwable $e) { + ++$failures; + fwrite(STDERR, "FAIL: $name\n".$e."\n"); + } +} +echo count($tests).' tests, '.$failures." failures\n"; +exit($failures ? 1 : 0); From 9bba224c635ca7411b75433b9a5f9976fa14ef97 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 8 Oct 2026 21:27:29 -0700 Subject: [PATCH 2/3] Guard redirect origins and expand SDK regression coverage --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 9 +- README.md | 20 +++- src/SendCloud.php | 51 +++++++++- tests/run.php | 195 ++++++++++++++++++++++++++++-------- 5 files changed, 229 insertions(+), 48 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d9b66ae..564996f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.2', '7.4', '8.2', '8.4', '8.5'] + php: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] dependencies: [lowest, highest] name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3302d53..b452ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ API base path, matching the documented `/mail/send` usage. Callers that need an origin-root path must supply an absolute URL. +- Redirect following is disabled by default. Explicitly enabled redirects must + remain on the initial scheme, host, and port; cross-origin/downgrade redirects + throw before a second request is sent. + +- Prebuilt multipart bodies and low-level HTTP-client multipart calls are rejected; + use the SDK `upload()` or `request()` multipart option instead. + ### Fixed - Use HTTPS by default and honor an explicitly configured base URI. @@ -26,4 +33,4 @@ ### Tests - Add mocked outgoing-request regressions, syntax checks, dependency audits, and - lowest/latest dependency CI for PHP 7.2, 7.4, 8.2, 8.4, and 8.5. + lowest/latest dependency CI for PHP 7.2–7.4 and 8.0–8.5. diff --git a/README.md b/README.md index 53dd7a4..88b85b4 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,14 @@ $apiKey = 'UWoBGa2sgxyxxxxxxxx'; $client = new SendCloud($apiUser, $apiKey); -$result = $client->post('mail/send', [ +$message = [ 'from' => 'overtrue@domain.sendcloud.org', 'to' => 'demo@easywechat.com', 'subject' => '来自 SendCloud 的第一封邮件!', 'html' => '你太棒了!你已成功的 从 SendCloud 发送了一封测试邮件!', -]); +]; + +$result = $client->post('mail/send', $message); var_dump($result); @@ -49,7 +51,14 @@ The PHP minimum and Guzzle 7 requirement are breaking changes from version 1. Requests use `https://api.sendcloud.net/apiv2/` by default. Endpoint paths such as `mail/send` and `/mail/send` are resolved under this base path. An explicitly configured `base_uri` is honored; include its trailing slash. Absolute URLs -retain Guzzle's normal behavior. Only use trusted endpoints with your API keys. +retain Guzzle's normal behavior. Only use trusted endpoints with your API keys. Prefer HTTPS for every configured +or absolute endpoint; an explicitly supplied HTTP URL is not upgraded. + +Redirects are disabled by default. If you explicitly set `allow_redirects` to +`true` or a Guzzle redirect-options array, redirects must retain the original +scheme, host, and port. Cross-origin redirects and HTTPS-to-HTTP downgrades throw +a request exception before credentials or payloads can be forwarded. Existing +`on_redirect` callbacks are called only after this check passes. ```php $client = new SendCloud($apiUser, $apiKey, [ @@ -60,7 +69,10 @@ $client = new SendCloud($apiUser, $apiKey, [ POST form requests carry authentication in the form body. Use `upload()` for attachments; authentication is added as multipart fields without URL-encoding -those field values. GET authentication is added to the query without removing +those field values. You can also pass the `multipart` option to the SDK's +`request()` method. Prebuilt multipart bodies and low-level HTTP-client multipart +calls are rejected; use these SDK entry points so caller-supplied authentication +fields can be replaced before encoding. GET authentication is added to the query without removing other parameters. Non-form request bodies are left unchanged and authentication is added to their query. Response types and exception behavior remain those of `overtrue/http`; API-level failures are not converted into exceptions. diff --git a/src/SendCloud.php b/src/SendCloud.php index f9f4674..af910be 100644 --- a/src/SendCloud.php +++ b/src/SendCloud.php @@ -10,6 +10,8 @@ namespace Overtrue\SendCloud; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\AppendStream; use GuzzleHttp\Psr7\LimitStream; use GuzzleHttp\Psr7\MultipartStream; @@ -17,6 +19,8 @@ use Overtrue\Http\Client; use Overtrue\Http\Config; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; /** * Class SendCloud. @@ -49,7 +53,7 @@ public function __construct(string $apiUser, string $apiKey, array $config = []) $this->apiUser = $apiUser; $this->apiKey = $apiKey; - $config += ['base_uri' => self::BASE_URI]; + $config += ['base_uri' => self::BASE_URI, 'allow_redirects' => false]; parent::__construct(new Config($config)); @@ -67,10 +71,13 @@ public function authorizationParamsMiddleware() 'apiUser' => $this->apiUser, 'apiKey' => $this->apiKey, ]; - $contentType = strtolower(explode(';', $request->getHeaderLine('Content-Type'))[0]); + $contentType = strtolower(trim(explode(';', $request->getHeaderLine('Content-Type'))[0])); $body = $request->getBody(); if ('POST' === strtoupper($request->getMethod()) && $body instanceof MultipartStream) { + if (empty($options['sendcloud_multipart'])) { + throw new \InvalidArgumentException('Use SendCloud::upload() or its multipart request option instead of a prebuilt multipart body.'); + } $parts = []; foreach ($credentials as $name => $contents) { $parts[] = compact('name', 'contents'); @@ -99,6 +106,44 @@ public function authorizationParamsMiddleware() }; } + /** + * Guard redirects before Guzzle follows them or authentication is added again. + */ + public function getHandlerStack(): HandlerStack + { + $needsGuard = null === $this->handlerStack; + $stack = parent::getHandlerStack(); + + if ($needsGuard) { + $stack->before('allow_redirects', function (callable $handler) { + return function (RequestInterface $request, array $options) use ($handler) { + $redirects = $options['allow_redirects'] ?? false; + if (true === $redirects || (is_array($redirects) && $redirects)) { + $redirects = true === $redirects ? [] : $redirects; + $callback = $redirects['on_redirect'] ?? null; + $origin = $request->getUri(); + $redirects['on_redirect'] = function (RequestInterface $request, ResponseInterface $response, UriInterface $target) use ($origin, $callback) { + if ($origin->getScheme() !== $target->getScheme() + || $origin->getHost() !== $target->getHost() + || $origin->getPort() !== $target->getPort() + || $origin->getUserInfo() !== $target->getUserInfo()) { + throw new RequestException('SendCloud redirects must stay on the original origin.', $request, $response); + } + if (null !== $callback) { + $callback($request, $response, $target); + } + }; + $options['allow_redirects'] = $redirects; + } + + return $handler($request, $options); + }; + }, 'sendcloud_redirect_guard'); + } + + return $stack; + } + /** * Treat API endpoint paths with a leading slash like their relative form. */ @@ -108,7 +153,9 @@ public function requestRaw(string $uri, string $method = 'GET', array $options = $uri = ltrim($uri, '/'); } + unset($options['sendcloud_multipart']); if ('POST' === strtoupper($method) && isset($options['multipart'])) { + $options['sendcloud_multipart'] = true; $options['multipart'] = array_values(array_filter($options['multipart'], function ($part) { return !isset($part['name']) || !in_array($part['name'], ['apiUser', 'apiKey'], true); })); diff --git a/tests/run.php b/tests/run.php index 7c71ec8..5bfa48b 100644 --- a/tests/run.php +++ b/tests/run.php @@ -68,7 +68,16 @@ function lengthMatches($request) } $tests = []; -$tests['HTTPS, documented path and POST body authentication'] = function () { +function addTest($name, Closure $test) +{ + global $tests; + if (array_key_exists($name, $tests)) { + throw new RuntimeException('Duplicate test name: '.$name); + } + $tests[$name] = $test; +} + +addTest('HTTPS, documented path and POST body authentication', function () { $c = client($history); same(['result' => true], $c->post('/mail/send?trace=one', ['subject' => '你好 +&= 100%', 'html' => 'hello'])); $r = $history[0]['request']; @@ -79,20 +88,20 @@ function lengthMatches($request) same('hello', form($r)['html']); same('application/x-www-form-urlencoded', $r->getHeaderLine('Content-Type')); lengthMatches($r); -}; -$tests['relative paths and custom base URI'] = function () { +}); +addTest('relative paths and custom base URI', function () { $c = client($history, ['base_uri' => 'https://example.test/custom/']); $c->post('mail/send', []); same('https://example.test/custom/mail/send', (string) $history[0]['request']->getUri()); auth(form($history[0]['request'])); -}; -$tests['absolute URLs retain Guzzle resolution'] = function () { +}); +addTest('absolute URLs retain Guzzle resolution', function () { $c = client($history); $c->get('https://example.test/exact?x=1'); same('example.test', $history[0]['request']->getUri()->getHost()); same('/exact', $history[0]['request']->getUri()->getPath()); -}; -$tests['GET preserves repeated and encoded parameters'] = function () { +}); +addTest('GET preserves repeated and encoded parameters', function () { $c = client($history); $c->get('/label/list?x=1&x=2&value=a%2Bb%20c&apiUser=wrong&apiKey=wrong'); $q = $history[0]['request']->getUri()->getQuery(); @@ -101,16 +110,16 @@ function lengthMatches($request) auth($query); same(1, substr_count($q, 'apiUser=')); same(1, substr_count($q, 'apiKey=')); -}; -$tests['Guzzle query option retains data'] = function () { +}); +addTest('Guzzle query option retains data', function () { $c = client($history); $c->get('label/list', ['query' => ['labelId' => 42, 'name' => '测试 +']]); parse_str($history[0]['request']->getUri()->getQuery(), $query); auth($query); same('42', $query['labelId']); same('测试 +', $query['name']); -}; -$tests['form credentials replaced without losing duplicate values'] = function () { +}); +addTest('form credentials replaced without losing duplicate values', function () { $c = client($history); $c->request('mail/send', 'post', ['body' => 'x=1&x=2&api%55ser=wrong&apiKey=wrong', 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8']]); $r = $history[0]['request']; @@ -118,14 +127,14 @@ function lengthMatches($request) auth(form($r)); same(1, substr_count((string) $r->getBody(), 'apiUser=')); lengthMatches($r); -}; -$tests['empty POST becomes authenticated form'] = function () { +}); +addTest('empty POST becomes authenticated form', function () { $c = client($history); $c->requestRaw('mail/send', 'POST'); auth(form($history[0]['request'])); same('', $history[0]['request']->getUri()->getQuery()); -}; -$tests['multipart attachment and credentials use one valid boundary'] = function () { +}); +addTest('multipart attachment and credentials use one valid boundary', function () { $c = client($history); $file = fopen('php://temp', 'r+'); fwrite($file, "binary\0attachment\r\n你好"); @@ -144,8 +153,8 @@ function lengthMatches($request) same('trace=1', $r->getUri()->getQuery()); lengthMatches($r); fclose($file); -}; -$tests['JSON remains unchanged, authentication preserves query'] = function () { +}); +addTest('JSON remains unchanged, authentication preserves query', function () { $c = client($history); $c->request('custom?trace=1', 'POST', ['json' => ['value' => '你好']]); $r = $history[0]['request']; @@ -153,8 +162,8 @@ function lengthMatches($request) parse_str($r->getUri()->getQuery(), $q); auth($q); same('1', $q['trace']); -}; -$tests['repeated calls never accumulate authentication or lose payload'] = function () { +}); +addTest('repeated calls never accumulate authentication or lose payload', function () { $c = client($history, [], [new Response(200, [], '{}'), new Response(200, [], '{}')]); for ($i = 0; $i < 2; ++$i) { $c->post('/mail/send', ['subject' => 'repeat']); @@ -164,9 +173,9 @@ function lengthMatches($request) same(1, substr_count((string) $r->getBody(), 'apiKey=')); } same((string) $history[0]['request']->getBody(), (string) $history[1]['request']->getBody()); -}; +}); foreach (['array', 'object', 'collection', 'raw'] as $type) { - $tests['response type '.$type.' remains unchanged'] = function () use ($type) { + addTest('response type '.$type.' remains unchanged', function () use ($type) { $c = client($history, ['response_type' => $type]); $result = $c->post('mail/send'); if ('array' === $type) { @@ -180,14 +189,14 @@ function lengthMatches($request) check($result instanceof ResponseInterface, 'Not a raw response'); same('{"result":true}', (string) $result->getBody()); } - }; + }); } -$tests['API-level failure stays a response'] = function () { +addTest('API-level failure stays a response', function () { $payload = ['result' => false, 'statusCode' => 40001, 'message' => 'synthetic failure']; $c = client($history, [], [new Response(200, [], json_encode($payload))]); same($payload, $c->post('mail/send')); -}; -$tests['HTTP failure propagates'] = function () { +}); +addTest('HTTP failure propagates', function () { $c = client($history, [], [new Response(400, [], '{"result":false}')]); try { $c->post('mail/send'); @@ -196,12 +205,12 @@ function lengthMatches($request) return; } throw new RuntimeException('Expected ClientException'); -}; -$tests['http_errors option remains configurable'] = function () { +}); +addTest('http_errors option remains configurable', function () { $c = client($history, [], [new Response(400, [], '{"result":false}')]); same(['result' => false], $c->post('mail/send', [], ['http_errors' => false])); -}; -$tests['transport errors propagate'] = function () { +}); +addTest('transport errors propagate', function () { $error = new ConnectException('synthetic connection failure', new Request('POST', 'https://example.test/')); $c = client($history, [], [$error]); try { @@ -211,23 +220,23 @@ function lengthMatches($request) return; } throw new RuntimeException('Expected ConnectException'); -}; -$tests['async POST returns promise and authenticates'] = function () { +}); +addTest('async POST returns promise and authenticates', function () { $c = client($history); $promise = $c->postAsync('/mail/send', ['subject' => 'async']); check($promise instanceof PromiseInterface, 'Not a promise'); same(['result' => true], $promise->wait()); auth(form($history[0]['request'])); -}; -$tests['async GET and raw response'] = function () { +}); +addTest('async GET and raw response', function () { $c = client($history, ['response_type' => 'raw']); $result = $c->getAsync('/label/list?labelId=1')->wait(); check($result instanceof ResponseInterface, 'Not a raw response'); parse_str($history[0]['request']->getUri()->getQuery(), $q); auth($q); same('1', $q['labelId']); -}; -$tests['async HTTP errors reject'] = function () { +}); +addTest('async HTTP errors reject', function () { $c = client($history, [], [new Response(400)]); try { $c->postAsync('mail/send')->wait(); @@ -236,9 +245,9 @@ function lengthMatches($request) return; } throw new RuntimeException('Expected async rejection'); -}; +}); -$tests['async multipart preserves form and file bytes'] = function () { +addTest('async multipart preserves form and file bytes', function () { $c = client($history); $file = fopen('php://temp', 'r+'); fwrite($file, 'async attachment'); @@ -250,8 +259,8 @@ function lengthMatches($request) same('', $r->getUri()->getQuery()); lengthMatches($r); fclose($file); -}; -$tests['partially read form streams retain the full encoded body'] = function () { +}); +addTest('partially read form streams retain the full encoded body', function () { $c = client($history); $stream = new GuzzleHttp\Psr7\Stream(fopen('php://temp', 'r+')); $stream->write('subject=hello&x=1'); @@ -263,7 +272,113 @@ function lengthMatches($request) same('hello', form($r)['subject']); same('1', form($r)['x']); lengthMatches($r); -}; +}); + +foreach ([302, 307, 308] as $code) { + foreach (['get', 'post', 'multipart'] as $mode) { + addTest('redirects disabled '.$code.' '.$mode, function () use ($code, $mode) { + $c = client($history, ['response_type' => 'raw'], [new Response($code, ['Location' => 'https://other.example.test/stolen'])]); + if ('get' === $mode) { + $response = $c->get('label/list?private=value'); + } elseif ('post' === $mode) { + $response = $c->post('mail/send', ['subject' => 'private payload']); + } else { + $response = $c->request('mail/send', 'POST', ['multipart' => [['name' => 'attachment', 'contents' => 'private bytes']]]); + } + same($code, $response->getStatusCode()); + same(1, count($history)); + }); + foreach (['https://other.example.test/stolen', 'http://api.sendcloud.net/stolen', 'https://api.sendcloud.net:444/stolen', '//other.example.test/stolen'] as $target) { + addTest('unsafe redirect '.$code.' '.$mode.' '.$target, function () use ($code, $mode, $target) { + $callbackCalls = 0; + $c = client($history, ['allow_redirects' => ['on_redirect' => function () use (&$callbackCalls) { ++$callbackCalls; }]], [new Response($code, ['Location' => $target]), new Response(200, [], '{}')]); + try { + if ('get' === $mode) { + $c->get('label/list?private=value'); + } elseif ('post' === $mode) { + $c->post('mail/send', ['subject' => 'private payload']); + } else { + $c->request('mail/send', 'POST', ['multipart' => [['name' => 'attachment', 'contents' => 'private bytes']]]); + } + } catch (GuzzleHttp\Exception\RequestException $e) { + same(1, count($history)); + same(0, $callbackCalls); + same($code, $e->getResponse()->getStatusCode()); + return; + } + throw new RuntimeException('Unsafe redirect was followed'); + }); + } + } +} +addTest('async unsafe redirect rejects before second transport', function () { + $c = client($history, [], [new Response(307, ['Location' => 'https://other.example.test/stolen']), new Response(200, [], '{}')]); + try { + $c->postAsync('mail/send', ['subject' => 'private'], ['allow_redirects' => true])->wait(); + } catch (GuzzleHttp\Exception\RequestException $e) { + same(1, count($history)); + return; + } + throw new RuntimeException('Expected rejected redirect promise'); +}); +addTest('custom origin same-origin redirect preserves multipart and callback', function () { + $calls = 0; + $c = client($history, ['base_uri' => 'https://example.test/custom/', 'allow_redirects' => ['on_redirect' => function () use (&$calls) { ++$calls; }]], [new Response(307, ['Location' => 'https://example.test:443/next']), new Response(200, [], '{"result":true}')]); + same(['result' => true], $c->request('mail/send', 'POST', ['multipart' => [['name' => 'attachment', 'contents' => 'private bytes']]])); + same(2, count($history)); + same(1, $calls); + foreach ($history as $transaction) { + $r = $transaction['request']; + same('example.test', $r->getUri()->getHost()); + same('POST', $r->getMethod()); + check(false !== strpos((string) $r->getBody(), 'private bytes'), 'Multipart bytes lost on redirect'); + same(1, substr_count((string) $r->getBody(), 'name="apiKey"')); + lengthMatches($r); + } +}); +addTest('custom origin cannot redirect to the default API origin', function () { + $c = client($history, ['base_uri' => 'https://example.test/custom/', 'allow_redirects' => true], [new Response(302, ['Location' => SendCloud::BASE_URI]), new Response(200, [], '{}')]); + try { + $c->get('label/list'); + } catch (GuzzleHttp\Exception\RequestException $e) { + same(1, count($history)); + return; + } + throw new RuntimeException('Custom-origin boundary not enforced'); +}); +addTest('direct HTTP client retains the redirect guard', function () { + $c = client($history, [], [new Response(302, ['Location' => 'https://other.example.test/stolen']), new Response(200, [], '{}')]); + try { + $c->getHttpClient()->request('GET', 'label/list', ['allow_redirects' => true]); + } catch (GuzzleHttp\Exception\RequestException $e) { + same(1, count($history)); + return; + } + throw new RuntimeException('Direct client bypassed redirect guard'); +}); + +foreach (['request', 'raw-client'] as $entry) { + addTest('reject ambiguous prebuilt multipart '.$entry, function () use ($entry) { + $c = client($history); + $body = new GuzzleHttp\Psr7\MultipartStream([['name' => 'apiKey', 'contents' => 'wrong'], ['name' => 'subject', 'contents' => 'private']]); + try { + if ('request' === $entry) { + $c->request('mail/send', 'POST', ['body' => $body]); + } else { + $c->getHttpClient()->request('POST', 'mail/send', ['body' => $body]); + } + } catch (InvalidArgumentException $e) { + same(0, count($history)); + check(false !== strpos($e->getMessage(), 'multipart request option'), 'Missing migration guidance'); + return; + } + throw new RuntimeException('Ambiguous multipart body was sent'); + }); +} + +if (!$tests) { + throw new RuntimeException('No regression tests registered.'); +} $failures = 0; foreach ($tests as $name => $test) { From de7486f9dc8d45ec44d641ac2fce8d43c9eaaf4d Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 8 Oct 2026 21:29:54 -0700 Subject: [PATCH 3/3] Preserve explicit form content-type parameters --- src/SendCloud.php | 5 ++++- tests/run.php | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/SendCloud.php b/src/SendCloud.php index af910be..7accb4b 100644 --- a/src/SendCloud.php +++ b/src/SendCloud.php @@ -92,7 +92,10 @@ public function authorizationParamsMiddleware() $stream = new Stream(fopen('php://temp', 'r+')); $stream->write($this->withCredentials((string) $body, $credentials)); $stream->rewind(); - $request = $request->withBody($stream)->withHeader('Content-Type', 'application/x-www-form-urlencoded'); + $request = $request->withBody($stream); + if ('' === $contentType) { + $request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded'); + } $request = $this->withBodyLength($request); } else { // Leave non-form bodies untouched, preserving unrelated query parameters. diff --git a/tests/run.php b/tests/run.php index 5bfa48b..b40570e 100644 --- a/tests/run.php +++ b/tests/run.php @@ -124,6 +124,7 @@ function addTest($name, Closure $test) $c->request('mail/send', 'post', ['body' => 'x=1&x=2&api%55ser=wrong&apiKey=wrong', 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8']]); $r = $history[0]['request']; check(0 === strpos((string) $r->getBody(), 'x=1&x=2&'), 'Duplicate form values lost'); + same('application/x-www-form-urlencoded; charset=UTF-8', $r->getHeaderLine('Content-Type')); auth(form($r)); same(1, substr_count((string) $r->getBody(), 'apiUser=')); lengthMatches($r);