From fe0594beaa2c4d8fe4ee43a06126c7f341273b66 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 28 May 2026 10:10:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E9=92=B1=E5=8C=85=20?= =?UTF-8?q?API=20URL=20=E9=AA=8C=E8=AF=81=E4=B8=8E=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AdminIntegrationSiteStoreRequest 和 AdminIntegrationSiteUpdateRequest 中引入 WalletApiUrlRule,确保 wallet_api_url 字段符合 HTTPS 公开域名要求。 - 更新 HttpMainSiteWalletBalanceClient 和 HttpMainSiteWalletGateway,使用 WalletApiUrlSanitizer 进行 URL 规范化与验证,防止 SSRF 攻击。 - 新增测试用例,验证 wallet_api_url 的有效性,确保系统安全性与稳定性。 - 更新 .env.example 文件,添加 LOTTERY_RISK_POOL_USE_REDIS_LUA 配置项以支持 Redis Lua 原子扣减功能。 - 修改 package-lock.json 中的项目名称,确保一致性。 - 在 API 路由中新增 integration/runtime-origins 路由,提供运行时白名单功能。 --- .env.example | 3 + .../IntegrationRuntimeOriginsController.php | 48 +++++ .../AdminIntegrationSiteStoreRequest.php | 3 +- .../AdminIntegrationSiteUpdateRequest.php | 3 +- app/Rules/WalletApiUrlRule.php | 20 ++ .../HttpMainSiteWalletBalanceClient.php | 15 +- .../Wallet/HttpMainSiteWalletGateway.php | 41 +++- .../Integration/WalletApiUrlSanitizer.php | 195 ++++++++++++++++++ package-lock.json | 2 +- routes/api/v1/public.php | 5 + tests/Feature/AdminIntegrationSiteApiTest.php | 39 ++++ .../PublicIntegrationRuntimeOriginsTest.php | 38 ++++ tests/Feature/WalletBalanceTest.php | 4 +- tests/Feature/WalletTransferScenariosTest.php | 8 +- tests/Feature/WalletTransferTest.php | 2 +- 15 files changed, 412 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Integration/IntegrationRuntimeOriginsController.php create mode 100644 app/Rules/WalletApiUrlRule.php create mode 100644 app/Support/Integration/WalletApiUrlSanitizer.php create mode 100644 tests/Feature/PublicIntegrationRuntimeOriginsTest.php diff --git a/.env.example b/.env.example index b09a312..c38c119 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,8 @@ FILESYSTEM_DISK=local QUEUE_CONNECTION=redis # 缓存存储:file / database / redis 等;与 Redis 赔付池等能力对接前可用 database CACHE_STORE=redis +# 号码赔付池是否使用 Redis Lua 原子扣减;本地无 Redis Lua 需求时可 false +LOTTERY_RISK_POOL_USE_REDIS_LUA=false # 缓存键全局前缀;多环境共 Redis 时可用于隔离,一般可留空使用框架默认 # CACHE_PREFIX= @@ -219,6 +221,7 @@ LOTTERY_JWT_REQUIRE_IAT=true # 管理端登录:Sanctum PAT 有效天数(签发时刻起),至少 1;到期需重新登录 ADMIN_API_TOKEN_TTL_DAYS=7 +# Legacy 主站兜底配置:正式接入站点以后台 admin_sites 为准;这里仅用于本地默认站/历史回退。 # 主站站点根 URL(SSO、跳转等) MAIN_SITE_BASE_URL= # 主站 JWT 验签密钥(与主站约定,勿泄露) diff --git a/app/Http/Controllers/Api/V1/Integration/IntegrationRuntimeOriginsController.php b/app/Http/Controllers/Api/V1/Integration/IntegrationRuntimeOriginsController.php new file mode 100644 index 0000000..e2249a3 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Integration/IntegrationRuntimeOriginsController.php @@ -0,0 +1,48 @@ +where('status', 1) + ->pluck('iframe_allowed_origins') + ->flatMap(static function (mixed $value): array { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : []; + } + + if (! is_array($value)) { + return []; + } + + return array_values(array_filter( + array_map( + static fn (mixed $origin): string => is_string($origin) ? trim($origin) : '', + $value, + ), + static fn (string $origin): bool => $origin !== '', + )); + }) + ->unique() + ->values() + ->all(); + + return ApiResponse::success([ + 'iframe_allowed_origins' => $origins, + ]); + } +} diff --git a/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php index 15e0c57..5e6c0d0 100644 --- a/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php +++ b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Admin; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use App\Rules\WalletApiUrlRule; final class AdminIntegrationSiteStoreRequest extends FormRequest { @@ -20,7 +21,7 @@ final class AdminIntegrationSiteStoreRequest extends FormRequest 'name' => ['required', 'string', 'max:128'], 'currency_code' => ['sometimes', 'string', 'max:16'], 'status' => ['sometimes', 'integer', 'in:0,1'], - 'wallet_api_url' => ['nullable', 'string', 'max:512'], + 'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()], 'wallet_debit_path' => ['sometimes', 'string', 'max:128'], 'wallet_credit_path' => ['sometimes', 'string', 'max:128'], 'wallet_balance_path' => ['sometimes', 'string', 'max:128'], diff --git a/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php index c184b93..34b6397 100644 --- a/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Admin; use Illuminate\Foundation\Http\FormRequest; +use App\Rules\WalletApiUrlRule; final class AdminIntegrationSiteUpdateRequest extends FormRequest { @@ -18,7 +19,7 @@ final class AdminIntegrationSiteUpdateRequest extends FormRequest 'name' => ['required', 'string', 'max:128'], 'currency_code' => ['sometimes', 'string', 'max:16'], 'status' => ['sometimes', 'integer', 'in:0,1'], - 'wallet_api_url' => ['nullable', 'string', 'max:512'], + 'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()], 'wallet_debit_path' => ['sometimes', 'string', 'max:128'], 'wallet_credit_path' => ['sometimes', 'string', 'max:128'], 'wallet_balance_path' => ['sometimes', 'string', 'max:128'], diff --git a/app/Rules/WalletApiUrlRule.php b/app/Rules/WalletApiUrlRule.php new file mode 100644 index 0000000..0c0c406 --- /dev/null +++ b/app/Rules/WalletApiUrlRule.php @@ -0,0 +1,20 @@ +walletApiUrl, '/'); + $base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); + if ($base === null) { + return new MainSiteWalletBalanceProbeResult( + success: false, + mainBalanceMinor: null, + currencyCode: $currencyCode, + requestUrl: '', + httpStatus: null, + message: 'wallet_api_url 无效(拒绝以防 SSRF)', + responseBody: null, + ); + } + $path = $config->walletBalancePath; $url = $base.'/'.ltrim($path, '/'); $timeout = $config->walletTimeoutSeconds; diff --git a/app/Services/Wallet/HttpMainSiteWalletGateway.php b/app/Services/Wallet/HttpMainSiteWalletGateway.php index bd4ddd1..afd1fe1 100644 --- a/app/Services/Wallet/HttpMainSiteWalletGateway.php +++ b/app/Services/Wallet/HttpMainSiteWalletGateway.php @@ -6,6 +6,7 @@ use App\Models\Player; use Illuminate\Support\Facades\Http; use GuzzleHttp\Exception\ConnectException; use App\Services\Integration\PartnerSiteConfigResolver; +use App\Support\Integration\WalletApiUrlSanitizer; /** * 通过 HTTP 调用主站钱包 API(路径见 config lottery.main_site.wallet_*_path)。 @@ -61,7 +62,7 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway \App\Services\Integration\PartnerSiteConfig $config, ): MainSiteWalletResult { if (! $config->hasWalletApi()) { - return MainSiteWalletResult::success(null, ['stub' => true, 'reason' => 'wallet_api_not_configured'], [ + $requestSnapshot = [ 'site_code' => $player->site_code, 'site_player_id' => $player->site_player_id, 'player_id' => $player->id, @@ -69,10 +70,44 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway 'amount_minor' => $amountMinor, 'idempotent_key' => $idempotentKey, '_meta' => ['stub' => true], - ]); + ]; + + // 生产环境不允许把“主站未配置钱包 API”当作成功,从而避免资金链路失真。 + if (app()->environment(['production'])) { + return MainSiteWalletResult::failure( + 'wallet_api_not_configured', + ['stub' => false, 'reason' => 'wallet_api_not_configured'], + false, + $requestSnapshot, + ); + } + + // 本地/测试环境允许 stub 成功,方便联调与自动化用例覆盖资金链路流程。 + return MainSiteWalletResult::success( + null, + ['stub' => true, 'reason' => 'wallet_api_not_configured'], + $requestSnapshot, + ); + } + + $base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); + if ($base === null) { + return MainSiteWalletResult::failure( + 'wallet_api_url_invalid', + ['reason' => 'invalid_base_url'], + false, + [ + 'site_code' => $player->site_code, + 'site_player_id' => $player->site_player_id, + 'player_id' => $player->id, + 'currency_code' => $currencyCode, + 'amount_minor' => $amountMinor, + 'idempotent_key' => $idempotentKey, + '_meta' => ['stub' => true, 'operation' => 'invalid_wallet_api_url'], + ], + ); } - $base = rtrim((string) $config->walletApiUrl, '/'); $url = $base.'/'.ltrim($path, '/'); $timeout = $config->walletTimeoutSeconds; $apiKey = $config->walletApiKey; diff --git a/app/Support/Integration/WalletApiUrlSanitizer.php b/app/Support/Integration/WalletApiUrlSanitizer.php new file mode 100644 index 0000000..113d818 --- /dev/null +++ b/app/Support/Integration/WalletApiUrlSanitizer.php @@ -0,0 +1,195 @@ + 65535) { + return null; + } + } + + $normalized = 'https://'.$host; + if (isset($parts['port'])) { + $normalized .= ':'.(string) (int) $parts['port']; + } + + return $normalized; + } + + private static function ipIsPrivateOrReserved(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $v = ip2long($ip); + if ($v === false) { + return true; + } + + // PHP 在 macOS 上 ip2long 可能为有符号,强转为 int64 统一处理 + $v = (int) $v; + + return self::ipInRangesV4((int) $v, [ + // 0.0.0.0/8 + ['base' => ip2long('0.0.0.0'), 'mask' => 0xFF000000], + // 10.0.0.0/8 + ['base' => ip2long('10.0.0.0'), 'mask' => 0xFF000000], + // 127.0.0.0/8 + ['base' => ip2long('127.0.0.0'), 'mask' => 0xFF000000], + // 169.254.0.0/16 + ['base' => ip2long('169.254.0.0'), 'mask' => 0xFFFF0000], + // 172.16.0.0/12 + ['base' => ip2long('172.16.0.0'), 'mask' => 0xFFF00000], + // 192.168.0.0/16 + ['base' => ip2long('192.168.0.0'), 'mask' => 0xFFFF0000], + // 100.64.0.0/10 (CGNAT) + ['base' => ip2long('100.64.0.0'), 'mask' => 0xFFC00000], + // 192.0.0.0/24 (IETF Protocol Assignments) + ['base' => ip2long('192.0.0.0'), 'mask' => 0xFFFFFF00], + // 198.18.0.0/15 (benchmarking) + ['base' => ip2long('198.18.0.0'), 'mask' => 0xFFFE0000], + // 224.0.0.0/4 (multicast) + ['base' => ip2long('224.0.0.0'), 'mask' => 0xF0000000], + // 240.0.0.0/4 (reserved for future use) + ['base' => ip2long('240.0.0.0'), 'mask' => 0xF0000000], + ]); + } + + // IPv6:仅做关键保守段拦截(避免复杂数值比较引入 bug) + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $bin = inet_pton($ip); + if ($bin === false) { + return true; + } + + // IPv6 ::1 (loopback) + if (substr($bin, 0, 15) === str_repeat("\0", 15) && $bin[15] === "\1") { + return true; + } + + // IPv6 unspecified :: + if ($bin === str_repeat("\0", 16)) { + return true; + } + + $b0 = ord($bin[0]); + $b1 = ord($bin[1]); + + // ff00::/8 multicast + if ($b0 === 0xFF) { + return true; + } + + // fc00::/7 unique local => fc or fd + if ($b0 === 0xFC || $b0 === 0xFD) { + return true; + } + + // fe80::/10 link-local => fe + (second byte & 0xC0) == 0x80 + if ($b0 === 0xFE && (($b1 & 0xC0) === 0x80)) { + return true; + } + + // IPv4-mapped ::ffff:0:0/96 => 检查最后 4 字节映射的 IPv4 是否为私网 + if (substr($bin, 0, 10) === str_repeat("\0", 10) && substr($bin, 10, 2) === "\xFF\xFF") { + $v4bin = substr($bin, 12, 4); + $v4 = inet_ntop($v4bin); + // inet_ntop 对 v4bin 有时返回 false,这里保守返回 true + if ($v4 === false) { + return true; + } + + return self::ipIsPrivateOrReserved($v4); + } + } + + // 非法 IP:保守拒绝 + return true; + } + + private static function ipInRangesV4(int $v, array $ranges): bool + { + foreach ($ranges as $r) { + $base = (int) $r['base']; + $mask = (int) $r['mask']; + if (($v & $mask) === ($base & $mask)) { + return true; + } + } + + return false; + } +} + diff --git a/package-lock.json b/package-lock.json index f6fdc71..659f41e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "llotterLaravel", + "name": "lotterLaravel", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/routes/api/v1/public.php b/routes/api/v1/public.php index db649bb..97eece9 100644 --- a/routes/api/v1/public.php +++ b/routes/api/v1/public.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; use App\Http\Controllers\Api\V1\Setting\SettingIndexController; +use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController; /** * 公开路由(无需登录)。 @@ -43,3 +44,7 @@ Route::prefix('player') // 系统公共配置(如前端规则等) Route::get('settings', SettingIndexController::class)->name('api.v1.settings.index'); + +// iframe 运行时白名单(只公开启用接入站点的 origin,不公开密钥) +Route::get('integration/runtime-origins', IntegrationRuntimeOriginsController::class) + ->name('api.v1.integration.runtime-origins'); diff --git a/tests/Feature/AdminIntegrationSiteApiTest.php b/tests/Feature/AdminIntegrationSiteApiTest.php index 57aa80b..00e51f0 100644 --- a/tests/Feature/AdminIntegrationSiteApiTest.php +++ b/tests/Feature/AdminIntegrationSiteApiTest.php @@ -323,3 +323,42 @@ test('player list is filtered by admin site binding', function (): void { $siteCodes = collect($response->json('data.items'))->pluck('site_code')->unique()->values()->all(); expect($siteCodes)->toBe(['site-a']); }); + +test('wallet_api_url rejects non-https', function (): void { + $token = integrationAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'bad-https-1', + 'name' => 'Bad HTTPS 1', + 'wallet_api_url' => 'http://wallet.bad.test', + ]) + ->assertStatus(422) + ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); +}); + +test('wallet_api_url rejects localhost', function (): void { + $token = integrationAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'bad-https-2', + 'name' => 'Bad HTTPS 2', + 'wallet_api_url' => 'https://localhost:8080', + ]) + ->assertStatus(422) + ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); +}); + +test('wallet_api_url rejects private ip with path', function (): void { + $token = integrationAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'bad-https-3', + 'name' => 'Bad HTTPS 3', + 'wallet_api_url' => 'https://127.0.0.1/wallet', + ]) + ->assertStatus(422) + ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); +}); diff --git a/tests/Feature/PublicIntegrationRuntimeOriginsTest.php b/tests/Feature/PublicIntegrationRuntimeOriginsTest.php new file mode 100644 index 0000000..dfc7cb2 --- /dev/null +++ b/tests/Feature/PublicIntegrationRuntimeOriginsTest.php @@ -0,0 +1,38 @@ +create([ + 'code' => 'enabled-a', + 'name' => 'Enabled A', + 'currency_code' => 'NPR', + 'status' => 1, + 'iframe_allowed_origins' => [ + 'https://main.example.test', + 'https://main.example.test', + ' https://shell.example.test ', + ], + ]); + + AdminSite::query()->create([ + 'code' => 'disabled-a', + 'name' => 'Disabled A', + 'currency_code' => 'NPR', + 'status' => 0, + 'iframe_allowed_origins' => [ + 'https://disabled.example.test', + ], + ]); + + $this->getJson('/api/v1/integration/runtime-origins') + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.iframe_allowed_origins', [ + 'https://main.example.test', + 'https://shell.example.test', + ]); +}); diff --git a/tests/Feature/WalletBalanceTest.php b/tests/Feature/WalletBalanceTest.php index 3c459fb..8ba3310 100644 --- a/tests/Feature/WalletBalanceTest.php +++ b/tests/Feature/WalletBalanceTest.php @@ -59,13 +59,13 @@ test('wallet balance rejects illegal currency query', function () { test('wallet balance returns main_balance when main site wallet api is configured', function () { config([ - 'lottery.main_site.wallet_api_url' => 'http://fake-main.test', + 'lottery.main_site.wallet_api_url' => 'https://fake-main.test', 'lottery.main_site.wallet_balance_path' => '/wallet/balance', ]); Http::preventStrayRequests(); Http::fake([ - 'http://fake-main.test/wallet/balance*' => Http::response([ + 'https://fake-main.test/wallet/balance*' => Http::response([ 'success' => true, 'data' => [ 'main_balance' => 499_900, diff --git a/tests/Feature/WalletTransferScenariosTest.php b/tests/Feature/WalletTransferScenariosTest.php index 2646671..d0b83ac 100644 --- a/tests/Feature/WalletTransferScenariosTest.php +++ b/tests/Feature/WalletTransferScenariosTest.php @@ -111,7 +111,7 @@ test('transfer in main site explicit failure returns 1009 and marks order failed Http::fake([ 'reject-debit.test/*' => Http::response(['success' => false, 'message' => 'main_insufficient'], 200), ]); - config(['lottery.main_site.wallet_api_url' => 'http://reject-debit.test']); + config(['lottery.main_site.wallet_api_url' => 'https://reject-debit.test']); config(['lottery.main_site.wallet_debit_path' => 'debit']); $player = Player::query()->create([ @@ -143,7 +143,7 @@ test('transfer in main site timeout returns 1002 and pending_reconcile', functio Http::fake([ 'timeout-debit.test/*' => Http::response([], 504), ]); - config(['lottery.main_site.wallet_api_url' => 'http://timeout-debit.test']); + config(['lottery.main_site.wallet_api_url' => 'https://timeout-debit.test']); config(['lottery.main_site.wallet_debit_path' => 'debit']); $player = Player::query()->create([ @@ -210,7 +210,7 @@ test('transfer out main site failure refunds lottery and returns 1009', function Http::fake([ 'reject-credit.test/*' => Http::response(['success' => false, 'message' => 'credit_denied'], 200), ]); - config(['lottery.main_site.wallet_api_url' => 'http://reject-credit.test']); + config(['lottery.main_site.wallet_api_url' => 'https://reject-credit.test']); config(['lottery.main_site.wallet_credit_path' => 'credit']); $player = Player::query()->create([ @@ -251,7 +251,7 @@ test('transfer out main site timeout returns 1002 and pending_reconcile on order Http::fake([ 'timeout-credit.test/*' => Http::response([], 504), ]); - config(['lottery.main_site.wallet_api_url' => 'http://timeout-credit.test']); + config(['lottery.main_site.wallet_api_url' => 'https://timeout-credit.test']); config(['lottery.main_site.wallet_credit_path' => 'credit']); $player = Player::query()->create([ diff --git a/tests/Feature/WalletTransferTest.php b/tests/Feature/WalletTransferTest.php index fed4156..23f86ae 100644 --- a/tests/Feature/WalletTransferTest.php +++ b/tests/Feature/WalletTransferTest.php @@ -256,7 +256,7 @@ test('transfer in http 504 marks order pending reconcile', function () { Http::fake([ 'fake-main.test/*' => Http::response([], 504), ]); - config(['lottery.main_site.wallet_api_url' => 'http://fake-main.test']); + config(['lottery.main_site.wallet_api_url' => 'https://fake-main.test']); config(['lottery.main_site.wallet_debit_path' => 'debit']); $player = Player::query()->create([