Files
lotteryLaravel/app/Support/Integration/WalletApiUrlSanitizer.php

240 lines
8.0 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 解析与请求时固定解析由 WalletApiRequestGuard 负责。
*/
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;
}
$host = trim($host, '[]');
// 明确拦截 localhost / 本地常见名
if ($host === 'localhost' || $host === 'local' || $host === 'localdomain') {
return null;
}
// 拦截 IP 字面量私网
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
if ($isIp) {
if (! self::isPublicIp($host)) {
return null;
}
} else {
$host = rtrim($host, '.');
if (! str_contains($host, '.')
|| preg_match('/^[0-9.]+$/D', $host) === 1
|| preg_match('/(^|\.)(?:0x[0-9a-f]+|0[0-7]+)(?:\.|$)/iD', $host) === 1
|| filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false
) {
return null;
}
}
// 端口校验(允许不写 port
if (isset($parts['port'])) {
$port = (int) $parts['port'];
if ($port < 1 || $port > 65535) {
return null;
}
}
$normalizedHost = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
? '['.$host.']'
: $host;
$normalized = 'https://'.$normalizedHost;
if (isset($parts['port'])) {
$normalized .= ':'.(string) (int) $parts['port'];
}
return $normalized;
}
public static function isPublicIp(string $ip): bool
{
if (filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
) === false) {
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$v = ip2long($ip);
if ($v === false) {
return false;
}
// 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],
// Documentation and deprecated relay ranges
['base' => ip2long('192.0.2.0'), 'mask' => 0xFFFFFF00],
['base' => ip2long('192.88.99.0'), 'mask' => 0xFFFFFF00],
['base' => ip2long('198.51.100.0'), 'mask' => 0xFFFFFF00],
['base' => ip2long('203.0.113.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 false;
}
// IPv6 ::1 (loopback)
if (substr($bin, 0, 15) === str_repeat("\0", 15) && $bin[15] === "\1") {
return false;
}
// IPv6 unspecified ::
if ($bin === str_repeat("\0", 16)) {
return false;
}
$b0 = ord($bin[0]);
$b1 = ord($bin[1]);
// ff00::/8 multicast
if ($b0 === 0xFF) {
return false;
}
// fc00::/7 unique local => fc or fd
if ($b0 === 0xFC || $b0 === 0xFD) {
return false;
}
// fe80::/10 link-local => fe + (second byte & 0xC0) == 0x80
if ($b0 === 0xFE && (($b1 & 0xC0) === 0x80)) {
return false;
}
// 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这里保守拒绝
if ($v4 === false) {
return false;
}
return self::isPublicIp($v4);
}
// IPv4 translation prefixes can otherwise tunnel a private IPv4 target.
$isIpv4Translation = substr($bin, 0, 12) === "\x00\x64\xFF\x9B\x00\x00\x00\x00\x00\x00\x00\x00"
|| substr($bin, 0, 6) === "\x00\x64\xFF\x9B\x00\x01";
// 100::/64 discard-only, 2001::/23 protocol assignments,
// 2001:db8::/32 and 3fff::/20 documentation, 2002::/16 deprecated 6to4.
if ($isIpv4Translation
|| substr($bin, 0, 8) === "\x00\x64\x00\x00\x00\x00\x00\x00"
|| ($b0 === 0x20 && $b1 === 0x01 && (ord($bin[2]) & 0xFE) === 0)
|| substr($bin, 0, 4) === "\x20\x01\x0D\xB8"
|| ($b0 === 0x3F && ($b1 & 0xF0) === 0xF0)
|| ($b0 === 0x20 && $b1 === 0x02)
) {
return false;
}
return true;
}
// 非法 IP保守拒绝
return false;
}
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;
}
}