Files
lotteryLaravel/app/Support/Integration/WalletApiUrlSanitizer.php
kang fe0594beaa 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 路由,提供运行时白名单功能。
2026-05-28 10:10:26 +08:00

196 lines
6.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}