feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,516 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ywxapp\library;
|
||||
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 真假蜘蛛识别(Forward-Confirmed rDNS 双向验证)
|
||||
*
|
||||
* 判定流程(业界标准做法,Google / Bing 官方文档推荐):
|
||||
* 1. PTR 反查:来访 IP → 主机名(如 123.125.71.1 → baiduspider-123-125-71-1.crawl.baidu.com)
|
||||
* 2. 后缀匹配:主机名必须落在该蜘蛛的官方域名后缀内(如 *.crawl.baidu.com / baidu.com)
|
||||
* 3. A/AAAA 正查:该主机名必须解析回**同一个来访 IP**(防 DNS 欺骗/自建 PTR)
|
||||
* 三步全过 = 真蜘蛛;任一步明确失败 = 假蜘蛛(伪造 UA);DNS 网络故障 = 待验证(不武断判假)
|
||||
*
|
||||
* 工程要点(决定能不能上生产):
|
||||
* - **自实现 UDP DNS 客户端**:PHP 原生 gethostbyaddr()/dns_get_record() 无超时参数,
|
||||
* 受 /etc/resolv.conf 的 timeout 5s × attempts 2 支配,最坏阻塞 10 秒——
|
||||
* 绝不可放进请求周期。这里走 UDP 53 手写报文,硬超时 0.5s,完全可控。
|
||||
* - **结果按 IP 缓存 24h**:蜘蛛 IP 高度集中,同一 IP 段反复抓取只查一次 DNS。
|
||||
* - **熔断**:DNS 服务器连续失败 3 次 → 降级 5 分钟不查询(避免拖慢整站)。
|
||||
* - 只对命中蜘蛛 UA 的请求调用(见 middleware/SpiderStat),普通用户零开销。
|
||||
*
|
||||
* 返回状态(verified 字段):
|
||||
* 0 待验证(DNS 故障 / 该蜘蛛官方不提供 rDNS)
|
||||
* 1 真蜘蛛
|
||||
* 2 假蜘蛛(伪造 UA)
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class SpiderVerify
|
||||
{
|
||||
/** DNS 记录类型 */
|
||||
private const T_A = 1;
|
||||
private const T_PTR = 12;
|
||||
private const T_AAAA = 28;
|
||||
|
||||
/** IP 校验结果缓存时长(秒) */
|
||||
public const TTL = 86400;
|
||||
|
||||
/** 单次 DNS 查询硬超时(秒) */
|
||||
public const DNS_TIMEOUT = 0.5;
|
||||
|
||||
/** 缓存键前缀 */
|
||||
private const CACHE_PREFIX = 'spider_verify:';
|
||||
private const FAIL_KEY = 'spider_dns:fail';
|
||||
private const DOWN_KEY = 'spider_dns:down';
|
||||
|
||||
/** 可配置 DNS 服务器(config/spider.php: dns_server),留空自动探测 */
|
||||
private static ?string $serverOverride = null;
|
||||
|
||||
/**
|
||||
* 校验来访 IP 是否为声明的蜘蛛
|
||||
*
|
||||
* @param string $ip 来访 IP
|
||||
* @param string $spider 蜘蛛标识(SpiderDetect::SPIDERS 的 key)
|
||||
* @return array{status:int, rdns:string} status: 0待验证 1真 2假
|
||||
*/
|
||||
public static function verify(string $ip, string $spider): array
|
||||
{
|
||||
$ip = trim($ip);
|
||||
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return ['status' => 0, 'rdns' => ''];
|
||||
}
|
||||
|
||||
$suffixes = SpiderDetect::rdnsSuffixes($spider);
|
||||
if ($suffixes === []) {
|
||||
// 该蜘蛛官方不提供 rDNS(如部分国内蜘蛛),无法校验 → 只按 UA 计数
|
||||
return ['status' => 0, 'rdns' => ''];
|
||||
}
|
||||
|
||||
$key = self::CACHE_PREFIX . $spider . ':' . $ip;
|
||||
$cached = self::cacheGet($key);
|
||||
if (is_array($cached) && isset($cached['status'])) {
|
||||
return ['status' => (int) $cached['status'], 'rdns' => (string) ($cached['rdns'] ?? '')];
|
||||
}
|
||||
|
||||
// DNS 配额:每分钟最多 120 次实查,防止伪造 UA + 海量不同 IP 造成的 DNS 放大
|
||||
// (超配额时本次记为「待验证」,下分钟继续,不影响统计落库)
|
||||
if (self::rateLimited()) {
|
||||
return ['status' => 0, 'rdns' => ''];
|
||||
}
|
||||
|
||||
$result = self::check($ip, $suffixes);
|
||||
|
||||
// 只缓存「确定结论」(真 / 假);DNS 故障导致的待验证不缓存,下次访问重试
|
||||
if ($result['status'] !== 0) {
|
||||
self::cacheSet($key, $result, self::TTL);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某个 IP 的校验缓存(后台「重新校验」用)
|
||||
*/
|
||||
public static function forget(string $ip, string $spider = ''): void
|
||||
{
|
||||
if ($spider !== '') {
|
||||
self::cacheDel(self::CACHE_PREFIX . $spider . ':' . $ip);
|
||||
return;
|
||||
}
|
||||
// 不指定蜘蛛时,遍历已知蜘蛛逐一清除
|
||||
foreach (array_keys(SpiderDetect::SPIDERS) as $key) {
|
||||
self::cacheDel(self::CACHE_PREFIX . $key . ':' . $ip);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 DNS 是否处于熔断降级状态(后台可提示)
|
||||
*/
|
||||
public static function dnsDown(): bool
|
||||
{
|
||||
return (bool) self::cacheGet(self::DOWN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动指定 DNS 服务器(IP 或 IPv6)
|
||||
*/
|
||||
public static function setServer(string $server): void
|
||||
{
|
||||
self::$serverOverride = trim($server);
|
||||
}
|
||||
|
||||
// ==================== 核心校验 ====================
|
||||
|
||||
/**
|
||||
* 三步校验:PTR → 后缀 → A/AAAA 正查
|
||||
*
|
||||
* @param string $ip 来访 IP
|
||||
* @param string[] $suffixes 官方域名后缀
|
||||
* @return array{status:int, rdns:string}
|
||||
*/
|
||||
private static function check(string $ip, array $suffixes): array
|
||||
{
|
||||
if (self::dnsDown()) {
|
||||
return ['status' => 0, 'rdns' => ''];
|
||||
}
|
||||
|
||||
// ---------- 1) PTR 反查 ----------
|
||||
$hosts = self::query(self::reverseName($ip), self::T_PTR);
|
||||
if ($hosts === null) {
|
||||
self::markDnsFail();
|
||||
return ['status' => 0, 'rdns' => ''];
|
||||
}
|
||||
if ($hosts === []) {
|
||||
// DNS 正常响应但该 IP 无 PTR 记录:正规蜘蛛必然配置 PTR → 伪造 UA
|
||||
self::markDnsOk();
|
||||
return ['status' => 2, 'rdns' => ''];
|
||||
}
|
||||
|
||||
$host = strtolower(rtrim((string) $hosts[0], '.'));
|
||||
|
||||
// ---------- 2) 官方域名后缀匹配 ----------
|
||||
if (!self::matchSuffix($host, $suffixes)) {
|
||||
self::markDnsOk();
|
||||
return ['status' => 2, 'rdns' => $host];
|
||||
}
|
||||
|
||||
// ---------- 3) A / AAAA 正查,必须解析回来访 IP ----------
|
||||
// IPv4 来访只查 A,IPv6 来访只查 AAAA:少一次 DNS 往返(真蜘蛛通常 2 次查询完成)
|
||||
$lookupType = str_contains($ip, ':') ? self::T_AAAA : self::T_A;
|
||||
$records = self::query($host, $lookupType);
|
||||
|
||||
if ($records === null) {
|
||||
// 主查询失败:再试另一族记录,仍失败则视为 DNS 故障
|
||||
$fallback = self::query($host, $lookupType === self::T_A ? self::T_AAAA : self::T_A);
|
||||
if ($fallback === null) {
|
||||
self::markDnsFail();
|
||||
return ['status' => 0, 'rdns' => $host];
|
||||
}
|
||||
$records = $fallback;
|
||||
}
|
||||
|
||||
$resolved = array_map([self::class, 'normalizeIp'], $records);
|
||||
if (!in_array(self::normalizeIp($ip), $resolved, true)) {
|
||||
// 域名对但正查不含该 IP:伪造 PTR / DNS 欺骗
|
||||
self::markDnsOk();
|
||||
return ['status' => 2, 'rdns' => $host];
|
||||
}
|
||||
|
||||
self::markDnsOk();
|
||||
return ['status' => 1, 'rdns' => $host];
|
||||
}
|
||||
|
||||
// ==================== 熔断 & 限流 ====================
|
||||
|
||||
/**
|
||||
* 每分钟 DNS 实查配额(超限则本次跳过,不判假)
|
||||
*/
|
||||
private static function rateLimited(): bool
|
||||
{
|
||||
$key = 'spider_dns:rate:' . date('YmdHi');
|
||||
$n = (int) self::cacheGet($key);
|
||||
if ($n >= 120) {
|
||||
return true;
|
||||
}
|
||||
self::cacheSet($key, $n + 1, 70);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function markDnsFail(): void
|
||||
{
|
||||
$fails = (int) self::cacheGet(self::FAIL_KEY) + 1;
|
||||
self::cacheSet(self::FAIL_KEY, $fails, 300);
|
||||
if ($fails >= 3) {
|
||||
self::cacheSet(self::DOWN_KEY, 1, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private static function markDnsOk(): void
|
||||
{
|
||||
self::cacheDel(self::FAIL_KEY);
|
||||
}
|
||||
|
||||
// ==================== DNS 客户端(自实现,硬超时) ====================
|
||||
|
||||
/**
|
||||
* 执行一次 DNS 查询
|
||||
*
|
||||
* @param string $name 查询名(域名或 in-addr.arpa / ip6.arpa)
|
||||
* @param int $type 记录类型(T_A / T_PTR / T_AAAA)
|
||||
* @return array|null 记录字符串数组;null 表示网络层失败(超时/无响应/服务器故障)
|
||||
*/
|
||||
private static function query(string $name, int $type): ?array
|
||||
{
|
||||
$server = self::dnsServer();
|
||||
$addr = str_contains($server, ':') ? "[{$server}]" : $server;
|
||||
|
||||
$errno = 0;
|
||||
$err = '';
|
||||
$sock = @stream_socket_client('udp://' . $addr . ':53', $errno, $err, self::DNS_TIMEOUT);
|
||||
if (!is_resource($sock)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sec = (int) floor(self::DNS_TIMEOUT);
|
||||
$usec = (int) round((self::DNS_TIMEOUT - $sec) * 1000000);
|
||||
stream_set_timeout($sock, $sec, $usec);
|
||||
|
||||
$id = random_int(1, 65535);
|
||||
$packet = pack('n6', $id, 0x0100, 1, 0, 0, 0)
|
||||
. self::encodeName($name)
|
||||
. pack('n2', $type, 1);
|
||||
|
||||
if (@fwrite($sock, $packet) === false) {
|
||||
fclose($sock);
|
||||
return null;
|
||||
}
|
||||
|
||||
$resp = @fread($sock, 4096);
|
||||
$meta = stream_get_meta_data($sock);
|
||||
fclose($sock);
|
||||
|
||||
if (!is_string($resp) || strlen($resp) < 12 || !empty($meta['timed_out'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::parseResponse($resp, $type, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 DNS 响应报文
|
||||
*
|
||||
* @return array|null null=服务器故障(触发熔断)
|
||||
*/
|
||||
private static function parseResponse(string $resp, int $type, int $id): ?array
|
||||
{
|
||||
$header = unpack('nid/nflags/nqd/nan/nns/nar', substr($resp, 0, 12));
|
||||
if (!is_array($header) || (int) $header['id'] !== $id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rcode = ((int) $header['flags']) & 0x0F;
|
||||
if ($rcode === 3) {
|
||||
return []; // NXDOMAIN:域名/IP 不存在(正常响应)
|
||||
}
|
||||
if ($rcode !== 0) {
|
||||
return null; // SERVFAIL / REFUSED 等 → 视为服务器故障
|
||||
}
|
||||
|
||||
$len = strlen($resp);
|
||||
$off = 12;
|
||||
|
||||
// 跳过 Question 段
|
||||
for ($i = 0; $i < (int) $header['qd']; $i++) {
|
||||
$off = self::skipName($resp, $off) + 4;
|
||||
if ($off > $len) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 Answer 段
|
||||
$records = [];
|
||||
for ($i = 0; $i < (int) $header['an']; $i++) {
|
||||
$off = self::skipName($resp, $off);
|
||||
if ($off + 10 > $len) {
|
||||
break;
|
||||
}
|
||||
$meta = unpack('ntype/nclass/Nttl/nrdlen', substr($resp, $off, 10));
|
||||
$off += 10;
|
||||
if (!is_array($meta)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$rdlen = (int) $meta['rdlen'];
|
||||
if ($off + $rdlen > $len) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ((int) $meta['type'] === $type) {
|
||||
$rdata = substr($resp, $off, $rdlen);
|
||||
if ($type === self::T_A && $rdlen === 4) {
|
||||
$records[] = implode('.', array_values(unpack('C4', $rdata)));
|
||||
} elseif ($type === self::T_AAAA && $rdlen === 16) {
|
||||
$ip = @inet_ntop($rdata);
|
||||
if (is_string($ip)) {
|
||||
$records[] = $ip;
|
||||
}
|
||||
} elseif ($type === self::T_PTR) {
|
||||
$records[] = self::decodeName($resp, $off);
|
||||
}
|
||||
}
|
||||
|
||||
$off += $rdlen;
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 域名 → DNS 报文字节(label 序列)
|
||||
*/
|
||||
private static function encodeName(string $name): string
|
||||
{
|
||||
$out = '';
|
||||
foreach (explode('.', trim($name, '.')) as $label) {
|
||||
$len = strlen($label);
|
||||
if ($len === 0 || $len > 63) {
|
||||
continue;
|
||||
}
|
||||
$out .= chr($len) . $label;
|
||||
}
|
||||
return $out . "\x00";
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过报文中的域名(支持压缩指针),返回下一个字段的偏移
|
||||
*/
|
||||
private static function skipName(string $packet, int $off): int
|
||||
{
|
||||
$len = strlen($packet);
|
||||
while ($off < $len) {
|
||||
$b = ord($packet[$off]);
|
||||
if ($b === 0) {
|
||||
return $off + 1;
|
||||
}
|
||||
if (($b & 0xC0) === 0xC0) {
|
||||
return $off + 2;
|
||||
}
|
||||
$off += 1 + $b;
|
||||
}
|
||||
return $len;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码报文中的域名(支持压缩指针)
|
||||
*/
|
||||
private static function decodeName(string $packet, int $off): string
|
||||
{
|
||||
$labels = [];
|
||||
$len = strlen($packet);
|
||||
$jumped = false;
|
||||
$guard = 0;
|
||||
|
||||
while ($off < $len && $guard++ < 64) {
|
||||
$b = ord($packet[$off]);
|
||||
if ($b === 0) {
|
||||
break;
|
||||
}
|
||||
if (($b & 0xC0) === 0xC0) {
|
||||
if ($jumped || $off + 1 >= $len) {
|
||||
break; // 只允许跳转一次,防解析环
|
||||
}
|
||||
$jumped = true;
|
||||
$off = (($b & 0x3F) << 8) | ord($packet[$off + 1]);
|
||||
continue;
|
||||
}
|
||||
$labels[] = substr($packet, $off + 1, $b);
|
||||
$off += 1 + $b;
|
||||
}
|
||||
|
||||
return implode('.', $labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP → PTR 查询名(1.2.3.4 → 4.3.2.1.in-addr.arpa;IPv6 → nibble 反转 + ip6.arpa)
|
||||
*/
|
||||
private static function reverseName(string $ip): string
|
||||
{
|
||||
if (str_contains($ip, ':')) {
|
||||
$packed = @inet_pton($ip);
|
||||
if (!is_string($packed)) {
|
||||
return '';
|
||||
}
|
||||
$hex = bin2hex($packed);
|
||||
return implode('.', array_reverse(str_split($hex))) . '.ip6.arpa';
|
||||
}
|
||||
|
||||
return implode('.', array_reverse(explode('.', $ip))) . '.in-addr.arpa';
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 归一化:IPv4-mapped IPv6(::ffff:1.2.3.4)还原为 IPv4,统一小写
|
||||
*/
|
||||
private static function normalizeIp(string $ip): string
|
||||
{
|
||||
$ip = strtolower(trim($ip));
|
||||
if (str_starts_with($ip, '::ffff:')) {
|
||||
$tail = substr($ip, 7);
|
||||
if (filter_var($tail, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
return $tail;
|
||||
}
|
||||
}
|
||||
return $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主机名是否落在官方域名后缀内(按 label 边界匹配,防 evil-baidu.com 冒充 baidu.com)
|
||||
*
|
||||
* @param string[] $suffixes
|
||||
*/
|
||||
private static function matchSuffix(string $host, array $suffixes): bool
|
||||
{
|
||||
foreach ($suffixes as $suffix) {
|
||||
$suffix = strtolower(trim((string) $suffix, '. '));
|
||||
if ($suffix === '') {
|
||||
continue;
|
||||
}
|
||||
if ($host === $suffix || str_ends_with($host, '.' . $suffix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS 服务器:配置 > /etc/resolv.conf > 公共 DNS
|
||||
*/
|
||||
private static function dnsServer(): string
|
||||
{
|
||||
if (self::$serverOverride !== null && self::$serverOverride !== '') {
|
||||
return self::$serverOverride;
|
||||
}
|
||||
|
||||
static $detected = null;
|
||||
if ($detected !== null) {
|
||||
return $detected;
|
||||
}
|
||||
|
||||
$detected = '223.5.5.5'; // 阿里公共 DNS(国内解析快,国外亦可用)
|
||||
|
||||
try {
|
||||
$cfg = config('spider.dns_server');
|
||||
if (is_string($cfg) && trim($cfg) !== '') {
|
||||
return $detected = trim($cfg);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 配置不可用则走探测
|
||||
}
|
||||
|
||||
$conf = @file_get_contents('/etc/resolv.conf');
|
||||
if (is_string($conf) && preg_match('/^\s*nameserver\s+([0-9a-fA-F:.]+)/m', $conf, $m)) {
|
||||
$detected = $m[1];
|
||||
}
|
||||
|
||||
return $detected;
|
||||
}
|
||||
|
||||
// ==================== 缓存小工具(缓存不可用时不抛异常) ====================
|
||||
|
||||
private static function cacheGet(string $key)
|
||||
{
|
||||
try {
|
||||
return Cache::get($key);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function cacheSet(string $key, $value, int $ttl): void
|
||||
{
|
||||
try {
|
||||
Cache::set($key, $value, $ttl);
|
||||
} catch (\Throwable $e) {
|
||||
// 缓存不可用:退化为每次实时校验
|
||||
}
|
||||
}
|
||||
|
||||
private static function cacheDel(string $key): void
|
||||
{
|
||||
try {
|
||||
Cache::delete($key);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user