安全: 修复支付回调验签与网关边界

This commit is contained in:
Anyon 2026-05-04 14:35:56 +08:00
parent 8cef0b65cc
commit 1e1f0d231b
8 changed files with 444 additions and 20 deletions

View File

@ -105,6 +105,7 @@ $result = $official->call('cgi-bin/menu/get', [], 'GET');
| 场景 | 写法 |
|------|------|
| 接口 path | 只传相对路径,如 `cgi-bin/user/get`SDK 会拒绝 `https://...``//...`。 |
| 微信普通接口需要 `access_token` | 默认自动附加。 |
| 微信授权、登录等不需要 `access_token` 的接口 | 传 `['with_token' => false]`。 |
| POST JSON | 默认行为,直接传 `$params`。 |
@ -441,8 +442,13 @@ $refund = $payment->post('v3/refund/domestic/refunds', [
回调验签与解密:
```php
$rawBody = file_get_contents('php://input') ?: '';
$body = json_decode($rawBody, true) ?: [];
$data = $payment->post('decrypt_notification', [], [
'headers' => $headers,
// 微信支付 APIv3 验签必须使用原始 JSON body不要先 json_decode 后重新编码。
'raw_body' => $rawBody,
'body' => $body,
]);
```
@ -588,6 +594,16 @@ $refund = $pay->post('refund', [
]);
```
支付宝异步通知验签:
```php
if (!$pay->verifyNotify($_POST)) {
throw new RuntimeException('支付宝通知验签失败');
}
// 验签通过后再处理 trade_status、out_trade_no、trade_no 等业务字段。
```
## 框架集成建议
- 在 Laravel、Hyperf、Symfony 等框架中,建议把 `Client` 注册为容器服务,缓存实现接入框架 Redis 或 Cache 组件。

View File

@ -41,12 +41,17 @@ class PlatformClient
'form_params' => $params,
'headers' => ['Accept' => 'application/json'],
]);
$payload = json_decode((string)$response->getBody(), true);
$body = (string)$response->getBody();
$payload = json_decode($body, true);
if (!is_array($payload)) {
throw new WechatException('支付宝网关响应格式无效');
}
$node = str_replace('.', '_', $apiMethod) . '_response';
$data = is_array($payload[$node] ?? null) ? $payload[$node] : $payload;
$responseNode = is_array($payload[$node] ?? null) ? $node : (is_array($payload['error_response'] ?? null) ? 'error_response' : $node);
if ($this->config->alipayPublicKey !== '') {
$this->assertResponseSignature($body, $responseNode, (string)($payload['sign'] ?? ''));
}
$data = is_array($payload[$responseNode] ?? null) ? $payload[$responseNode] : $payload;
if (($data['code'] ?? '10000') !== '10000') {
throw new WechatException((string)($data['sub_msg'] ?? $data['msg'] ?? '支付宝接口调用失败'));
}
@ -54,6 +59,34 @@ class PlatformClient
return $data;
}
/**
* 验证支付宝异步通知签名;业务处理回调前应先调用该方法。
*
* @param array<string,mixed> $params 支付宝通知完整参数,包含 sign/sign_type。
*/
public function verifyNotify(array $params): bool
{
return $this->verify($params);
}
/**
* 验证支付宝参数签名;通知验签会排除 sign sign_type其他参数按字典序拼接。
*
* @param array<string,mixed> $params
*/
public function verify(array $params): bool
{
$sign = (string)($params['sign'] ?? '');
if ($sign === '') {
return false;
}
if ($this->config->alipayPublicKey === '') {
throw new WechatException('支付宝公钥不能为空');
}
return $this->verifySignature($this->buildSignContent($params, true), $sign);
}
public function auth(string $redirectUri, string $scope = 'auth_user', string $state = ''): string
{
return 'https://openauth.alipay.com/oauth2/publicAppAuthorize.htm?' . http_build_query([
@ -134,16 +167,8 @@ class PlatformClient
/** @param array<string,mixed> $params */
protected function sign(array $params): string
{
ksort($params);
$pairs = [];
foreach ($params as $key => $value) {
if ($key === 'sign' || $value === null || $value === '') {
continue;
}
$pairs[] = $key . '=' . $value;
}
$data = implode('&', $pairs);
$privateKey = str_contains($this->config->privateKey, 'BEGIN') ? $this->config->privateKey : "-----BEGIN PRIVATE KEY-----\n" . chunk_split($this->config->privateKey, 64, "\n") . "-----END PRIVATE KEY-----";
$data = $this->buildSignContent($params);
$privateKey = $this->normalizePrivateKey($this->config->privateKey);
$resource = openssl_pkey_get_private($privateKey);
if ($resource === false) {
throw new WechatException('支付宝私钥无效');
@ -156,4 +181,109 @@ class PlatformClient
return base64_encode($signature);
}
private function assertResponseSignature(string $body, string $node, string $sign): void
{
if ($sign === '') {
throw new WechatException('支付宝响应缺少签名');
}
// 支付宝同步响应的验签原文是响应节点的原始 JSON 片段,不能使用 json_decode 后重新编码的数组。
if (!$this->verifySignature($this->extractJsonValue($body, $node), $sign)) {
throw new WechatException('支付宝响应验签失败');
}
}
/**
* @param array<string,mixed> $params
*/
private function buildSignContent(array $params, bool $skipSignType = false): string
{
ksort($params);
$pairs = [];
foreach ($params as $key => $value) {
if ($key === 'sign' || ($skipSignType && $key === 'sign_type') || $value === null || $value === '') {
continue;
}
$pairs[] = $key . '=' . (is_scalar($value) ? (string)$value : (json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: ''));
}
return implode('&', $pairs);
}
private function verifySignature(string $source, string $signature): bool
{
$publicKey = $this->normalizePublicKey($this->config->alipayPublicKey);
$resource = openssl_pkey_get_public($publicKey);
if ($resource === false) {
throw new WechatException('支付宝公钥无效');
}
$decoded = base64_decode($signature, true);
if ($decoded === false) {
return false;
}
$algo = strtoupper($this->config->signType) === 'RSA2' ? OPENSSL_ALGO_SHA256 : OPENSSL_ALGO_SHA1;
return openssl_verify($source, $decoded, $resource, $algo) === 1;
}
private function normalizePrivateKey(string $privateKey): string
{
return str_contains($privateKey, 'BEGIN') ? $privateKey : "-----BEGIN PRIVATE KEY-----\n" . chunk_split($privateKey, 64, "\n") . "-----END PRIVATE KEY-----";
}
private function normalizePublicKey(string $publicKey): string
{
return str_contains($publicKey, 'BEGIN') ? $publicKey : "-----BEGIN PUBLIC KEY-----\n" . chunk_split($publicKey, 64, "\n") . "-----END PUBLIC KEY-----";
}
private function extractJsonValue(string $json, string $key): string
{
if (preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*/', $json, $match, PREG_OFFSET_CAPTURE) !== 1) {
throw new WechatException('支付宝响应缺少签名节点: ' . $key);
}
$start = (int)$match[0][1] + strlen((string)$match[0][0]);
$length = strlen($json);
while ($start < $length && ctype_space($json[$start])) {
++$start;
}
$first = $json[$start] ?? '';
if ($first !== '{' && $first !== '[') {
throw new WechatException('支付宝响应签名节点格式无效');
}
$depth = 0;
$inString = false;
$escaped = false;
for ($i = $start; $i < $length; ++$i) {
$char = $json[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
} elseif ($char === '\\') {
$escaped = true;
} elseif ($char === '"') {
$inString = false;
}
continue;
}
if ($char === '"') {
$inString = true;
continue;
}
if ($char === '{' || $char === '[') {
++$depth;
continue;
}
if ($char === '}' || $char === ']') {
--$depth;
if ($depth === 0) {
return substr($json, $start, $i - $start + 1);
}
}
}
throw new WechatException('支付宝响应签名节点不完整');
}
}

View File

@ -7,6 +7,7 @@ namespace We\Platform\Wechat;
use GuzzleHttp\ClientInterface;
use We\Config\WechatPaymentConfig;
use We\Exception\SignatureException;
use We\Exception\WechatException;
use We\Support\JsonClient;
use We\Support\PayCrypto;
use We\Support\Signature;
@ -50,14 +51,20 @@ final class PaymentClient
/**
* @param array<string,string> $headers
* @param array<string,mixed> $body
* @param array<string,mixed>|string $body 原始 JSON 字符串优先;传数组仅用于兼容旧调用,验签会退化为本地重编码。
* @return array<string,mixed>
*/
public function decryptNotification(array $headers, array $body): array
public function decryptNotification(array $headers, array|string $body): array
{
$this->assertNotificationSignature($headers, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
// 微信支付 APIv3 签名串要求使用 HTTP 原始 body不能先 json_decode 再重新编码,否则字段顺序或转义差异会导致验签失败。
$rawBody = is_string($body) ? $body : (json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
$this->assertNotificationSignature($headers, $rawBody);
$payload = is_string($body) ? json_decode($body, true) : $body;
if (!is_array($payload)) {
throw new WechatException('微信支付回调 JSON 无效');
}
/** @var array{ciphertext:string,nonce:string,associated_data?:string} $resource */
$resource = $body['resource'] ?? [];
$resource = $payload['resource'] ?? [];
return PayCrypto::decryptResource($this->config->apiV3Key, $resource);
}
@ -75,9 +82,10 @@ final class PaymentClient
return $this->refund($params);
}
if ($uri === 'decrypt_notification') {
$body = $options['raw_body'] ?? $options['body'] ?? $params;
return $this->decryptNotification(
is_array($options['headers'] ?? null) ? $options['headers'] : [],
is_array($options['body'] ?? null) ? $options['body'] : $params,
is_string($body) || is_array($body) ? $body : $params,
);
}
@ -127,9 +135,13 @@ final class PaymentClient
private function assertNotificationSignature(array $headers, string $body): void
{
$timestamp = (string)($headers['Wechatpay-Timestamp'] ?? $headers['wechatpay-timestamp'] ?? '');
$nonce = (string)($headers['Wechatpay-Nonce'] ?? $headers['wechatpay-nonce'] ?? '');
$signature = (string)($headers['Wechatpay-Signature'] ?? $headers['wechatpay-signature'] ?? '');
$timestamp = $this->headerValue($headers, 'Wechatpay-Timestamp');
$nonce = $this->headerValue($headers, 'Wechatpay-Nonce');
$signature = $this->headerValue($headers, 'Wechatpay-Signature');
$serial = $this->headerValue($headers, 'Wechatpay-Serial');
if ($this->config->platformSerial !== '' && !hash_equals($this->config->platformSerial, $serial)) {
throw new SignatureException('微信支付平台序列号不匹配');
}
$message = "{$timestamp}\n{$nonce}\n{$body}\n";
$publicKey = $this->config->platformPublicKey !== '' ? $this->config->platformPublicKey : $this->config->platformCertificate;
if ($publicKey === '') {
@ -139,4 +151,18 @@ final class PaymentClient
throw new SignatureException('微信支付回调验签失败');
}
}
/**
* @param array<string,mixed> $headers
*/
private function headerValue(array $headers, string $name): string
{
foreach ($headers as $key => $value) {
if (strcasecmp((string)$key, $name) === 0) {
return is_array($value) ? implode(',', array_map('strval', $value)) : (string)$value;
}
}
return '';
}
}

View File

@ -43,10 +43,20 @@ final class JsonClient
*/
public function send(string $method, string $uri, array $options = []): ResponseInterface
{
$this->assertRelativeUri($uri);
try {
return $this->http->request($method, $uri, $options);
} catch (GuzzleException $exception) {
throw new ApiException($exception->getMessage(), (int)$exception->getCode(), $exception);
}
}
private function assertRelativeUri(string $uri): void
{
$uri = trim($uri);
// SDK 的微信类客户端都绑定了官方 base_uri禁止传入绝对 URL避免网关代调用场景被放大成 SSRF。
if (str_starts_with($uri, '//') || preg_match('#^[a-z][a-z0-9+.-]*:#i', $uri) === 1) {
throw new ApiException('接口路径必须是相对路径');
}
}
}

View File

@ -14,6 +14,11 @@ final class PayCrypto
*/
public static function decryptResource(string $apiV3Key, array $resource): array
{
foreach (['ciphertext', 'nonce'] as $field) {
if (!isset($resource[$field]) || !is_string($resource[$field]) || $resource[$field] === '') {
throw new WechatException('微信支付回调资源字段缺失: ' . $field);
}
}
$ciphertext = base64_decode($resource['ciphertext'], true);
if ($ciphertext === false || strlen($ciphertext) <= 16) {
throw new WechatException('微信支付回调密文无效');

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace We\Tests;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use We\Config\AlipayPlatformConfig;
use We\Platform\Alipay\PlatformClient;
#[CoversClass(PlatformClient::class)]
final class AlipayPlatformClientTest extends TestCase
{
public function testRequestVerifiesSignedResponseWhenPublicKeyConfigured(): void
{
[$privateKey, $publicKey] = self::keyPair();
$responseNode = '{"code":"10000","msg":"Success","trade_no":"TRADE202605040001"}';
$body = '{"alipay_trade_query_response":' . $responseNode . ',"sign":"' . self::sign($responseNode, $privateKey) . '"}';
$client = new PlatformClient(
new AlipayPlatformConfig('ali_app', $privateKey, $publicKey),
new AlipayFakeHttpClient($body),
);
$data = $client->request('alipay.trade.query', ['out_trade_no' => 'P202605040001']);
self::assertSame('TRADE202605040001', $data['trade_no']);
}
public function testVerifyNotify(): void
{
[$privateKey, $publicKey] = self::keyPair();
$params = [
'notify_time' => '2026-05-04 12:00:00',
'app_id' => 'ali_app',
'trade_status' => 'TRADE_SUCCESS',
'out_trade_no' => 'P202605040001',
'sign_type' => 'RSA2',
];
$params['sign'] = self::sign('app_id=ali_app&notify_time=2026-05-04 12:00:00&out_trade_no=P202605040001&trade_status=TRADE_SUCCESS', $privateKey);
$client = new PlatformClient(new AlipayPlatformConfig('ali_app', $privateKey, $publicKey));
self::assertTrue($client->verifyNotify($params));
}
/**
* @return array{0:string,1:string}
*/
private static function keyPair(): array
{
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
self::assertNotFalse($resource);
openssl_pkey_export($resource, $privateKey);
$details = openssl_pkey_get_details($resource);
self::assertIsArray($details);
return [$privateKey, (string)$details['key']];
}
private static function sign(string $source, string $privateKey): string
{
$ok = openssl_sign($source, $signature, $privateKey, OPENSSL_ALGO_SHA256);
self::assertTrue($ok);
return base64_encode($signature);
}
}
final class AlipayFakeHttpClient implements ClientInterface
{
public function __construct(private readonly string $body) {}
public function send(RequestInterface $request, array $options = []): ResponseInterface
{
return $this->response();
}
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
{
return Create::rejectionFor(new \RuntimeException('sendAsync is not used in this test'));
}
public function request(string $method, $uri = '', array $options = []): ResponseInterface
{
return $this->response();
}
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
{
return Create::rejectionFor(new \RuntimeException('requestAsync is not used in this test'));
}
public function getConfig(?string $option = null): mixed
{
return null;
}
private function response(): ResponseInterface
{
return new Response(200, [], $this->body);
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace We\Tests;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use We\Exception\ApiException;
use We\Support\JsonClient;
#[CoversClass(JsonClient::class)]
final class JsonClientTest extends TestCase
{
public function testSendRejectsAbsoluteUri(): void
{
$client = new JsonClient();
$this->expectException(ApiException::class);
$this->expectExceptionMessage('相对路径');
$client->send('GET', 'https://example.com/evil');
}
public function testSendRejectsNetworkPathUri(): void
{
$client = new JsonClient();
$this->expectException(ApiException::class);
$this->expectExceptionMessage('相对路径');
$client->send('GET', '//example.com/evil');
}
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace We\Tests;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use We\Config\WechatPaymentConfig;
use We\Exception\SignatureException;
use We\Platform\Wechat\PaymentClient;
use We\Support\Signature;
#[CoversClass(PaymentClient::class)]
final class PaymentClientTest extends TestCase
{
public function testDecryptNotificationUsesRawBodyForSignature(): void
{
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
$apiV3Key = str_repeat('k', 32);
$nonce = '123456789012';
$aad = 'transaction';
$plain = '{"out_trade_no":"T202605040001","trade_state":"SUCCESS"}';
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
self::assertIsString($cipher);
$rawBody = "{\n \"id\": \"notify-id\",\n \"resource\": {\n \"ciphertext\": \"" . base64_encode($cipher . $tag) . "\",\n \"nonce\": \"{$nonce}\",\n \"associated_data\": \"{$aad}\"\n }\n}";
$timestamp = '1777600000';
$notifyNonce = 'notify-nonce';
$headers = [
'Wechatpay-Timestamp' => $timestamp,
'Wechatpay-Nonce' => $notifyNonce,
'Wechatpay-Serial' => 'platform-serial',
'Wechatpay-Signature' => Signature::payV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
];
$client = new PaymentClient(new WechatPaymentConfig(
'wx_app',
'mch_id',
$apiV3Key,
'merchant-serial',
'merchant-private-key',
'',
$platformPublicKey,
'platform-serial',
));
$data = $client->decryptNotification($headers, $rawBody);
self::assertSame('T202605040001', $data['out_trade_no']);
self::assertSame('SUCCESS', $data['trade_state']);
}
public function testDecryptNotificationRejectsPlatformSerialMismatch(): void
{
[$platformPrivateKey, $platformPublicKey] = self::keyPair();
$rawBody = '{"resource":{"ciphertext":"invalid","nonce":"nonce"}}';
$timestamp = '1777600000';
$notifyNonce = 'notify-nonce';
$headers = [
'Wechatpay-Timestamp' => $timestamp,
'Wechatpay-Nonce' => $notifyNonce,
'Wechatpay-Serial' => 'other-serial',
'Wechatpay-Signature' => Signature::payV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
];
$client = new PaymentClient(new WechatPaymentConfig(
'wx_app',
'mch_id',
str_repeat('k', 32),
'merchant-serial',
'merchant-private-key',
'',
$platformPublicKey,
'platform-serial',
));
$this->expectException(SignatureException::class);
$this->expectExceptionMessage('序列号');
$client->decryptNotification($headers, $rawBody);
}
/**
* @return array{0:string,1:string}
*/
private static function keyPair(): array
{
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
self::assertNotFalse($resource);
openssl_pkey_export($resource, $privateKey);
$details = openssl_pkey_get_details($resource);
self::assertIsArray($details);
return [$privateKey, (string)$details['key']];
}
}