/on* 事件 → 存储型 XSS,落盘前强制消毒; * 4) 落盘文件名随机化,杜绝路径穿越与覆盖。 */ protected function uploadFile(string $key): ?string { if (empty($_FILES[$key]['tmp_name'])) return null; $f = $_FILES[$key]; if ($f['error'] !== UPLOAD_ERR_OK) return null; if (!is_uploaded_file($f['tmp_name'])) return null; if (($f['size'] ?? 0) > 8 * 1024 * 1024) return null; // 8MB 上限 $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION)); $allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; if (!in_array($ext, $allowed, true)) return null; // 真实类型校验(不信任扩展名与浏览器提交的 MIME) $realMime = function_exists('finfo_open') ? (finfo_file(($fi = finfo_open(FILEINFO_MIME_TYPE)), $f['tmp_name']) ?: '') : ''; if (isset($fi) && $fi) { finfo_close($fi); } $isSvg = ($ext === 'svg'); if (!$isSvg) { // 光栅图:必须能被 GD 识别为真实图像,且 MIME 属于图片类 $info = @getimagesize($f['tmp_name']); $okMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if ($info === false) return null; if ($realMime && !in_array($realMime, $okMimes, true)) return null; } else { // SVG:类型须为 svg/xml/text,随后消毒内容 $svgMimes = ['image/svg+xml', 'text/plain', 'text/xml', 'application/xml']; if ($realMime && !in_array($realMime, $svgMimes, true)) return null; } $dir = BASE_PATH . '/public/assets/uploads'; if (!is_dir($dir)) mkdir($dir, 0755, true); $name = uniqid('u_') . '.' . $ext; $dest = $dir . '/' . $name; if ($isSvg) { // 读取 → 消毒 → 写入(不使用 move_uploaded_file,因内容已被改写) $raw = @file_get_contents($f['tmp_name']); if ($raw === false) return null; $clean = $this->sanitizeSvg($raw); if ($clean === '' || @file_put_contents($dest, $clean) === false) return null; } else { if (!move_uploaded_file($f['tmp_name'], $dest)) return null; } return 'assets/uploads/' . $name; } /** * 处理多文件上传(name="gallery[]"),返回相对站点根路径数组。 * 逐张复用 uploadFile 的校验逻辑(真实图像/体积上限/类型白名单/SVG 消毒), * 任一文件失败不影响其余文件。 */ protected function uploadFiles(string $key): array { if (empty($_FILES[$key]['tmp_name']) || !is_array($_FILES[$key]['tmp_name'])) return []; $names = $_FILES[$key]['name'] ?? []; $errors = $_FILES[$key]['error'] ?? []; $sizes = $_FILES[$key]['size'] ?? []; $out = []; foreach ($_FILES[$key]['tmp_name'] as $i => $tmp) { if (empty($tmp)) continue; // 桥接到单文件校验逻辑 $_FILES['_multi_tmp'] = [ 'name' => $names[$i] ?? 'x.bin', 'type' => '', 'tmp_name' => $tmp, 'error' => $errors[$i] ?? UPLOAD_ERR_OK, 'size' => $sizes[$i] ?? 0, ]; $rel = $this->uploadFile('_multi_tmp'); unset($_FILES['_multi_tmp']); if ($rel) $out[] = $rel; } return $out; } /** 消毒 SVG:移除脚本、事件处理器与危险协议,阻断存储型 XSS */ protected function sanitizeSvg(string $svg): string { // 去除 $svg = preg_replace('#]*>.*?#is', '', $svg); // 去除 (可嵌 HTML/脚本) $svg = preg_replace('#]*>.*?#is', '', $svg); // 去除内联事件处理器 on*="..." $svg = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $svg); // 去除 javascript: / data:text 等危险协议引用 $svg = preg_replace('#(href|xlink:href)\s*=\s*("|\')?\s*javascript:[^"\'>]*#i', '', $svg); // 去除 外部引用与 标签,避免脚本跳转 $svg = preg_replace('#]*>|#i', '', $svg); return trim((string)$svg); } }