第 5 章:文件操作风险管控——安全上传与文件管理
章节介绍
学习目标
通过本章学习,您将能够:
- 深刻理解文件上传功能中潜藏的多重安全风险(如 Webshell 上传、路径遍历等)
- 掌握构建多层防御的文件上传安全校验流程
- 学会安全地管理用户上传的文件,包括存储、访问和清理
- 理解并防范文件系统操作中的目录遍历攻击
- 能够在实际项目中实现一个完整的、符合安全标准的文件上传模块
本章作用与定位
在前四章中,我们学习了输入验证、SQL 注入防护、XSS 与 CSRF 防御等 Web 安全基础知识.本章将聚焦于另一个高频攻击面——文件操作.文件上传是 Web 应用中极为常见的功能,也是攻击者最青睐的突破口之一.一个未经严格校验的文件上传点,可能瞬间成为攻击者控制整个服务器的后门.本章将系统性地讲解文件上传漏洞的攻防原理,并指导您构建从客户端到服务器端的完整安全防御体系.
与前面章节的衔接
本章内容与**第 2 章(输入验证)紧密相关,文件上传本质上是特殊类型的用户输入.与第 4 章(XSS 防护)也有交叉,因为恶意文件可能包含脚本代码.同时,安全的文件管理也会用到第 7 章(加密)**中的部分知识.掌握本章内容后,您将对 Web 应用的攻击面有更全面的认识.
本章主要内容概览
- 文件上传漏洞深度剖析:分析攻击者如何通过文件上传获取服务器控制权
- 安全校验多层防御:从客户端到服务器端的完整校验流程设计
- 安全存储与访问控制:文件存储策略、权限设置与访问隔离
- 文件系统操作安全:防范目录遍历攻击的最佳实践
- 综合实战项目:构建一个带完整安全防护的图片上传相册系统
核心概念讲解
文件上传漏洞的本质与危害
文件上传功能的安全风险源于一个简单的事实:服务器执行了用户可控的文件内容.攻击者通过精心构造,可以上传并执行恶意脚本,从而控制服务器.
主要攻击方式
- Webshell 上传:上传包含 PHP、ASP、JSP 等服务器端脚本语言代码的文件,通过浏览器访问该文件即可在服务器上执行任意命令.
- 钓鱼文件:上传伪装成正常文件的恶意程序(如.exe、.bat),诱骗其他用户下载执行.
- 文件覆盖:通过目录遍历或文件名预测,覆盖服务器上的关键系统文件或应用配置文件.
- 拒绝服务攻击:上传超大文件耗尽服务器磁盘空间或处理资源.
- 客户端攻击:上传包含恶意脚本的 HTML/JS 文件,当其他用户访问时触发 XSS 攻击.
漏洞利用条件
要使文件上传漏洞被成功利用,通常需要满足以下条件之一:
- 服务器配置不当,允许直接执行上传目录中的脚本文件
- 应用程序未对文件内容进行有效校验,仅检查扩展名
- 存在文件解析漏洞(如 Apache 的
mod_mime解析缺陷) - 能够结合其他漏洞(如目录遍历)将文件上传到可执行目录
安全的文件校验流程(纵深防御)
单一防御措施极易被绕过,应采用多层防御策略:
客户端校验 → 服务器端扩展名白名单 → MIME类型检查 → 文件头校验 → 内容安全检查 → 随机重命名 → 安全存储
1. 客户端校验(辅助层)
- 作用:提供即时反馈,提升用户体验,减少无效请求
- 限制:完全不可信,可被轻易绕过
- 实现:HTML5 的
accept属性、JavaScript 文件类型/大小校验
2. 服务器端扩展名白名单(基础层)
- 原则:只允许已知安全的扩展名,拒绝其他所有
- 实现:维护一个小型的、明确允许的扩展名列表(如
.jpg,.png,.gif) - 注意:不要使用黑名单!攻击者总能找到不在名单中的危险扩展名
3. MIME 类型检查(增强层)
- 原理:检查 HTTP 请求头中的
Content-Type信息 - 限制:可被篡改,不能单独依赖
- 实现:通过
$_FILES['file']['type']获取,但需结合其他校验
4. 文件头校验(内容层)
- 原理:检查文件的实际二进制内容开头部分(魔术字节)
- 优势:难以伪造,是判断文件真实类型的可靠方法
- 实现:使用
getimagesize()检查图片,或直接读取文件头字节
5. 内容安全检查(深度层)
- 目的:防止图片马(在正常图片中嵌入恶意代码)
- 方法:对图片进行二次渲染、使用防病毒软件扫描、检查文件内容是否包含 PHP 标签等
6. 随机重命名(隔离层)
- 目的:防止攻击者预测文件路径,避免文件覆盖攻击
- 方法:使用不可预测的随机字符串(如 UUID)作为文件名,保留原始扩展名
7. 安全存储(物理层)
- 原则:上传文件存储在 Web 根目录之外,或通过脚本代理访问
- 配置:正确设置文件系统权限(最小权限原则)
目录遍历攻击(Path Traversal)
目录遍历攻击通过使用../等路径遍历序列,访问或操作应用程序预期目录之外的文件.
攻击示例
- 文件下载功能:
download.php?file=../../../../etc/passwd - 文件上传功能:通过文件名
../../../var/www/html/shell.php将文件上传到可执行目录 - 文件包含功能:
include($_GET['page'] . '.php'),传入../../../etc/passwd%00
防护方法
- 规范化路径:使用
realpath()获取绝对路径,并与允许的基准目录比较 - 白名单过滤:只允许文件名,不允许路径
- 剥离目录遍历序列:过滤掉
../、..\等序列 - 使用索引存储:将文件存储在数据库中,通过 ID 引用而非直接路径
代码示例
示例 1:存在严重漏洞的文件上传代码(反面教材)
这是一个典型的、存在多个安全漏洞的文件上传实现:
<?php
// 存在严重安全漏洞的文件上传代码 - 请勿在生产环境使用!
// 1. 没有任何输入验证
if(isset($_FILES['uploaded_file'])) {
$file = $_FILES['uploaded_file'];
// 2. 直接使用用户提供的文件名 - 存在路径遍历风险
$target_path = "uploads/" . $file['name'];
// 3. 没有任何文件类型校验
if(move_uploaded_file($file['tmp_name'], $target_path)) {
echo "文件上传成功: <a href='$target_path'>查看文件</a>";
} else {
echo "文件上传失败";
}
}
?>
攻击演示:
- 上传名为
shell.php的文件,内容为<?php system($_GET['cmd']); ?> - 访问
http:// example.com/uploads/shell.php?cmd=whoami即可执行系统命令 - 上传名为
../../../var/www/html/shell.php的文件,可能将 Webshell 写入 Web 根目录
示例 2:基础安全防护的文件上传代码
以下是添加了基础安全防护的上传代码:
<?php
// 基础安全防护的文件上传示例
// 定义允许的文件类型白名单
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
$max_file_size = 2 * 1024 * 1024; // 2MB
// 检查是否有文件上传
if(!isset($_FILES['uploaded_file']) || $_FILES['uploaded_file']['error'] !== UPLOAD_ERR_OK) {
die("文件上传失败或未选择文件");
}
$file = $_FILES['uploaded_file'];
// 1. 检查文件大小
if($file['size'] > $max_file_size) {
die("文件大小超过限制(最大2MB)");
}
// 2. 获取文件扩展名并进行白名单校验
$file_name = $file['name'];
$file_ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if(!in_array($file_ext, $allowed_extensions)) {
die("不支持的文件类型,仅允许: " . implode(', ', $allowed_extensions));
}
// 3. 生成安全的随机文件名(防止文件覆盖和路径遍历)
$safe_file_name = uniqid('file_', true) . '.' . $file_ext;
$upload_dir = 'uploads/';
$target_path = $upload_dir . $safe_file_name;
// 4. 确保目标目录存在
if(!is_dir($upload_dir)) {
mkdir($upload_dir, 0755, true);
}
// 5. 移动上传的文件
if(move_uploaded_file($file['tmp_name'], $target_path)) {
// 6. 设置安全权限(仅所有者可读写,其他人只读)
chmod($target_path, 0644);
echo "文件上传成功!<br>";
echo "保存为: " . htmlspecialchars($safe_file_name) . "<br>";
// 仅对图片文件显示预览
if(in_array($file_ext, ['jpg', 'jpeg', 'png', 'gif'])) {
echo "<img src='$target_path' style='max-width: 300px;' alt='上传的图片'>";
}
} else {
die("文件保存失败");
}
?>
示例 3:包含文件头校验的增强版上传代码
<?php
// 包含文件头校验的增强安全上传
class SecureFileUploader {
private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif'];
private $allowed_mime_types = ['image/jpeg', 'image/png', 'image/gif'];
private $max_file_size = 2 * 1024 * 1024; // 2MB
private $upload_dir = 'secure_uploads/';
// 图片文件的魔术字节签名
private $image_signatures = [
'jpg' => "\xFF\xD8\xFF",
'png' => "\x89\x50\x4E\x47",
'gif' => "GIF89a"
];
public function upload($file_field_name) {
// 验证上传状态
if(!isset($_FILES[$file_field_name])) {
throw new Exception('没有文件被上传');
}
$file = $_FILES[$file_field_name];
// 检查上传错误码
if($file['error'] !== UPLOAD_ERR_OK) {
$error_messages = [
UPLOAD_ERR_INI_SIZE => '文件大小超过服务器限制',
UPLOAD_ERR_FORM_SIZE => '文件大小超过表单限制',
UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
UPLOAD_ERR_NO_FILE => '没有文件被上传',
UPLOAD_ERR_NO_TMP_DIR => '缺少临时文件夹',
UPLOAD_ERR_CANT_WRITE => '文件写入失败',
UPLOAD_ERR_EXTENSION => 'PHP扩展阻止了文件上传'
];
throw new Exception($error_messages[$file['error']] ?? '未知上传错误');
}
// 1. 文件大小校验
if($file['size'] > $this->max_file_size) {
throw new Exception('文件大小不能超过 ' . ($this->max_file_size / 1024 / 1024) . 'MB');
}
// 2. 扩展名白名单校验
$original_name = basename($file['name']); // 使用basename防止路径遍历
$file_ext = strtolower(pathinfo($original_name, PATHINFO_EXTENSION));
if(!in_array($file_ext, $this->allowed_extensions)) {
throw new Exception('不允许的文件类型.仅支持: ' . implode(', ', $this->allowed_extensions));
}
// 3. MIME类型校验(不可单独依赖)
$detected_mime = mime_content_type($file['tmp_name']);
if(!in_array($detected_mime, $this->allowed_mime_types)) {
throw new Exception('检测到非法的MIME类型: ' . $detected_mime);
}
// 4. 文件头(魔术字节)校验
if(!$this->validateFileSignature($file['tmp_name'], $file_ext)) {
throw new Exception('文件内容与扩展名不匹配,可能被篡改');
}
// 5. 图片文件二次校验
if(in_array($file_ext, ['jpg', 'jpeg', 'png', 'gif'])) {
if(!$this->validateImageFile($file['tmp_name'])) {
throw new Exception('图片文件损坏或包含恶意内容');
}
}
// 6. 生成安全的随机文件名
$safe_filename = $this->generateSafeFilename($file_ext);
$target_path = $this->upload_dir . $safe_filename;
// 7. 确保上传目录存在且安全
$this->ensureSecureUploadDir();
// 8. 移动文件并设置权限
if(!move_uploaded_file($file['tmp_name'], $target_path)) {
throw new Exception('文件保存失败');
}
// 9. 设置安全文件权限
chmod($target_path, 0644);
return [
'original_name' => $original_name,
'saved_name' => $safe_filename,
'file_path' => $target_path,
'file_size' => $file['size'],
'file_type' => $detected_mime
];
}
/**
* 验证文件魔术字节签名
*/
private function validateFileSignature($tmp_file_path, $expected_ext) {
if(!file_exists($tmp_file_path)) {
return false;
}
$handle = fopen($tmp_file_path, 'rb');
if(!$handle) {
return false;
}
// 根据扩展名检查对应的魔术字节
$signature_length = strlen($this->image_signatures[$expected_ext] ?? '');
$file_signature = fread($handle, $signature_length);
fclose($handle);
return $file_signature === ($this->image_signatures[$expected_ext] ?? '');
}
/**
* 验证图片文件
*/
private function validateImageFile($tmp_file_path) {
// 使用getimagesize验证图片有效性
$image_info = @getimagesize($tmp_file_path);
if($image_info === false) {
return false;
}
// 检查图片是否包含PHP标签(图片马检测简化版)
$file_content = file_get_contents($tmp_file_path);
if(strpos($file_content, '<?php') !== false || strpos($file_content, '<?=') !== false) {
return false;
}
return true;
}
/**
* 生成安全的随机文件名
*/
private function generateSafeFilename($extension) {
// 使用更安全的随机生成方式
$random_bytes = random_bytes(16);
$safe_name = bin2hex($random_bytes) . '.' . $extension;
return $safe_name;
}
/**
* 确保上传目录安全
*/
private function ensureSecureUploadDir() {
if(!is_dir($this->upload_dir)) {
mkdir($this->upload_dir, 0755, true);
}
// 在目录中放置.htaccess文件防止直接执行PHP(Apache服务器)
$htaccess_content = <<<HTACCESS
# 防止直接执行PHP文件
<Files *.php>
Order Deny,Allow
Deny from all
</Files>
# 防止目录列表
Options -Indexes
# 设置文件缓存头(针对图片)
<FilesMatch "\.(jpg|jpeg|png|gif)$">
Header set Cache-Control "max-age=604800, public"
</FilesMatch>
HTACCESS;
$htaccess_path = $this->upload_dir . '.htaccess';
if(!file_exists($htaccess_path)) {
file_put_contents($htaccess_path, $htaccess_content);
}
// 放置一个空白的index.html防止目录遍历
$index_path = $this->upload_dir . 'index.html';
if(!file_exists($index_path)) {
file_put_contents($index_path, '<html><body><!-- 目录访问被阻止 --></body></html>');
}
}
}
// 使用示例
try {
$uploader = new SecureFileUploader();
$result = $uploader->upload('userfile');
echo "文件上传成功!<br>";
echo "原始文件名: " . htmlspecialchars($result['original_name']) . "<br>";
echo "保存文件名: " . htmlspecialchars($result['saved_name']) . "<br>";
echo "文件大小: " . round($result['file_size'] / 1024, 2) . " KB<br>";
// 显示图片预览
if(strpos($result['file_type'], 'image/') === 0) {
echo "<img src='{$result['file_path']}' style='max-width: 400px; border: 1px solid #ddd;'>";
}
} catch (Exception $e) {
echo "上传失败: " . htmlspecialchars($e->getMessage());
}
?>
示例 4:防止目录遍历攻击的文件下载代码
<?php
// 安全的文件下载实现 - 防止目录遍历攻击
class SecureFileDownload {
private $base_dir; // 允许访问的基准目录
private $allowed_extensions = ['pdf', 'txt', 'jpg', 'png', 'docx'];
public function __construct($base_directory) {
// 规范化基准目录路径
$this->base_dir = realpath($base_directory);
if($this->base_dir === false) {
throw new Exception('基准目录不存在: ' . $base_directory);
}
}
/**
* 安全地提供文件下载
*/
public function downloadFile($requested_file) {
// 1. 只允许文件名,不允许路径
$file_name = basename($requested_file);
// 2. 验证扩展名
$file_ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if(!in_array($file_ext, $this->allowed_extensions)) {
http_response_code(403);
die('不允许的文件类型');
}
// 3. 构建完整路径
$file_path = $this->base_dir . DIRECTORY_SEPARATOR . $file_name;
// 4. 规范化并验证路径(防止目录遍历)
$real_path = realpath($file_path);
if($real_path === false || strpos($real_path, $this->base_dir) !== 0) {
// 文件不存在或路径不在基准目录内
http_response_code(404);
die('文件不存在');
}
// 5. 验证确实是文件(不是目录)
if(!is_file($real_path)) {
http_response_code(403);
die('拒绝访问');
}
// 6. 设置下载头
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($file_name) . '"');
header('Content-Transfer-Encoding: binary');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . filesize($real_path));
// 7. 清空输出缓冲区并发送文件
ob_clean();
flush();
readfile($real_path);
exit;
}
/**
* 安全的文件查看(仅限图片)
*/
public function viewImage($requested_file) {
$file_name = basename($requested_file);
$allowed_image_ext = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$file_ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if(!in_array($file_ext, $allowed_image_ext)) {
http_response_code(403);
die('只允许查看图片文件');
}
$file_path = $this->base_dir . DIRECTORY_SEPARATOR . $file_name;
$real_path = realpath($file_path);
if($real_path === false || strpos($real_path, $this->base_dir) !== 0) {
http_response_code(404);
die('图片不存在');
}
if(!is_file($real_path)) {
http_response_code(403);
die('拒绝访问');
}
// 根据扩展名设置正确的Content-Type
$mime_types = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp'
];
header('Content-Type: ' . ($mime_types[$file_ext] ?? 'image/jpeg'));
header('Content-Length: ' . filesize($real_path));
readfile($real_path);
exit;
}
}
// 使用示例
try {
// 假设我们的文件存储在files/目录下
$downloader = new SecureFileDownload(__DIR__ . '/files');
// 从URL参数获取请求的文件名
$requested_file = $_GET['file'] ?? '';
if(empty($requested_file)) {
die('请指定要下载的文件名');
}
// 根据参数决定是下载还是查看
$action = $_GET['action'] ?? 'download';
if($action === 'view') {
$downloader->viewImage($requested_file);
} else {
$downloader->downloadFile($requested_file);
}
} catch (Exception $e) {
http_response_code(500);
echo '错误: ' . htmlspecialchars($e->getMessage());
}
?>
示例 5:文件上传的 HTML 表单与客户端校验
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>安全的文件上传表单</title>
<style>
.upload-container {
max-width: 500px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="file"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 3px;
}
button {
background-color: #4caf50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #45a049;
}
.error {
color: #d9534f;
margin-top: 5px;
font-size: 14px;
}
.progress-container {
display: none;
margin-top: 15px;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #4caf50;
width: 0%;
transition: width 0.3s;
}
</style>
</head>
<body>
<div class="upload-container">
<h2>安全文件上传演示</h2>
<p>仅允许上传JPG、PNG、GIF格式的图片文件,大小不超过2MB.</p>
<form
id="uploadForm"
action="secure_upload.php"
method="POST"
enctype="multipart/form-data"
>
<div class="form-group">
<label for="userfile">选择文件:</label>
<input
type="file"
name="userfile"
id="userfile"
accept=".jpg,.jpeg,.png,.gif"
required
/>
<div id="fileError" class="error"></div>
</div>
<div class="form-group">
<label for="description">文件描述(可选):</label>
<input
type="text"
name="description"
id="description"
maxlength="100"
/>
</div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div id="progressText">上传中: 0%</div>
</div>
<button type="submit" id="uploadButton">上传文件</button>
</form>
<div id="result" style="margin-top: 20px;"></div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("uploadForm");
const fileInput = document.getElementById("userfile");
const fileError = document.getElementById("fileError");
const uploadButton = document.getElementById("uploadButton");
const progressContainer = document.getElementById("progressContainer");
const progressFill = document.getElementById("progressFill");
const progressText = document.getElementById("progressText");
const resultDiv = document.getElementById("result");
// 最大文件大小(2MB)
const MAX_FILE_SIZE = 2 * 1024 * 1024;
// 允许的文件类型
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif"];
// 客户端文件验证
fileInput.addEventListener("change", function () {
const file = this.files[0];
fileError.textContent = "";
if (!file) {
return;
}
// 1. 验证文件大小
if (file.size > MAX_FILE_SIZE) {
fileError.textContent = "文件大小不能超过2MB";
this.value = ""; // 清空文件选择
return;
}
// 2. 验证文件类型
if (!ALLOWED_TYPES.includes(file.type)) {
fileError.textContent = "只允许JPG、PNG、GIF格式的图片文件";
this.value = "";
return;
}
// 3. 验证扩展名(双重检查)
const fileName = file.name.toLowerCase();
const validExtensions = [".jpg", ".jpeg", ".png", ".gif"];
const hasValidExtension = validExtensions.some((ext) =>
fileName.endsWith(ext)
);
if (!hasValidExtension) {
fileError.textContent = "文件扩展名不被允许";
this.value = "";
return;
}
// 验证通过,可以显示文件信息
console.log("文件验证通过:", {
name: file.name,
size: (file.size / 1024 / 1024).toFixed(2) + " MB",
type: file.type,
});
});
// AJAX文件上传(可选增强功能)
form.addEventListener("submit", function (e) {
e.preventDefault();
const file = fileInput.files[0];
if (!file) {
fileError.textContent = "请选择文件";
return;
}
// 禁用上传按钮,防止重复提交
uploadButton.disabled = true;
uploadButton.textContent = "上传中...";
// 显示进度条
progressContainer.style.display = "block";
// 使用FormData对象
const formData = new FormData(this);
// 使用XMLHttpRequest以便获取上传进度
const xhr = new XMLHttpRequest();
// 上传进度事件
xhr.upload.addEventListener("progress", function (e) {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percentComplete + "%";
progressText.textContent = "上传中: " + percentComplete + "%";
}
});
// 请求完成
xhr.addEventListener("load", function () {
progressContainer.style.display = "none";
uploadButton.disabled = false;
uploadButton.textContent = "上传文件";
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
resultDiv.innerHTML =
'<div style="color: green; padding: 10px; background-color: #dff0d8; border: 1px solid #d6e9c6; border-radius: 3px;">' +
"<strong>上传成功!</strong><br>" +
"文件名: " +
response.filename +
"<br>" +
"文件大小: " +
response.size +
" KB<br>" +
(response.preview
? '<img src="' +
response.preview +
'" style="max-width: 100%; margin-top: 10px;">'
: "") +
"</div>";
// 重置表单
form.reset();
} else {
resultDiv.innerHTML =
'<div style="color: #d9534f; padding: 10px; background-color: #f2dede; border: 1px solid #ebccd1; border-radius: 3px;">' +
"<strong>上传失败:</strong> " +
response.message +
"</div>";
}
} catch (e) {
resultDiv.innerHTML =
'<div style="color: #d9534f; padding: 10px; background-color: #f2dede; border: 1px solid #ebccd1; border-radius: 3px;">' +
"<strong>服务器响应解析失败</strong>" +
"</div>";
}
} else {
resultDiv.innerHTML =
'<div style="color: #d9534f; padding: 10px; background-color: #f2dede; border: 1px solid #ebccd1; border-radius: 3px;">' +
"<strong>服务器错误:</strong> HTTP " +
xhr.status +
"</div>";
}
});
// 请求错误
xhr.addEventListener("error", function () {
progressContainer.style.display = "none";
uploadButton.disabled = false;
uploadButton.textContent = "上传文件";
resultDiv.innerHTML =
'<div style="color: #d9534f; padding: 10px; background-color: #f2dede; border: 1px solid #ebccd1; border-radius: 3px;">' +
"<strong>网络错误,请稍后重试</strong>" +
"</div>";
});
// 发送请求
xhr.open("POST", form.action);
xhr.send(formData);
});
// 传统表单提交方式(备用)
const traditionalSubmitBtn = document.createElement("button");
traditionalSubmitBtn.type = "submit";
traditionalSubmitBtn.textContent = "传统方式上传";
traditionalSubmitBtn.style.marginLeft = "10px";
traditionalSubmitBtn.style.backgroundColor = "#337ab7";
traditionalSubmitBtn.addEventListener("click", function (e) {
// 允许表单默认提交行为
form.removeEventListener("submit", arguments.callee);
});
// 将传统提交按钮添加到表单中
form.appendChild(traditionalSubmitBtn);
});
</script>
</body>
</html>
预期输出:
当用户选择文件后,客户端 JavaScript 会立即验证文件大小和类型.如果选择了一个 3MB 的文件,会立即显示"文件大小不能超过 2MB"的错误信息.如果选择了一个 PDF 文件,会显示"只允许 JPG、PNG、GIF 格式的图片文件".只有通过验证的文件才能被提交.
实战项目:构建安全图片相册系统
项目需求分析
我们将构建一个完整的图片相册系统,包含以下功能:
- 用户认证系统:用户注册、登录、会话管理
- 安全图片上传:实现多层安全校验的图片上传功能
- 图片管理:查看、删除用户自己的图片
- 相册分享:生成安全的分享链接
- 管理员功能:管理所有用户和图片(可选)
技术方案
- 前端:HTML5 + CSS3 + JavaScript(客户端校验)
- 后端:PHP 7.4+(服务器端处理)
- 数据库:MySQL(存储用户信息和图片元数据)
- 文件存储:本地文件系统(存储上传的图片)
- 安全措施:
- 图片文件多层校验
- 防止 SQL 注入(使用 PDO 预处理语句)
- 防止 XSS 攻击(输出转义)
- 防止 CSRF 攻击(Token 验证)
- 安全的会话管理
项目结构
secure_gallery/
├── index.php # 首页
├── login.php # 登录页面
├── register.php # 注册页面
├── logout.php # 退出登录
├── upload.php # 文件上传处理
├── gallery.php # 个人相册
├── share.php # 分享查看
├── admin/ # 管理后台
│ ├── index.php
│ ├── users.php
│ └── images.php
├── includes/ # 包含文件
│ ├── config.php # 配置文件
│ ├── database.php # 数据库连接
│ ├── auth.php # 认证函数
│ ├── uploader.php # 文件上传类
│ └── functions.php # 通用函数
├── uploads/ # 上传文件存储目录
│ ├── images/ # 图片文件(Web根目录外)
│ └── thumbs/ # 缩略图
└── assets/ # 静态资源
├── css/
├── js/
└── images/
分步骤实现
步骤 1:数据库设计与初始化
-- 创建数据库
CREATE DATABASE IF NOT EXISTS secure_gallery CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE secure_gallery;
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(100),
role ENUM('user', 'admin') DEFAULT 'user',
status ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
INDEX idx_username (username),
INDEX idx_email (email)
);
-- 图片表
CREATE TABLE images (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) UNIQUE NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size INT NOT NULL,
mime_type VARCHAR(50) NOT NULL,
width INT,
height INT,
title VARCHAR(200),
description TEXT,
is_public BOOLEAN DEFAULT FALSE,
share_token CHAR(32) UNIQUE,
view_count INT DEFAULT 0,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_share_token (share_token),
INDEX idx_uploaded_at (uploaded_at)
);
-- 创建管理员用户(密码:Admin@123)
INSERT INTO users (username, email, password_hash, full_name, role) VALUES
('admin', 'admin@example.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '系统管理员', 'admin');
-- 创建测试用户(密码:Test@123)
INSERT INTO users (username, email, password_hash, full_name) VALUES
('testuser', 'user@example.com', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '测试用户');
步骤 2:配置文件与数据库连接
<?php
// includes/config.php
// 安全图片相册系统 - 配置文件
// 错误报告设置(开发环境)
error_reporting(E_ALL);
ini_set('display_errors', 1);
// 生产环境应设置为:
// error_reporting(0);
// ini_set('display_errors', 0);
// ini_set('log_errors', 1);
// ini_set('error_log', '/path/to/php-error.log');
// 时区设置
date_default_timezone_set('Asia/Shanghai');
// 会话安全设置
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1); // 仅HTTPS启用
ini_set('session.use_only_cookies', 1);
ini_set('session.cookie_samesite', 'Strict');
// 应用配置
define('APP_NAME', '安全图片相册');
define('APP_VERSION', '1.0.0');
define('BASE_URL', 'http:// localhost/secure_gallery'); // 根据实际修改
// 数据库配置
define('DB_HOST', 'localhost');
define('DB_NAME', 'secure_gallery');
define('DB_USER', 'root'); // 根据实际修改
define('DB_PASS', ''); // 根据实际修改
define('DB_CHARSET', 'utf8mb4');
// 文件上传配置
define('UPLOAD_MAX_SIZE', 5 * 1024 * 1024); // 5MB
define('ALLOWED_IMAGE_TYPES', ['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
define('ALLOWED_EXTENSIONS', ['jpg', 'jpeg', 'png', 'gif', 'webp']);
// 文件存储路径(建议放在Web根目录外)
define('UPLOAD_BASE_DIR', dirname(__DIR__) . '/private_uploads/');
define('THUMBNAIL_DIR', 'thumbs/');
// 安全配置
define('CSRF_TOKEN_NAME', 'csrf_token');
define('SESSION_TIMEOUT', 1800); // 30分钟
// 管理员邮箱
define('ADMIN_EMAIL', 'admin@example.com');
// 自动加载类
spl_autoload_register(function ($class_name) {
$file = __DIR__ . '/../classes/' . $class_name . '.php';
if (file_exists($file)) {
require_once $file;
}
});
// 启动会话(放在配置加载后)
if (session_status() === PHP_SESSION_NONE) {
session_start();
// 会话固定防护:定期更新会话ID
if (!isset($_SESSION['created'])) {
$_SESSION['created'] = time();
} else if (time() - $_SESSION['created'] > 600) { // 每10分钟更新一次
session_regenerate_id(true);
$_SESSION['created'] = time();
}
}
// 设置CSRF Token(如果不存在)
if (empty($_SESSION[CSRF_TOKEN_NAME])) {
$_SESSION[CSRF_TOKEN_NAME] = bin2hex(random_bytes(32));
}
// 通用函数:生成CSRF Token字段
function csrf_field() {
return '<input type="hidden" name="' . CSRF_TOKEN_NAME . '" value="' . $_SESSION[CSRF_TOKEN_NAME] . '">';
}
// 通用函数:验证CSRF Token
function validate_csrf_token($token) {
return isset($_SESSION[CSRF_TOKEN_NAME]) && hash_equals($_SESSION[CSRF_TOKEN_NAME], $token);
}
// 通用函数:安全的跳转
function redirect($url, $permanent = false) {
if ($permanent) {
header('HTTP/1.1 301 Moved Permanently');
}
header('Location: ' . $url);
exit();
}
// 通用函数:安全的输出
function escape($string) {
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}
?>
<?php
// includes/database.php
// 数据库连接类(使用PDO,防止SQL注入)
class Database {
private static $instance = null;
private $connection;
private function __construct() {
try {
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 禁用预处理模拟,提高安全性
PDO::ATTR_STRINGIFY_FETCHES => false
];
$this->connection = new PDO($dsn, DB_USER, DB_PASS, $options);
} catch (PDOException $e) {
// 生产环境应记录到日志文件,而不是直接显示
error_log('数据库连接失败: ' . $e->getMessage());
die('数据库连接失败,请稍后重试');
}
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new Database();
}
return self::$instance;
}
public function getConnection() {
return $this->connection;
}
/**
* 安全的查询执行(预处理语句)
*/
public function query($sql, $params = []) {
try {
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
return $stmt;
} catch (PDOException $e) {
error_log('数据库查询错误: ' . $e->getMessage() . ' SQL: ' . $sql);
throw new Exception('数据库操作失败');
}
}
/**
* 获取单行结果
*/
public function fetch($sql, $params = []) {
$stmt = $this->query($sql, $params);
return $stmt->fetch();
}
/**
* 获取所有结果
*/
public function fetchAll($sql, $params = []) {
$stmt = $this->query($sql, $params);
return $stmt->fetchAll();
}
/**
* 插入数据并返回最后插入的ID
*/
public function insert($sql, $params = []) {
$this->query($sql, $params);
return $this->connection->lastInsertId();
}
/**
* 开始事务
*/
public function beginTransaction() {
return $this->connection->beginTransaction();
}
/**
* 提交事务
*/
public function commit() {
return $this->connection->commit();
}
/**
* 回滚事务
*/
public function rollBack() {
return $this->connection->rollBack();
}
}
?>
步骤 3:增强版安全文件上传类
<?php
// includes/uploader.php
// 安全文件上传类 - 完整的多层防御实现
class SecureUploader {
private $db;
private $allowed_mime_types;
private $allowed_extensions;
private $max_file_size;
private $upload_base_dir;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
$this->allowed_mime_types = ALLOWED_IMAGE_TYPES;
$this->allowed_extensions = ALLOWED_EXTENSIONS;
$this->max_file_size = UPLOAD_MAX_SIZE;
$this->upload_base_dir = UPLOAD_BASE_DIR;
// 确保上传目录存在且安全
$this->ensureSecureDirectories();
}
/**
* 处理文件上传
*/
public function upload($file_field, $user_id, $title = '', $description = '', $is_public = false) {
// 验证上传状态
if (!isset($_FILES[$file_field]) || $_FILES[$file_field]['error'] !== UPLOAD_ERR_OK) {
throw new Exception($this->getUploadErrorMessage($_FILES[$file_field]['error'] ?? UPLOAD_ERR_NO_FILE));
}
$file = $_FILES[$file_field];
// 1. 文件大小校验
if ($file['size'] > $this->max_file_size) {
throw new Exception('文件大小不能超过 ' . ($this->max_file_size / 1024 / 1024) . 'MB');
}
// 2. 扩展名白名单校验
$original_name = basename($file['name']);
$file_ext = strtolower(pathinfo($original_name, PATHINFO_EXTENSION));
if (!in_array($file_ext, $this->allowed_extensions)) {
throw new Exception('不支持的文件类型.仅允许: ' . implode(', ', $this->allowed_extensions));
}
// 3. MIME类型校验
$detected_mime = mime_content_type($file['tmp_name']);
if (!in_array($detected_mime, $this->allowed_mime_types)) {
throw new Exception('检测到非法的MIME类型: ' . $detected_mime);
}
// 4. 文件头(魔术字节)校验
if (!$this->validateFileSignature($file['tmp_name'], $file_ext)) {
throw new Exception('文件内容与扩展名不匹配,可能被篡改');
}
// 5. 图片文件深度校验
if (!$this->validateImageFile($file['tmp_name'])) {
throw new Exception('图片文件损坏或包含恶意内容');
}
// 6. 检查图片中是否包含Webshell代码(简化版)
if ($this->containsMaliciousContent($file['tmp_name'])) {
throw new Exception('检测到潜在的安全威胁,文件被拒绝');
}
// 7. 生成安全的随机文件名
$stored_name = $this->generateSafeFilename($file_ext);
$file_path = $this->upload_base_dir . 'images/' . $stored_name;
// 8. 移动文件
if (!move_uploaded_file($file['tmp_name'], $file_path)) {
throw new Exception('文件保存失败,请检查目录权限');
}
// 9. 设置安全权限
chmod($file_path, 0644);
// 10. 获取图片尺寸
$image_info = getimagesize($file_path);
$width = $image_info[0] ?? 0;
$height = $image_info[1] ?? 0;
// 11. 生成缩略图
$thumbnail_path = $this->generateThumbnail($file_path, $stored_name, $width, $height);
// 12. 生成分享令牌(如果图片是公开的)
$share_token = $is_public ? $this->generateShareToken() : null;
// 13. 保存到数据库
$image_id = $this->saveImageToDatabase(
$user_id,
$original_name,
$stored_name,
$file_path,
$file['size'],
$detected_mime,
$width,
$height,
$title,
$description,
$is_public,
$share_token
);
// 14. 记录安全日志
$this->logSecurityEvent('file_upload', [
'user_id' => $user_id,
'image_id' => $image_id,
'original_name' => $original_name,
'file_size' => $file['size'],
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
return [
'id' => $image_id,
'original_name' => $original_name,
'stored_name' => $stored_name,
'file_path' => $file_path,
'thumbnail_path' => $thumbnail_path,
'share_token' => $share_token,
'width' => $width,
'height' => $height
];
}
/**
* 验证文件魔术字节签名
*/
private function validateFileSignature($tmp_file_path, $expected_ext) {
$signatures = [
'jpg' => "\xFF\xD8\xFF",
'jpeg' => "\xFF\xD8\xFF",
'png' => "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
'gif' => "GIF89a",
'webp' => "RIFF"
];
if (!isset($signatures[$expected_ext])) {
return false;
}
$expected_signature = $signatures[$expected_ext];
$signature_length = strlen($expected_signature);
$handle = fopen($tmp_file_path, 'rb');
if (!$handle) {
return false;
}
$file_signature = fread($handle, $signature_length);
fclose($handle);
return strncmp($file_signature, $expected_signature, $signature_length) === 0;
}
/**
* 验证图片文件
*/
private function validateImageFile($tmp_file_path) {
// 使用GD库验证图片
$image_info = @getimagesize($tmp_file_path);
if ($image_info === false) {
return false;
}
// 验证图片是否可以成功加载
$image_type = $image_info[2];
$supported_types = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP];
if (!in_array($image_type, $supported_types)) {
return false;
}
return true;
}
/**
* 检查文件是否包含恶意内容
*/
private function containsMaliciousContent($file_path) {
$content = file_get_contents($file_path);
// 检查常见的Webshell特征(简化示例)
$dangerous_patterns = [
'/<\?php\s*(system|exec|shell_exec|passthru|eval|assert)/i',
'/<script[^>]*>.*?(eval|document\.write|document\.cookie).*?<\/script>/is',
'/onload\s*=|onerror\s*=|onclick\s*=/i',
'/javascript:/i'
];
foreach ($dangerous_patterns as $pattern) {
if (preg_match($pattern, $content)) {
return true;
}
}
// 检查是否包含PHP标签(对于图片文件不应该有)
if (preg_match('/<\?php|<%|<\?=/i', $content)) {
return true;
}
return false;
}
/**
* 生成安全的随机文件名
*/
private function generateSafeFilename($extension) {
// 使用密码学安全的随机数生成器
$random_bytes = random_bytes(16);
$safe_name = bin2hex($random_bytes) . '.' . $extension;
return $safe_name;
}
/**
* 生成分享令牌
*/
private function generateShareToken() {
return bin2hex(random_bytes(16));
}
/**
* 生成缩略图
*/
private function generateThumbnail($source_path, $stored_name, $width, $height) {
$thumb_dir = $this->upload_base_dir . THUMBNAIL_DIR;
$thumb_path = $thumb_dir . $stored_name;
// 创建缩略图目录
if (!is_dir($thumb_dir)) {
mkdir($thumb_dir, 0755, true);
}
// 最大缩略图尺寸
$max_width = 200;
$max_height = 200;
// 计算缩略图尺寸
if ($width > $height) {
$new_width = $max_width;
$new_height = intval($height * ($max_width / $width));
} else {
$new_height = $max_height;
$new_width = intval($width * ($max_height / $height));
}
// 创建缩略图
$source_image = imagecreatefromstring(file_get_contents($source_path));
$thumbnail = imagecreatetruecolor($new_width, $new_height);
// 保持透明度(针对PNG和GIF)
imagealphablending($thumbnail, false);
imagesavealpha($thumbnail, true);
imagecopyresampled($thumbnail, $source_image, 0, 0, 0, 0,
$new_width, $new_height, $width, $height);
// 保存缩略图
$extension = strtolower(pathinfo($stored_name, PATHINFO_EXTENSION));
switch ($extension) {
case 'jpg':
case 'jpeg':
imagejpeg($thumbnail, $thumb_path, 85);
break;
case 'png':
imagepng($thumbnail, $thumb_path, 8);
break;
case 'gif':
imagegif($thumbnail, $thumb_path);
break;
case 'webp':
imagewebp($thumbnail, $thumb_path, 85);
break;
}
imagedestroy($source_image);
imagedestroy($thumbnail);
return $thumb_path;
}
/**
* 保存图片信息到数据库
*/
private function saveImageToDatabase($user_id, $original_name, $stored_name, $file_path,
$file_size, $mime_type, $width, $height,
$title, $description, $is_public, $share_token) {
try {
$sql = "INSERT INTO images (user_id, original_name, stored_name, file_path,
file_size, mime_type, width, height, title, description, is_public, share_token)
VALUES (:user_id, :original_name, :stored_name, :file_path,
:file_size, :mime_type, :width, :height, :title, :description, :is_public, :share_token)";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':user_id' => $user_id,
':original_name' => $original_name,
':stored_name' => $stored_name,
':file_path' => $file_path,
':file_size' => $file_size,
':mime_type' => $mime_type,
':width' => $width,
':height' => $height,
':title' => $title,
':description' => $description,
':is_public' => $is_public ? 1 : 0,
':share_token' => $share_token
]);
return $this->db->lastInsertId();
} catch (PDOException $e) {
// 如果数据库保存失败,删除已上传的文件
if (file_exists($file_path)) {
unlink($file_path);
}
throw new Exception('图片信息保存失败: ' . $e->getMessage());
}
}
/**
* 确保上传目录安全
*/
private function ensureSecureDirectories() {
$directories = [
$this->upload_base_dir,
$this->upload_base_dir . 'images/',
$this->upload_base_dir . THUMBNAIL_DIR
];
foreach ($directories as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// 在目录中放置.htaccess(Apache)或web.config(IIS)防止直接执行
$this->createSecurityFile($dir);
// 放置空白的index.html防止目录列表
$index_file = $dir . 'index.html';
if (!file_exists($index_file)) {
file_put_contents($index_file, '<!DOCTYPE html><html><body></body></html>');
}
}
}
/**
* 创建安全配置文件
*/
private function createSecurityFile($directory) {
// Apache服务器
$htaccess_content = <<<HTACCESS
# 防止直接执行脚本文件
<FilesMatch "\.(php|php5|phtml|pl|py|jsp|asp|sh|cgi)$">
Order Deny,Allow
Deny from all
</FilesMatch>
# 防止目录列表
Options -Indexes
# 设置缓存头
<FilesMatch "\.(jpg|jpeg|png|gif|webp)$">
Header set Cache-Control "max-age=2592000, public"
</FilesMatch>
# 限制文件访问(仅允许图片类型)
<FilesMatch "\.(jpg|jpeg|png|gif|webp)$">
Allow from all
</FilesMatch>
<FilesMatch "\.(php|txt|sql|log)$">
Deny from all
</FilesMatch>
HTACCESS;
$htaccess_path = $directory . '.htaccess';
if (!file_exists($htaccess_path)) {
file_put_contents($htaccess_path, $htaccess_content);
}
}
/**
* 记录安全事件
*/
private function logSecurityEvent($event_type, $data) {
$log_entry = sprintf(
"[%s] %s: %s\n",
date('Y-m-d H:i:s'),
$event_type,
json_encode($data, JSON_UNESCAPED_UNICODE)
);
$log_file = $this->upload_base_dir . 'security.log';
file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
}
/**
* 获取上传错误信息
*/
private function getUploadErrorMessage($error_code) {
$errors = [
UPLOAD_ERR_INI_SIZE => '文件大小超过服务器限制',
UPLOAD_ERR_FORM_SIZE => '文件大小超过表单限制',
UPLOAD_ERR_PARTIAL => '文件只有部分被上传',
UPLOAD_ERR_NO_FILE => '没有文件被上传',
UPLOAD_ERR_NO_TMP_DIR => '缺少临时文件夹',
UPLOAD_ERR_CANT_WRITE => '文件写入失败',
UPLOAD_ERR_EXTENSION => 'PHP扩展阻止了文件上传'
];
return $errors[$error_code] ?? '未知上传错误';
}
/**
* 获取用户图片列表
*/
public function getUserImages($user_id, $limit = 20, $offset = 0) {
$sql = "SELECT * FROM images WHERE user_id = :user_id
ORDER BY uploaded_at DESC LIMIT :limit OFFSET :offset";
$stmt = $this->db->prepare($sql);
$stmt->bindValue(':user_id', $user_id, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* 通过分享令牌获取图片
*/
public function getImageByToken($token) {
$sql = "SELECT i.*, u.username FROM images i
JOIN users u ON i.user_id = u.id
WHERE i.share_token = :token AND i.is_public = 1";
$stmt = $this->db->prepare($sql);
$stmt->execute([':token' => $token]);
$image = $stmt->fetch();
if ($image) {
// 更新查看次数
$update_sql = "UPDATE images SET view_count = view_count + 1 WHERE id = :id";
$this->db->prepare($update_sql)->execute([':id' => $image['id']]);
}
return $image;
}
/**
* 删除图片
*/
public function deleteImage($image_id, $user_id) {
// 获取图片信息
$sql = "SELECT * FROM images WHERE id = :id AND user_id = :user_id";
$stmt = $this->db->prepare($sql);
$stmt->execute([':id' => $image_id, ':user_id' => $user_id]);
$image = $stmt->fetch();
if (!$image) {
throw new Exception('图片不存在或无权删除');
}
// 删除物理文件
if (file_exists($image['file_path'])) {
unlink($image['file_path']);
}
// 删除缩略图
$thumb_path = $this->upload_base_dir . THUMBNAIL_DIR . $image['stored_name'];
if (file_exists($thumb_path)) {
unlink($thumb_path);
}
// 删除数据库记录
$delete_sql = "DELETE FROM images WHERE id = :id";
$this->db->prepare($delete_sql)->execute([':id' => $image_id]);
// 记录安全日志
$this->logSecurityEvent('file_delete', [
'user_id' => $user_id,
'image_id' => $image_id,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
return true;
}
}
?>
步骤 4:用户认证与权限管理
<?php
// includes/auth.php
// 用户认证与权限管理
class Auth {
private $db;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
}
/**
* 用户注册
*/
public function register($username, $email, $password, $full_name = '') {
// 验证输入
$this->validateRegistrationInput($username, $email, $password);
// 检查用户名是否已存在
if ($this->usernameExists($username)) {
throw new Exception('用户名已存在');
}
// 检查邮箱是否已存在
if ($this->emailExists($email)) {
throw new Exception('邮箱地址已被注册');
}
// 创建密码哈希
$password_hash = password_hash($password, PASSWORD_DEFAULT);
if ($password_hash === false) {
throw new Exception('密码加密失败');
}
// 插入用户记录
$sql = "INSERT INTO users (username, email, password_hash, full_name)
VALUES (:username, :email, :password_hash, :full_name)";
try {
$stmt = $this->db->prepare($sql);
$stmt->execute([
':username' => $username,
':email' => $email,
':password_hash' => $password_hash,
':full_name' => $full_name
]);
$user_id = $this->db->lastInsertId();
// 记录安全日志
$this->logSecurityEvent('user_register', [
'user_id' => $user_id,
'username' => $username,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
return $user_id;
} catch (PDOException $e) {
throw new Exception('用户注册失败: ' . $e->getMessage());
}
}
/**
* 用户登录
*/
public function login($username, $password) {
// 防止暴力破解:检查失败次数
if ($this->isLoginBlocked($username)) {
throw new Exception('账户暂时被锁定,请稍后重试');
}
// 获取用户信息
$user = $this->getUserByUsername($username);
if (!$user) {
// 记录失败尝试
$this->recordFailedLogin($username);
throw new Exception('用户名或密码错误');
}
// 验证密码
if (!password_verify($password, $user['password_hash'])) {
// 记录失败尝试
$this->recordFailedLogin($username);
throw new Exception('用户名或密码错误');
}
// 检查账户状态
if ($user['status'] !== 'active') {
throw new Exception('账户状态异常,请联系管理员');
}
// 清除失败记录
$this->clearFailedLogins($username);
// 更新最后登录时间
$this->updateLastLogin($user['id']);
// 设置会话
$this->setUserSession($user);
// 记录安全日志
$this->logSecurityEvent('user_login', [
'user_id' => $user['id'],
'username' => $username,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
]);
return true;
}
/**
* 设置用户会话
*/
private function setUserSession($user) {
// 销毁旧会话,创建新会话(防止会话固定)
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
$_SESSION['login_time'] = time();
// 设置会话过期时间
$_SESSION['expire_time'] = time() + SESSION_TIMEOUT;
// 设置用户指纹(防止会话劫持)
$_SESSION['user_fingerprint'] = $this->generateUserFingerprint();
}
/**
* 生成用户指纹(用于检测会话劫持)
*/
private function generateUserFingerprint() {
$components = [
$_SERVER['HTTP_USER_AGENT'] ?? '',
$_SERVER['REMOTE_ADDR'] ?? '',
// 可以添加更多组件,但注意隐私问题
];
return hash('sha256', implode('|', $components));
}
/**
* 验证用户指纹
*/
public function validateUserFingerprint() {
if (!isset($_SESSION['user_fingerprint'])) {
return false;
}
$current_fingerprint = $this->generateUserFingerprint();
return hash_equals($_SESSION['user_fingerprint'], $current_fingerprint);
}
/**
* 检查用户是否已登录
*/
public function isLoggedIn() {
if (!isset($_SESSION['user_id'], $_SESSION['expire_time'])) {
return false;
}
// 检查会话是否过期
if (time() > $_SESSION['expire_time']) {
$this->logout();
return false;
}
// 检查用户指纹(防止会话劫持)
if (!$this->validateUserFingerprint()) {
// 记录可疑活动
$this->logSecurityEvent('session_hijack_attempt', [
'user_id' => $_SESSION['user_id'] ?? 'unknown',
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
$this->logout();
return false;
}
// 延长会话过期时间(滑动过期)
$_SESSION['expire_time'] = time() + SESSION_TIMEOUT;
return true;
}
/**
* 获取当前用户信息
*/
public function getCurrentUser() {
if (!$this->isLoggedIn()) {
return null;
}
$sql = "SELECT id, username, email, full_name, role, status
FROM users WHERE id = :id";
$stmt = $this->db->prepare($sql);
$stmt->execute([':id' => $_SESSION['user_id']]);
return $stmt->fetch();
}
/**
* 检查用户是否是管理员
*/
public function isAdmin() {
if (!$this->isLoggedIn()) {
return false;
}
return $_SESSION['role'] === 'admin';
}
/**
* 用户退出
*/
public function logout() {
// 记录安全日志
if (isset($_SESSION['user_id'])) {
$this->logSecurityEvent('user_logout', [
'user_id' => $_SESSION['user_id'],
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
}
// 清除所有会话数据
$_SESSION = [];
// 删除会话cookie
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
// 销毁会话
session_destroy();
}
/**
* 验证注册输入
*/
private function validateRegistrationInput($username, $email, $password) {
// 用户名验证
if (strlen($username) < 3 || strlen($username) > 50) {
throw new Exception('用户名长度必须在3-50个字符之间');
}
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
throw new Exception('用户名只能包含字母、数字和下划线');
}
// 邮箱验证
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('请输入有效的邮箱地址');
}
// 密码验证
if (strlen($password) < 8) {
throw new Exception('密码长度至少8个字符');
}
// 密码强度检查
if (!preg_match('/[A-Z]/', $password) ||
!preg_match('/[a-z]/', $password) ||
!preg_match('/[0-9]/', $password)) {
throw new Exception('密码必须包含大小写字母和数字');
}
}
/**
* 检查用户名是否存在
*/
private function usernameExists($username) {
$sql = "SELECT COUNT(*) FROM users WHERE username = :username";
$stmt = $this->db->prepare($sql);
$stmt->execute([':username' => $username]);
return $stmt->fetchColumn() > 0;
}
/**
* 检查邮箱是否存在
*/
private function emailExists($email) {
$sql = "SELECT COUNT(*) FROM users WHERE email = :email";
$stmt = $this->db->prepare($sql);
$stmt->execute([':email' => $email]);
return $stmt->fetchColumn() > 0;
}
/**
* 通过用户名获取用户
*/
private function getUserByUsername($username) {
$sql = "SELECT * FROM users WHERE username = :username";
$stmt = $this->db->prepare($sql);
$stmt->execute([':username' => $username]);
return $stmt->fetch();
}
/**
* 更新最后登录时间
*/
private function updateLastLogin($user_id) {
$sql = "UPDATE users SET last_login = NOW() WHERE id = :id";
$this->db->prepare($sql)->execute([':id' => $user_id]);
}
/**
* 记录失败登录
*/
private function recordFailedLogin($username) {
$ip_address = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$key = 'login_failures:' . md5($username . '|' . $ip_address);
// 使用文件缓存模拟,实际应用中应使用Redis或Memcached
$cache_file = sys_get_temp_dir() . '/' . $key;
$failures = 0;
$first_failure_time = time();
if (file_exists($cache_file)) {
$data = json_decode(file_get_contents($cache_file), true);
if ($data && is_array($data)) {
$failures = $data['failures'] ?? 0;
$first_failure_time = $data['first_failure_time'] ?? time();
}
}
$failures++;
$data = [
'failures' => $failures,
'first_failure_time' => $first_failure_time,
'last_attempt' => time(),
'username' => $username,
'ip_address' => $ip_address
];
file_put_contents($cache_file, json_encode($data));
// 记录安全日志
$this->logSecurityEvent('login_failure', [
'username' => $username,
'ip_address' => $ip_address,
'failure_count' => $failures
]);
}
/**
* 清除失败登录记录
*/
private function clearFailedLogins($username) {
$ip_address = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$key = 'login_failures:' . md5($username . '|' . $ip_address);
$cache_file = sys_get_temp_dir() . '/' . $key;
if (file_exists($cache_file)) {
unlink($cache_file);
}
}
/**
* 检查登录是否被阻止
*/
private function isLoginBlocked($username) {
$ip_address = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$key = 'login_failures:' . md5($username . '|' . $ip_address);
$cache_file = sys_get_temp_dir() . '/' . $key;
if (!file_exists($cache_file)) {
return false;
}
$data = json_decode(file_get_contents($cache_file), true);
if (!$data || !is_array($data)) {
return false;
}
$failures = $data['failures'] ?? 0;
$first_failure_time = $data['first_failure_time'] ?? time();
// 如果30分钟内失败5次,则锁定15分钟
if ($failures >= 5 && (time() - $first_failure_time) < 1800) {
// 检查是否已经锁定15分钟
if ((time() - $first_failure_time) < 900) {
return true;
}
}
return false;
}
/**
* 记录安全事件
*/
private function logSecurityEvent($event_type, $data) {
$log_entry = sprintf(
"[%s] %s: %s\n",
date('Y-m-d H:i:s'),
$event_type,
json_encode($data, JSON_UNESCAPED_UNICODE)
);
$log_dir = dirname(__DIR__) . '/logs/';
if (!is_dir($log_dir)) {
mkdir($log_dir, 0755, true);
}
$log_file = $log_dir . 'security.log';
file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
}
}
?>
步骤 5:主要页面实现
<?php
// upload.php - 文件上传处理页面
require_once 'includes/config.php';
require_once 'includes/auth.php';
require_once 'includes/uploader.php';
$auth = new Auth();
$uploader = new SecureUploader();
// 检查用户是否登录
if (!$auth->isLoggedIn()) {
header('Location: login.php?redirect=' . urlencode($_SERVER['REQUEST_URI']));
exit;
}
$user = $auth->getCurrentUser();
$message = '';
$error = '';
// 处理表单提交
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 验证CSRF Token
$csrf_token = $_POST[CSRF_TOKEN_NAME] ?? '';
if (!validate_csrf_token($csrf_token)) {
$error = '安全验证失败,请重试';
} else {
try {
$title = escape($_POST['title'] ?? '');
$description = escape($_POST['description'] ?? '');
$is_public = isset($_POST['is_public']) && $_POST['is_public'] === '1';
$result = $uploader->upload('image_file', $user['id'], $title, $description, $is_public);
$message = '文件上传成功!';
// 如果开启了公开分享,显示分享链接
if ($is_public && $result['share_token']) {
$share_url = BASE_URL . '/share.php?token=' . $result['share_token'];
$message .= ' 分享链接: <a href="' . $share_url . '">' . $share_url . '</a>';
}
} catch (Exception $e) {
$error = '上传失败: ' . $e->getMessage();
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>上传图片 - <?php echo APP_NAME; ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<?php include 'includes/header.php'; ?>
<div class="container">
<h1>上传图片</h1>
<?php if ($message): ?>
<div class="alert alert-success">
<?php echo $message; ?>
</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-danger">
<?php echo $error; ?>
</div>
<?php endif; ?>
<div class="upload-form">
<form action="upload.php" method="POST" enctype="multipart/form-data" id="uploadForm">
<?php echo csrf_field(); ?>
<div class="form-group">
<label for="title">图片标题(可选):</label>
<input type="text" name="title" id="title" maxlength="200"
placeholder="请输入图片标题">
</div>
<div class="form-group">
<label for="description">描述(可选):</label>
<textarea name="description" id="description" rows="3"
placeholder="请输入图片描述" maxlength="1000"></textarea>
</div>
<div class="form-group">
<label for="image_file">选择图片文件:</label>
<input type="file" name="image_file" id="image_file"
accept=".jpg,.jpeg,.png,.gif,.webp" required>
<small class="form-text">
仅支持 JPG, PNG, GIF, WebP 格式,最大 5MB
</small>
<div id="filePreview" class="file-preview"></div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_public" value="1" id="is_public">
公开分享(生成可分享的链接)
</label>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" id="uploadButton">
上传图片
</button>
<a href="gallery.php" class="btn btn-secondary">返回相册</a>
</div>
</form>
</div>
<div class="upload-tips">
<h3>安全提示:</h3>
<ul>
<li>系统会对上传的图片进行多重安全校验,包括文件类型、大小、内容等</li>
<li>上传的图片会被随机重命名,防止文件覆盖攻击</li>
<li>所有图片文件都存储在安全目录中,无法直接通过URL访问执行</li>
<li>建议不要上传包含个人隐私信息的图片</li>
</ul>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<script src="assets/js/upload.js"></script>
</body>
</html>
步骤 6:相册展示页面
<?php
// gallery.php - 个人相册页面
require_once 'includes/config.php';
require_once 'includes/auth.php';
require_once 'includes/uploader.php';
$auth = new Auth();
$uploader = new SecureUploader();
// 检查用户是否登录
if (!$auth->isLoggedIn()) {
header('Location: login.php');
exit;
}
$user = $auth->getCurrentUser();
// 获取用户图片
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$limit = 12;
$offset = ($page - 1) * $limit;
$images = $uploader->getUserImages($user['id'], $limit, $offset);
// 获取图片总数(用于分页)
$db = Database::getInstance()->getConnection();
$count_sql = "SELECT COUNT(*) FROM images WHERE user_id = :user_id";
$stmt = $db->prepare($count_sql);
$stmt->execute([':user_id' => $user['id']]);
$total_images = $stmt->fetchColumn();
$total_pages = ceil($total_images / $limit);
// 处理删除请求
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_id'])) {
// 验证CSRF Token
$csrf_token = $_POST[CSRF_TOKEN_NAME] ?? '';
if (validate_csrf_token($csrf_token)) {
try {
$delete_id = intval($_POST['delete_id']);
$uploader->deleteImage($delete_id, $user['id']);
$success_message = '图片删除成功';
// 刷新页面
header('Location: gallery.php?page=' . $page . '&msg=' . urlencode($success_message));
exit;
} catch (Exception $e) {
$error_message = '删除失败: ' . $e->getMessage();
}
} else {
$error_message = '安全验证失败';
}
}
// 获取消息参数
$success_message = $_GET['msg'] ?? '';
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的相册 - <?php echo APP_NAME; ?></title>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="assets/css/gallery.css">
</head>
<body>
<?php include 'includes/header.php'; ?>
<div class="container">
<div class="gallery-header">
<h1>我的相册</h1>
<a href="upload.php" class="btn btn-primary">
<i class="upload-icon">↑</i> 上传新图片
</a>
</div>
<?php if ($success_message): ?>
<div class="alert alert-success">
<?php echo htmlspecialchars($success_message); ?>
</div>
<?php endif; ?>
<?php if (isset($error_message)): ?>
<div class="alert alert-danger">
<?php echo htmlspecialchars($error_message); ?>
</div>
<?php endif; ?>
<?php if (empty($images)): ?>
<div class="empty-gallery">
<div class="empty-icon">🖼️</div>
<h3>相册空空如也</h3>
<p>还没有上传任何图片,赶快上传第一张图片吧!</p>
<a href="upload.php" class="btn btn-primary">上传图片</a>
</div>
<?php else: ?>
<div class="gallery-stats">
<p>共 <?php echo $total_images; ?> 张图片</p>
</div>
<div class="image-grid">
<?php foreach ($images as $image): ?>
<div class="image-card">
<div class="image-preview">
<?php
$thumbnail_path = str_replace(UPLOAD_BASE_DIR, 'private_uploads/',
UPLOAD_BASE_DIR . THUMBNAIL_DIR . $image['stored_name']);
?>
<img src="<?php echo $thumbnail_path; ?>"
alt="<?php echo escape($image['title'] ?: $image['original_name']); ?>"
loading="lazy">
</div>
<div class="image-info">
<h4><?php echo escape($image['title'] ?: '未命名图片'); ?></h4>
<p class="image-meta">
<?php echo date('Y-m-d H:i', strtotime($image['uploaded_at'])); ?> |
<?php echo round($image['file_size'] / 1024); ?> KB
</p>
<?php if ($image['is_public'] && $image['share_token']): ?>
<p class="share-info">
<span class="share-badge">公开</span>
<a href="share.php?token=<?php echo $image['share_token']; ?>"
target="_blank" class="share-link">
分享链接
</a>
</p>
<?php endif; ?>
<div class="image-actions">
<button type="button" class="btn btn-sm btn-info view-btn"
data-image-id="<?php echo $image['id']; ?>">
查看
</button>
<form method="POST" class="delete-form"
onsubmit="return confirm('确定要删除这张图片吗?');">
<?php echo csrf_field(); ?>
<input type="hidden" name="delete_id" value="<?php echo $image['id']; ?>">
<button type="submit" class="btn btn-sm btn-danger">
删除
</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- 分页 -->
<?php if ($total_pages > 1): ?>
<nav class="pagination">
<ul>
<?php if ($page > 1): ?>
<li><a href="?page=<?php echo $page - 1; ?>">上一页</a></li>
<?php endif; ?>
<?php for ($i = 1; $i <= $total_pages; $i++): ?>
<?php if ($i == $page): ?>
<li class="active"><span><?php echo $i; ?></span></li>
<?php else: ?>
<li><a href="?page=<?php echo $i; ?>"><?php echo $i; ?></a></li>
<?php endif; ?>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<li><a href="?page=<?php echo $page + 1; ?>">下一页</a></li>
<?php endif; ?>
</ul>
</nav>
<?php endif; ?>
<?php endif; ?>
</div>
<!-- 图片查看模态框 -->
<div id="imageModal" class="modal">
<div class="modal-content">
<span class="close-modal">×</span>
<div id="modalImageContainer"></div>
<div id="modalImageInfo"></div>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<script src="assets/js/gallery.js"></script>
</body>
</html>
项目测试与部署指南
1. 环境准备
# 安装必要的PHP扩展
sudo apt-get install php php-mysql php-gd php-mbstring php-xml
# 检查扩展是否启用
php -m | grep -E "mysql|gd|mbstring"
# 创建项目目录结构
mkdir -p secure_gallery/{includes,uploads,assets/{css,js,images},logs}
mkdir -p private_uploads/{images,thumbs}
# 设置目录权限
chmod 755 secure_gallery
chmod 755 private_uploads
chmod 755 private_uploads/images
chmod 755 private_uploads/thumbs
chmod 644 private_uploads/.htaccess
chmod 644 private_uploads/images/.htaccess
chmod 644 private_uploads/thumbs/.htaccess
# 创建日志目录并设置权限
mkdir -p logs
chmod 755 logs
chmod 644 logs/security.log
2. 数据库配置
-- 执行数据库初始化脚本(见步骤1)
-- 修改includes/config.php中的数据库连接配置
3. 安全配置检查清单
创建安全检查脚本 check_security.php:
<?php
// 安全检查脚本
echo "=== PHP安全配置检查 ===\n\n";
// 1. 检查PHP版本
echo "1. PHP版本: " . PHP_VERSION . "\n";
if (version_compare(PHP_VERSION, '7.4.0') < 0) {
echo " 建议升级到PHP 7.4或更高版本\n";
}
// 2. 检查必要的扩展
$required_extensions = ['pdo_mysql', 'gd', 'mbstring', 'openssl'];
foreach ($required_extensions as $ext) {
echo "2. 扩展 {$ext}: " . (extension_loaded($ext) ? "✓ 已启用" : "✗ 未启用") . "\n";
}
// 3. 检查重要的PHP配置
$important_settings = [
'allow_url_fopen' => 'Off',
'allow_url_include' => 'Off',
'display_errors' => 'Off',
'log_errors' => 'On',
'expose_php' => 'Off',
'session.cookie_httponly' => '1',
'session.cookie_secure' => '1',
'session.use_only_cookies' => '1'
];
foreach ($important_settings as $setting => $recommended) {
$current = ini_get($setting);
echo "3. {$setting}: {$current}";
if ($current == $recommended) {
echo " ✓ 安全\n";
} else {
echo " 建议设置为: {$recommended}\n";
}
}
// 4. 检查文件权限
$directories_to_check = [
'private_uploads' => 0755,
'private_uploads/images' => 0755,
'private_uploads/thumbs' => 0755,
'logs' => 0755
];
foreach ($directories_to_check as $dir => $recommended_perm) {
if (is_dir($dir)) {
$perms = fileperms($dir) & 0777;
echo "4. 目录权限 {$dir}: " . decoct($perms);
if ($perms == $recommended_perm) {
echo " ✓ 安全\n";
} else {
echo " 建议设置为: " . decoct($recommended_perm) . "\n";
}
} else {
echo "4. 目录 {$dir}: ✗ 不存在\n";
}
}
// 5. 检查.htaccess文件
$htaccess_files = [
'private_uploads/.htaccess',
'private_uploads/images/.htaccess',
'private_uploads/thumbs/.htaccess'
];
foreach ($htaccess_files as $file) {
if (file_exists($file)) {
echo "5. 安全文件 {$file}: ✓ 存在\n";
// 检查内容
$content = file_get_contents($file);
if (strpos($content, 'Deny from all') !== false ||
strpos($content, 'Order Deny,Allow') !== false) {
echo " ✓ 包含基本安全规则\n";
}
} else {
echo "5. 安全文件 {$file}: ✗ 不存在\n";
}
}
echo "\n=== 检查完成 ===\n";
echo "✓ 表示安全配置正确\n";
echo " 表示需要关注或优化\n";
echo "✗ 表示存在安全问题\n";
?>

677

被折叠的 条评论
为什么被折叠?



