安全(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(市场 / 开发者中心 / 审核 / 分润)
228 lines
8.3 KiB
PHP
228 lines
8.3 KiB
PHP
<?php
|
||
/**
|
||
* 模板皮肤安装器(后台「安装模板」/ appmall 下载后部署的接入点)。
|
||
*
|
||
* 模板包 zip 由 scripts/package_template.php 产出,内部两段前缀:
|
||
* - templates/<name>/... → 部署到 <root>/templates/<name>/
|
||
* - static/... → 部署到 <root>/public/static/templates/<name>/
|
||
*
|
||
* 模板不携带 DB 迁移,安装即文件落地;与插件(Addon)的 importsql 机制解耦。
|
||
*/
|
||
namespace ywxapp\library;
|
||
|
||
use ZipArchive;
|
||
|
||
class TemplateInstaller
|
||
{
|
||
/**
|
||
* 从 zip 部署模板到项目。
|
||
*
|
||
* @param string $zipPath 模板包 zip 绝对路径
|
||
* @param string $rootDir 项目根目录(含 templates/ 与 public/)
|
||
* @return array 部署后的清单 ['name','version','target_addon','tpl_dir','static_dir']
|
||
* @throws \RuntimeException
|
||
*/
|
||
public static function install(string $zipPath, string $rootDir): array
|
||
{
|
||
if (!is_file($zipPath)) {
|
||
throw new \RuntimeException("模板包不存在: {$zipPath}");
|
||
}
|
||
if (!class_exists('ZipArchive')) {
|
||
throw new \RuntimeException('ZipArchive 扩展不可用');
|
||
}
|
||
|
||
$zip = new ZipArchive();
|
||
if ($zip->open($zipPath) !== true) {
|
||
throw new \RuntimeException("无法打开模板包: {$zipPath}");
|
||
}
|
||
|
||
// 先探测模板名(templates/<name>/ 前缀)
|
||
$name = '';
|
||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||
$entry = $zip->getNameIndex($i);
|
||
if (preg_match('#^templates/([^/]+)/#', $entry, $m)) {
|
||
$name = $m[1];
|
||
break;
|
||
}
|
||
}
|
||
if ($name === '') {
|
||
$zip->close();
|
||
throw new \RuntimeException('zip 中未找到 templates/<name>/ 结构,可能不是合法模板包');
|
||
}
|
||
|
||
// Zip Slip 防护第一道闸:模板名必须是「纯目录名」。
|
||
// 原实现只要求匹配 templates/<name>/,而 [<^/]+> 可以是 '..',
|
||
// 于是 templates/../../public/x 也能命中,后面拼路径就直接越出了安装目录。
|
||
if (! preg_match('/^[A-Za-z0-9_\-]{1,64}$/', $name)) {
|
||
$zip->close();
|
||
throw new \RuntimeException("模板名不合法(只允许字母数字与 - _):{$name}");
|
||
}
|
||
|
||
$tplTarget = rtrim($rootDir, '/\\') . '/templates/' . $name . '/';
|
||
$staticTarget = rtrim($rootDir, '/\\') . '/public/static/templates/' . $name . '/';
|
||
|
||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||
$entry = $zip->getNameIndex($i);
|
||
if ($entry === '' || $entry === null) {
|
||
continue;
|
||
}
|
||
$prefix = 'templates/' . $name . '/';
|
||
if (strpos($entry, $prefix) === 0) {
|
||
$rel = substr($entry, strlen($prefix));
|
||
self::assertSafeRelPath($rel, $entry);
|
||
self::writeEntry($zip, $entry, $tplTarget . $rel, $tplTarget);
|
||
} elseif (strpos($entry, 'static/') === 0) {
|
||
$rel = substr($entry, strlen('static/'));
|
||
if ($rel === '') {
|
||
continue;
|
||
}
|
||
self::assertSafeRelPath($rel, $entry);
|
||
self::writeEntry($zip, $entry, $staticTarget . $rel, $staticTarget);
|
||
}
|
||
}
|
||
$zip->close();
|
||
|
||
$metaFile = $tplTarget . 'template.json';
|
||
$meta = is_file($metaFile) ? (json_decode(file_get_contents($metaFile), true) ?? []) : [];
|
||
|
||
return [
|
||
'name' => $name,
|
||
'version' => $meta['version'] ?? '',
|
||
'title' => $meta['title'] ?? $name,
|
||
'target_addon' => $meta['target_addon'] ?? [],
|
||
'tpl_dir' => $tplTarget,
|
||
'static_dir' => $staticTarget,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 相对路径安全性校验(Zip Slip 第一道闸)
|
||
*
|
||
* 任何一个条目只要可能出现目录穿越迹象就拒绝安装:
|
||
* - 绝对路径(以 / 或 Windows 盘符开头)
|
||
* - 含 '..' 段
|
||
* - 含控制字符或空字节
|
||
*/
|
||
protected static function assertSafeRelPath(string $rel, string $entry): void
|
||
{
|
||
if ($rel === '' || $rel === '.' ) {
|
||
return;
|
||
}
|
||
if (strpos($rel, "\0") !== false || preg_match('/[\x00-\x1F]/', $rel)) {
|
||
throw new \RuntimeException("模板包含非法条目名:{$entry}");
|
||
}
|
||
if ($rel[0] === '/' || $rel[0] === '\\' || preg_match('#^[A-Za-z]:#', $rel)) {
|
||
throw new \RuntimeException("模板包含绝对路径条目:{$entry}");
|
||
}
|
||
foreach (explode('/', str_replace('\\', '/', $rel)) as $seg) {
|
||
if ($seg === '..') {
|
||
throw new \RuntimeException("模板包含目录穿越条目(Zip Slip):{$entry}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将 zip 内单个条目写出到目标路径(目录建目录,文件写内容)。
|
||
*
|
||
* @param ZipArchive $zip
|
||
* @param string $entry zip 内条目名
|
||
* @param string $target 目标绝对路径
|
||
* @param string $baseDir 允许写入的根目录(Zip Slip 第二道闸)
|
||
*/
|
||
protected static function writeEntry(ZipArchive $zip, string $entry, string $target, string $baseDir = ''): void
|
||
{
|
||
if (substr($entry, -1) === '/') {
|
||
// 先校验再建目录:否则非法目录已经落到磁盘上才报错
|
||
self::assertInside($target, $baseDir, $entry);
|
||
if (!is_dir($target)) {
|
||
@mkdir($target, 0755, true);
|
||
}
|
||
return;
|
||
}
|
||
$dir = dirname($target);
|
||
// 归一化后再确认没有跑出基准目录:realpath 能解开 ../ 与符号链接
|
||
self::assertInside($dir, $baseDir, $entry);
|
||
if (!is_dir($dir)) {
|
||
@mkdir($dir, 0755, true);
|
||
}
|
||
$content = $zip->getFromName($entry);
|
||
if ($content === false) {
|
||
return;
|
||
}
|
||
file_put_contents($target, $content);
|
||
}
|
||
|
||
/**
|
||
* 把一个(可能还不存在的)绝对路径规范化为可比较形式
|
||
*
|
||
* realpath() 对不存在的路径返回 false,而安装时目标目录往往尚未创建,
|
||
* 因此逐级向上找到第一个真实存在的祖先,再把未存在的尾部拼回去,同时折叠 `.` / `..`。
|
||
*/
|
||
protected static function normalizeAbs(string $abs): string
|
||
{
|
||
$abs = str_replace('\\', '/', rtrim($abs, '/'));
|
||
$real = realpath($abs);
|
||
if ($real !== false) {
|
||
return str_replace('\\', '/', rtrim($real, '/'));
|
||
}
|
||
$tail = [];
|
||
$cur = $abs;
|
||
$real = false;
|
||
while (true) {
|
||
$pos = strrpos($cur, '/');
|
||
if ($pos === false) {
|
||
break;
|
||
}
|
||
$tail[] = substr($cur, $pos + 1);
|
||
$cur = substr($cur, 0, $pos);
|
||
if ($cur === '') {
|
||
$cur = '/';
|
||
}
|
||
$real = realpath($cur);
|
||
if ($real !== false) {
|
||
break;
|
||
}
|
||
// Windows 盘符根 / POSIX 根,再往上没有了
|
||
if (strlen($cur) <= 3) {
|
||
break;
|
||
}
|
||
}
|
||
if ($real === false) {
|
||
return $abs;
|
||
}
|
||
$out = str_replace('\\', '/', rtrim($real, '/'));
|
||
foreach (array_reverse($tail) as $seg) {
|
||
$out .= '/' . $seg;
|
||
}
|
||
// 折叠 . 与 ..
|
||
$parts = [];
|
||
foreach (explode('/', $out) as $seg) {
|
||
if ($seg === '.' || $seg === '') {
|
||
continue;
|
||
}
|
||
if ($seg === '..') {
|
||
array_pop($parts);
|
||
continue;
|
||
}
|
||
$parts[] = $seg;
|
||
}
|
||
|
||
return '/' . implode('/', $parts);
|
||
}
|
||
|
||
/**
|
||
* 确认 $path(文件或目录)确实位于 $baseDir 之内
|
||
*/
|
||
protected static function assertInside(string $path, string $baseDir, string $entry): void
|
||
{
|
||
if ($baseDir === '') {
|
||
return;
|
||
}
|
||
$target = self::normalizeAbs($path);
|
||
$base = self::normalizeAbs($baseDir);
|
||
if (strcasecmp($target, $base) !== 0 && stripos($target . '/', $base . '/') !== 0) {
|
||
throw new \RuntimeException("模板包试图写入安装目录之外的路径(Zip Slip):{$entry}");
|
||
}
|
||
}
|
||
}
|