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

@@ -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 为准;这里仅用于本地默认站/历史回退。
# 主站站点根 URLSSO、跳转等
MAIN_SITE_BASE_URL=
# 主站 JWT 验签密钥(与主站约定,勿泄露)

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

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "llotterLaravel",
"name": "lotterLaravel",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

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

View File

@@ -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 与带路径/查询的地址。');
});

View File

@@ -0,0 +1,38 @@
<?php
use App\Models\AdminSite;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('public runtime origins returns enabled integration site iframe origins only', function (): void {
AdminSite::query()->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',
]);
});

View File

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

View File

@@ -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([

View File

@@ -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([