feat: 增强钱包 API URL 验证与配置

- 在 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 路由,提供运行时白名单功能。
This commit is contained in:
2026-05-28 10:10:26 +08:00
parent a60ce8caad
commit fe0594beaa
15 changed files with 412 additions and 14 deletions

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Api\V1\Integration;
use App\Http\Controllers\Controller;
use App\Models\AdminSite;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/**
* 玩家端 iframe 运行时白名单。
*
* 只公开启用站点的 origin不包含任何密钥或钱包地址。
*/
final class IntegrationRuntimeOriginsController extends Controller
{
public function __invoke(): JsonResponse
{
$origins = AdminSite::query()
->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,
]);
}
}

View File

@@ -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'],

View File

@@ -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'],

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Rules;
use App\Support\Integration\WalletApiUrlSanitizer;
use Illuminate\Contracts\Validation\Rule;
final class WalletApiUrlRule implements Rule
{
public function passes($attribute, $value): bool
{
return WalletApiUrlSanitizer::normalizeAndValidate($value) !== null;
}
public function message(): string
{
return 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。';
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Services\Wallet;
use App\Models\Player;
use Illuminate\Support\Facades\Http;
use App\Services\Integration\PartnerSiteConfigResolver;
use App\Support\Integration\WalletApiUrlSanitizer;
/**
* 查询主站钱包余额(供玩家端余额接口填充 main_balance
@@ -51,7 +52,19 @@ final class HttpMainSiteWalletBalanceClient
);
}
$base = rtrim((string) $config->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;

View File

@@ -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;

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Support\Integration;
/**
* SSRF对主站钱包基地址做严格归一化校验。
*
* 规则(保守):
* - 仅允许 https
* - 不允许 user/pass、query、fragment
* - 不允许除 / 以外的 path即仅允许根地址
* - 拒绝 localhost 与私网/保留网段IP 字面量层面)
*
* 说明:对 hostname 不做 DNS 解析(避免引入不确定性),但会拦截 localhost 及明显内网标识。
*/
final class WalletApiUrlSanitizer
{
public static function normalizeAndValidate(?string $raw): ?string
{
if (! is_string($raw)) {
return null;
}
$raw = trim($raw);
if ($raw === '') {
return null;
}
// 允许尾部 /,归一化后移除
$raw = rtrim($raw, " \t\n\r\0\x0B/");
$parts = parse_url($raw);
if (! is_array($parts)) {
return null;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if ($scheme !== 'https') {
return null;
}
if (isset($parts['user']) || isset($parts['pass'])) {
return null;
}
if (! isset($parts['host']) || ! is_string($parts['host']) || $parts['host'] === '') {
return null;
}
if (isset($parts['query']) || isset($parts['fragment'])) {
return null;
}
$path = (string) ($parts['path'] ?? '');
if ($path !== '' && $path !== '/') {
return null;
}
$host = strtolower(trim((string) $parts['host']));
if ($host === '' || str_contains($host, ' ') || str_contains($host, "\t") || str_contains($host, "\n")) {
return null;
}
// 明确拦截 localhost / 本地常见名
if ($host === 'localhost' || $host === 'local' || $host === 'localdomain') {
return null;
}
// 拦截 IP 字面量私网
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
if ($isIp) {
if (self::ipIsPrivateOrReserved($host)) {
return null;
}
}
// 端口校验(允许不写 port
if (isset($parts['port'])) {
$port = (int) $parts['port'];
if ($port < 1 || $port > 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;
}
}