# ThinkPHP8 PHP 文件上传安全实战:从前端校验到后端防护全流程

PHP 文件上传全流程与安全处理指南

前言

在PHP中,文件上传是一个涉及 前端表单、HTTP传输、服务器处理、PHP内核解析、应用层验证与存储 的完整流程。核心步骤虽简单,但便捷性背后隐藏着极高安全风险(如恶意文件上传、文件篡改、重放攻击等),因此允许文件上传时务必严格遵循安全规范!

文件上传

表单加载

  1. 生成csrf Token

上传事件触发

  1. 生成hash值
  2. 申请随机盐值
  3. 生成时间戳
  4. 二次生成hash

后端校验

  1. 校验csrf
  2. 校验文件hash
  3. 校验时间差
  4. 校验随机hash
  5. 校验文件格式,文件类型,重命名,改权限

一、核心配置:php.ini 关键参数

确保PHP已启用文件上传并配置合理限制,修改php.ini中的以下核心指令(重启服务器生效):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; 允许文件上传(必须开启,默认On)
file_uploads = On

; 单个文件最大上传大小(建议按业务设置,如5MB=5*1024*1024)
upload_max_filesize = 5M

; POST请求总大小(需大于upload_max_filesize,避免因表单其他数据导致超限)
post_max_size = 10M

; 单次请求最多上传文件数(默认20,按需调整)
max_file_uploads = 20

; 临时上传目录(需确保服务器有读写权限,默认系统临时目录,建议自定义)
upload_tmp_dir = /var/www/tmp_uploads

二、环境依赖与工具准备

必备工具安装

1
2
3
4
5
# 1. 安装图像处理依赖(用于图片验证/处理)
sudo apt install php8.1-gd imagemagick libmagickwand-dev

# 2. 安装ThinkPHP文件系统扩展(简化存储操作)
composer require topthink/think-filesystem

三、文件上传基础流程(通用逻辑)

1. 前端表单:三要素配置(缺一不可)

文件上传的客户端触发依赖表单的核心配置,确保二进制数据能正确传输:

  • 请求方法:必须用 POSTmethod="post"),GET请求有URL长度限制,无法传输二进制文件。
  • 编码类型:强制设置 enctype="multipart/form-data"(HTTP专门用于二进制数据传输的格式,会将文件拆分为「数据块+元信息」组合传输)。
  • 上传控件:单文件用 <input type="file" name="fileField">name为后端识别标识);多文件需改为 name="fileField[]" 并添加 multiple 属性。

基础表单示例

1
2
3
4
5
<form action="upload.php" method="post" enctype="multipart/form-data">
<!-- 限制文件类型(前端预校验,仅辅助作用) -->
<input type="file" name="fileField" accept="image/jpeg,image/png">
<input type="submit" value="上传">
</form>

2. 浏览器:编码与HTTP传输

用户提交后,浏览器执行两步操作:

  1. multipart/form-data 格式编码表单数据,包含文件二进制内容、文件名、MIME类型、文件大小等元信息,用随机 boundary 分隔字段。
  2. 发送POST请求,请求头携带 Content-Type: multipart/form-data; boundary=xxxx,告知服务器如何解析请求体。

3. 服务器:接收与临时存储

服务器(Nginx/Apache)接收请求后:

  1. 解析请求头,通过 boundary 拆分请求体,提取文件数据和元信息。
  2. 将文件临时保存到 upload_tmp_dir 配置目录,生成随机临时文件名(如 phpXXXXXX.tmp)。

⚠️ 安全提醒:临时文件会在PHP脚本执行结束后自动删除,需防范「竞争条件攻击」(利用脚本执行间隙访问临时文件)。

四、ThinkPHP8 实战:安全文件上传实现

结合 前端完整性校验 + 后端多层校验,实现高安全性图片上传方案(可扩展至其他文件类型)。

核心设计思路(分步骤清晰化)

  1. 前端请求后端获取随机盐值(Salt)→ 避免固定密钥被破解。
  2. 前端计算:文件MD5哈希 + 盐值拼接后的SHA-256哈希(双重校验)。
  3. 上传时携带:哈希值、盐值、时间戳、CSRF令牌 → 阻断篡改/重放攻击。
  4. 后端校验顺序:CSRF有效性 → 时间戳超时 → 哈希完整性 → 文件合法性。

1. 前端实现(带安全校验)

文件名:upload.html(整合哈希计算、盐值请求、进度展示)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安全图片上传</title>
<!-- 引入依赖库 -->
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js"></script>

<!-- Tailwind配置 -->
<script>
tailwind.config = {
theme: {
extend: {
colors: { primary: '#3B82F6', secondary: '#F3F4F6' },
fontFamily: { inter: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>

<!-- 自定义工具类 -->
<style type="text/tailwindcss">
@layer utilities {
.upload-shadow { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); }
.btn-hover { @apply transition-all duration-300 hover:scale-105 hover:shadow-lg; }
.progress-bar { @apply h-2 bg-primary rounded-full transition-all duration-300 w-0; }
.file-meta { @apply text-xs text-gray-500 mt-1 break-all; }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center p-4 font-inter">
<div class="w-full max-w-md bg-white rounded-xl p-6 upload-shadow">
<h2 class="text-[clamp(1.2rem,3vw,1.5rem)] font-bold text-gray-800 mb-4 flex items-center">
<i class="fa fa-cloud-upload text-primary mr-2"></i>图片上传
</h2>
<p class="text-gray-500 text-sm mb-6">支持JPG、PNG、GIF格式,单个文件不超过5MB</p>

<!-- 预览区域 -->
<div id="imagePreview" class="mb-4 hidden">
<p class="text-sm font-medium text-gray-700 mb-2">预览图:</p>
<div class="border rounded-lg overflow-hidden bg-gray-50">
<img id="previewImg" src="" alt="预览图" class="w-full h-auto object-contain max-h-64">
</div>
</div>

<!-- 哈希信息展示 -->
<div id="hashInfo" class="mb-4 hidden text-sm">
<p class="font-medium text-gray-700 mb-1">文件哈希(MD5):</p>
<p id="hashValue" class="bg-gray-50 p-2 rounded text-gray-600 break-all max-h-20 overflow-auto"></p>
</div>

<div id="saltedHashInfo" class="mb-4 hidden text-sm">
<p class="font-medium text-gray-700 mb-1">带盐值哈希(SHA-256):</p>
<p id="saltedHashValue" class="bg-gray-50 p-2 rounded text-gray-600 break-all max-h-20 overflow-auto">计算中...</p>
</div>

<!-- 文件信息展示 -->
<div id="fileDetails" class="mb-4 hidden text-sm">
<p class="font-medium text-gray-700 mb-1">文件信息:</p>
<div class="bg-gray-50 p-2 rounded">
<p><span class="text-gray-500">文件名:</span><span id="fullFileName" class="file-meta"></span></p>
<p><span class="text-gray-500">MIME类型:</span><span id="fileMimeType" class="file-meta"></span></p>
</div>
</div>

<!-- 盐值信息展示 -->
<div id="saltInfo" class="mb-4 hidden text-sm">
<p class="font-medium text-gray-700 mb-1">盐值(Salt):</p>
<div class="bg-gray-50 p-2 rounded">
<p id="saltValue" class="file-meta">获取中...</p>
</div>
</div>

<!-- 上传表单 -->
<form id="uploadForm" class="space-y-4">
<label for="fileToUpload" class="block w-full border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer file-hover">
<input type="file" id="fileToUpload" name="fileToUpload" accept="image/jpeg,image/png,image/gif" class="hidden" onchange="handleFileSelect(this)">
<input type="hidden" name="__token__" value="{:token()}" /> <!-- ThinkPHP CSRF令牌 -->

<div class="mb-3 text-primary"><i class="fa fa-picture-o text-4xl"></i></div>
<div id="fileInfo" class="text-gray-600">
<p class="font-medium">点击或拖拽文件到此处上传</p>
<p class="text-xs mt-1 text-gray-400">支持JPG、PNG、GIF,最大5MB</p>
</div>
</label>

<!-- 上传进度条 -->
<div id="progressContainer" class="hidden">
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
<div id="uploadProgress" class="progress-bar"></div>
</div>
<p id="progressText" class="text-xs text-gray-500 mt-1 text-right">0%</p>
</div>

<!-- 哈希计算状态 -->
<div id="hashProgress" class="hidden text-xs text-gray-500">
<i class="fa fa-spinner fa-spin mr-1"></i>正在计算文件哈希...
</div>

<!-- 提交按钮 -->
<button type="submit" id="submitBtn" name="submit"
class="w-full bg-primary text-white py-3 px-4 rounded-lg font-medium btn-hover flex items-center justify-center"
disabled>
<i class="fa fa-upload mr-2"></i>确认上传
</button>
</form>

<!-- 消息提示 -->
<div id="message" class="mt-4 text-sm hidden"></div>
</div>

<script>
let fileHash = null; // 文件MD5哈希
let salt = null; // 后端获取的盐值
let fileName = null; // 原始文件名

/**
* 计算文件MD5哈希(大文件分片处理,避免内存溢出)
* @param {File} file - 选择的文件
* @return {Promise<string>} 文件MD5哈希值
*/
async function getFileHash(file) {
return new Promise((resolve, reject) => {
const chunkSize = 1024 * 1024; // 1MB分片
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
let offset = 0;

fileReader.onload = (e) => {
spark.append(e.target.result);
offset += chunkSize;
offset < file.size ? loadNextChunk() : resolve(spark.end());
};
fileReader.onerror = () => reject(new Error('文件读取失败'));

const loadNextChunk = () => {
const chunk = file.slice(offset, offset + chunkSize);
fileReader.readAsArrayBuffer(chunk);
};
loadNextChunk();
});
}

/**
* 向后端请求随机盐值
* @return {Promise<string>} 盐值
*/
async function getHashSalt() {
const response = await fetch('http://192.168.87.177:8080/Index/getsalt');
const data = await response.json();
if (!data.success) throw new Error('获取盐值失败');
return data.data?.salt;
}

/**
* 计算带盐值的SHA-256哈希(双重校验核心)
* @param {string} text - 待加密文本(MD5哈希 + 文件名)
* @param {string} salt - 盐值
* @return {Object} 哈希结果
*/
async function calculateHash(text, salt) {
if (!text || !salt) throw new Error('缺少文本或盐值');
const textWithSalt = text + salt;
return {
integrityHash: CryptoJS.SHA256(textWithSalt).toString(),
salt
};
}

/**
* 文件选择事件处理(前端预校验+信息展示)
* @param {HTMLInputElement} input - 文件输入控件
*/
async function handleFileSelect(input) {
// 初始化DOM元素(批量获取提升可读性)
const dom = {
fileInfo: document.getElementById('fileInfo'),
imagePreview: document.getElementById('imagePreview'),
previewImg: document.getElementById('previewImg'),
message: document.getElementById('message'),
hashInfo: document.getElementById('hashInfo'),
hashValue: document.getElementById('hashValue'),
hashProgress: document.getElementById('hashProgress'),
submitBtn: document.getElementById('submitBtn'),
fileDetails: document.getElementById('fileDetails'),
fullFileName: document.getElementById('fullFileName'),
fileMimeType: document.getElementById('fileMimeType'),
saltInfo: document.getElementById('saltInfo'),
saltValue: document.getElementById('saltValue'),
saltedHashInfo: document.getElementById('saltedHashInfo'),
saltedHashValue: document.getElementById('saltedHashValue')
};

// 重置状态
dom.message.classList.add('hidden');
[dom.hashInfo, dom.fileDetails, dom.saltInfo, dom.saltedHashInfo].forEach(el => el.classList.add('hidden'));
[fileHash, salt, fileName] = [null, null, null];
dom.submitBtn.disabled = true;

const file = input.files[0];
if (!file) return;
fileName = file.name;

// 1. 前端预校验:文件大小(5MB)
if (file.size > 5 * 1024 * 1024) {
showMessage('文件大小不能超过5MB', 'error');
input.value = '';
return;
}

// 2. 展示文件基本信息
dom.fullFileName.textContent = fileName;
dom.fileMimeType.textContent = file.type || '未知类型';
dom.fileDetails.classList.remove('hidden');

// 3. 图片预览
dom.fileInfo.innerHTML = `<div class="flex items-center justify-center text-gray-700"><i class="fa fa-check-circle text-green-500 mr-2"></i>文件已选择</div>`;
const reader = new FileReader();
reader.onload = (e) => {
dom.previewImg.src = e.target.result;
dom.imagePreview.classList.remove('hidden');
};
reader.readAsDataURL(file);

// 4. 获取后端盐值(关键安全步骤)
dom.saltInfo.classList.remove('hidden');
dom.saltValue.textContent = '获取中...';
try {
salt = await getHashSalt();
dom.saltValue.textContent = salt || '未返回盐值';
} catch (err) {
dom.saltValue.textContent = `获取失败:${err.message}`;
showMessage('盐值获取失败,可能影响上传安全性', 'error');
}

// 5. 计算文件MD5哈希(防篡改基础)
dom.hashProgress.classList.remove('hidden');
try {
fileHash = await getFileHash(file);
dom.hashValue.textContent = fileHash;
dom.hashInfo.classList.remove('hidden');

// 6. 计算带盐值哈希(双重校验)
if (salt) {
dom.saltedHashInfo.classList.remove('hidden');
const textToHash = fileHash + fileName;
const { integrityHash } = await calculateHash(textToHash, salt);
dom.saltedHashValue.textContent = integrityHash;
showMessage('带盐值哈希计算完成', 'info');
} else {
dom.saltedHashValue.textContent = '盐值缺失,无法计算';
}
dom.submitBtn.disabled = false;
showMessage('文件哈希计算完成,可上传', 'info');
} catch (err) {
console.error('哈希计算失败:', err);
showMessage('文件哈希计算失败,请重试', 'error');
} finally {
dom.hashProgress.classList.add('hidden');
}
}

/**
* 表单提交事件(异步上传+进度监听)
*/
document.getElementById('uploadForm').addEventListener('submit', (e) => {
e.preventDefault();
const dom = {
fileInput: document.getElementById('fileToUpload'),
submitBtn: document.getElementById('submitBtn'),
progressContainer: document.getElementById('progressContainer'),
uploadProgress: document.getElementById('uploadProgress'),
progressText: document.getElementById('progressText')
};

// 前置校验(避免无效请求)
if (!dom.fileInput.files.length) { showMessage('请先选择文件', 'error'); return; }
if (!fileHash) { showMessage('文件哈希未计算完成', 'error'); return; }
if (!salt) { showMessage('未获取到盐值,无法上传', 'error'); return; }

// 构建FormData(携带所有校验参数)
const timestamp = Date.now();
const formData = new FormData();
formData.append('fileToUpload', dom.fileInput.files[0]);
formData.append('fileHash', fileHash);
formData.append('salt', salt);
formData.append('originalFileName', fileName);
formData.append('timestamp', timestamp);
formData.append('__token__', document.querySelector('input[name="__token__"]').value);

// 添加带盐值哈希(双重校验凭证)
const saltedHash = document.getElementById('saltedHashValue').textContent;
if (saltedHash && !saltedHash.includes('失败')) {
formData.append('saltedHash', saltedHash);
}

// 初始化上传状态
dom.progressContainer.classList.remove('hidden');
dom.uploadProgress.style.width = '0%';
dom.progressText.textContent = '0%';
dom.submitBtn.disabled = true;
dom.submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i>上传中...';

// 发送AJAX请求(监听进度)
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://192.168.87.177:8080/UploadController/save', true);

// 上传进度监听(提升用户体验)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
dom.uploadProgress.style.width = `${percent}%`;
dom.progressText.textContent = `${percent}%`;
}
});

// 上传完成处理
xhr.addEventListener('load', () => {
try {
const response = JSON.parse(xhr.responseText);
if (xhr.status === 200 && response.success) {
document.getElementById('previewImg').src = response.data.url;
showMessage('上传成功!后端已验证文件完整性', 'success');
} else {
showMessage(response.msg || '上传失败', 'error');
}
} catch (err) {
showMessage('服务器响应格式错误', 'error');
}
resetUploadState();
});

// 网络错误处理
xhr.addEventListener('error', () => {
showMessage('网络错误,上传失败', 'error');
resetUploadState();
});

xhr.send(formData);
});

/**
* 重置上传状态(复用逻辑)
*/
function resetUploadState() {
const dom = {
submitBtn: document.getElementById('submitBtn'),
progressContainer: document.getElementById('progressContainer')
};
dom.submitBtn.disabled = false;
dom.submitBtn.innerHTML = '<i class="fa fa-upload mr-2"></i>确认上传';
dom.progressContainer.classList.add('hidden');
}

/**
* 消息提示(统一样式)
* @param {string} text - 提示文本
* @param {string} type - 类型:error/success/info
*/
function showMessage(text, type) {
const message = document.getElementById('message');
message.textContent = text;
message.classList.remove('hidden', 'text-red-500', 'text-green-500', 'text-blue-500');

switch(type) {
case 'error':
message.classList.add('text-red-500');
message.innerHTML = `<i class="fa fa-exclamation-circle mr-1"></i>${text}`;
break;
case 'success':
message.classList.add('text-green-500');
message.innerHTML = `<i class="fa fa-check-circle mr-1"></i>${text}`;
break;
default:
message.classList.add('text-blue-500');
message.innerHTML = `<i class="fa fa-info-circle mr-1"></i>${text}`;
}
}
</script>
</body>
</html>

2. 后端实现(多层安全校验)

(1)盐值生成接口(IndexController.php

用于向前端返回随机盐值(建议添加接口频率限制,防止滥用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
namespace app\controller;

use think\facade\Json;

class IndexController
{
/**
* 生成32位随机盐值(结合时间戳+随机数增强随机性)
*/
public function getsalt()
{
// uniqid(mt_rand(), true) 生成唯一字符串,md5加密后得到32位盐值
$salt = md5(uniqid(mt_rand(), true));
return Json::create([
'success' => true,
'data' => ['salt' => $salt]
]);
}
}

(2)文件上传核心接口(UploadController.php

校验顺序:CSRF令牌 → 文件存在性 → 数据完整性 → 哈希一致性 → 文件合法性 → 安全存储

先了解:PHP文件上传验证属性
验证属性 参数说明 示例
fileSize 限制文件最大字节数 fileSize:5242880(5MB=510241024)
fileExt 允许的文件后缀(逗号分隔/数组) fileExt:jpg,png,giffileExt:['jpg','png','gif']
fileMime 允许的MIME类型(精准校验文件真实类型) fileMime:image/jpeg,image/png
image 复合验证(尺寸+类型),宽高参数可选 image:1000,500(宽≤1000px、高≤500px)
核心方法:获取文件哈希值

ThinkPHP文件系统扩展内置哈希计算方法,直接调用即可:

1
2
3
4
5
6
// 获取表单上传文件
$file = request()->file('fileToUpload');
// 计算MD5哈希(推荐,效率高)
$md5Hash = $file->md5();
// 计算SHA-1哈希(可选,安全性更高)
$sha1Hash = $file->sha1();
完整接口代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
<?php
namespace app\controller;

use think\facade\Request;
use think\exception\ValidateException;
use think\facade\Filesystem;
use think\facade\Json;

class UploadController
{
/**
* 处理文件上传(核心接口)
*/
public function save()
{
// 1. 接收请求参数
$params = [
'file' => Request::file('fileToUpload'), // 上传文件对象
'formFileHash' => Request::param('fileHash'), // 前端MD5哈希
'salt' => Request::param('salt'), // 后端下发盐值
'saltedHash' => Request::param('saltedHash'), // 前端带盐哈希
'originalName' => Request::param('originalFileName'), // 原始文件名
'timestamp' => Request::param('timestamp'), // 前端时间戳
'csrfValid' => Request::checkToken() // CSRF令牌校验结果
];

// 2. 第一层校验:CSRF令牌(阻断跨站请求伪造)
if (!$params['csrfValid']) {
throw new ValidateException('CSRF令牌无效/已过期,请刷新页面');
}

// 3. 第二层校验:文件是否存在
if (empty($params['file'])) {
return Json::create([
'success' => false,
'code' => 400,
'msg' => '请选择上传文件'
]);
}

// 4. 第三层校验:数据完整性(时间戳+带盐哈希,阻断重放/篡改)
$fileHash = $params['file']->md5(); // 后端重新计算文件MD5(核心防篡改步骤)
$textForHash = $fileHash . $params['originalName']; // 与前端拼接规则一致
$integrityRes = $this->validateIntegrity(
$textForHash, // 后端拼接文本
$params['saltedHash'], // 前端带盐哈希
$params['salt'], // 后端盐值
$params['timestamp'] // 前端时间戳
);
if (!$integrityRes['success']) {
return Json::create($integrityRes);
}

// 5. 第四层校验:文件哈希一致性(最终确认文件未被篡改)
if ($params['formFileHash'] !== $fileHash) {
return Json::create([
'success' => false,
'code' => 403,
'msg' => '文件完整性校验失败(哈希不匹配,可能被篡改)'
], 403);
}

// 6. 第五层校验:文件合法性(大小、格式、MIME类型)
try {
// 验证规则:5MB限制 + 仅允许jpg/png/gif + 匹配对应MIME类型
validate([
'image' => 'fileSize:5242880'
. '|fileExt:jpg,jpeg,png,gif'
. '|fileMime:image/jpeg,image/png,image/gif'
])->check(['image' => $params['file']]);

// 7. 安全存储:按日期分目录 + MD5命名(去重+防文件名注入)
$savePath = Filesystem::disk('uploads')->putFile(
date('Ymd'), // 按年月日分目录(优化性能)
$params['file'], // 上传文件对象
'md5' // 以文件MD5为文件名(自动去重,避免覆盖)
);

// 8. 生成访问URL(拼接域名+存储路径)
$diskConfig = config('filesystem.disks.uploads');
$fileUrl = Request::domain() . $diskConfig['url'] . '/' . $savePath;

// 9. 返回成功结果
return Json::create([
'success' => true,
'code' => 200,
'msg' => '文件上传成功',
'data' => [
'path' => $savePath, // 服务器存储路径
'url' => $fileUrl // 前端访问URL
]
]);

} catch (ValidateException $e) {
// 文件合法性校验失败(返回具体错误信息)
return Json::create([
'success' => false,
'code' => 400,
'msg' => '文件校验失败:' . $e->getMessage()
]);
} catch (\Exception $e) {
// 服务器异常(如存储目录无权限)
return Json::create([
'success' => false,
'code' => 500,
'msg' => '上传失败:' . $e->getMessage()
]);
}
}

/**
* 验证数据完整性(时间戳防重放 + 带盐哈希防篡改)
* @param string $text 后端拼接核心文本
* @param string $frontHash 前端带盐哈希
* @param string $salt 后端盐值
* @param int $timestamp 前端时间戳(毫秒级)
* @return array 校验结果
*/
private function validateIntegrity(string $text, string $frontHash, string $salt, $timestamp): array
{
// 校验1:时间戳格式有效性
$timestamp = (int)$timestamp;
if ($timestamp <= 0) {
return [
'success' => false,
'code' => 400,
'msg' => '时间戳格式错误'
];
}

// 校验2:请求超时(1分钟内有效,阻断重放攻击)
$currentTime = time() * 1000; // 当前毫秒级时间戳
$timeout = 60 * 1000; // 1分钟超时阈值
if ($currentTime - $timestamp > $timeout) {
return [
'success' => false,
'code' => 403,
'msg' => '请求已超时,请在1分钟内完成上传'
];
}

// 校验3:带盐哈希一致性(后端重新计算比对)
$backendHash = hash('sha256', $text . $salt);
if ($backendHash !== $frontHash) {
return [
'success' => false,
'code' => 403,
'msg' => '数据完整性校验失败(可能被篡改)'
];
}

return [
'success' => true,
'code' => 200,
'msg' => '数据完整性校验通过'
];
}
}

(3)文件系统配置(config/filesystem.php

配置上传目录、访问URL及权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
return [
'default' => 'local', // 默认存储磁盘
'disks' => [
// 本地存储(uploads目录,需手动创建并设置权限)
'uploads' => [
'type' => 'local',
'root' => app()->getRootPath() . 'public/uploads', // 存储根目录(绝对路径)
'url' => '/uploads', // 访问URL前缀(如:域名/uploads/xxx.jpg)
'visibility' => 'public', // 访问权限:公开
'permissions' => [ // 文件/目录权限(安全配置)
'file' => [
'public' => 0644, // 文件:只读权限(禁止执行)
'private' => 0600,
],
'dir' => [
'public' => 0755, // 目录:读写权限(禁止执行)
'private' => 0700,
],
],
],
],
];

五、总结以及测试

1. 前端校验

  • 预限制文件类型(accept属性)和大小(客户端校验,减少无效请求)。
  • 双重哈希校验(MD5 + 带盐SHA-256),从源头降低篡改风险。
  • 强制携带CSRF令牌和时间戳,阻断跨站请求伪造(CSRF)和重放攻击。

2. 后端校验

  • CSRF令牌验证:ThinkPHP内置checkToken(),避免跨站上传恶意文件。
  • 时间戳超时校验:1分钟内有效,防止攻击者复用旧请求参数上传。
  • 哈希一致性校验:后端重新计算文件哈希,与前端比对,确认文件未被篡改。
  • 文件合法性校验:同时校验大小、后缀、MIME类型(避免伪装成图片的PHP脚本)。
  • 权限控制:上传目录设置为0644(文件)和0755(目录),禁止PHP执行权限;或通过Nginx/Apache配置deny all解析该目录PHP文件。

3. 存储安全

  • 文件名脱敏:用MD5/UUID重命名,避免文件名注入攻击(如../../etc/passwd)。
  • 分目录存储:按日期分目录,避免单目录文件过多导致性能下降,同时便于管理。
  • 禁止目录遍历:限制上传目录的访问范围,避免通过上传文件访问服务器其他资源。

六、效果演示

正常上传流程

正常文件上传效果
所有校验步骤通过,文件成功上传并返回访问URL

上传成功细节
哈希值、盐值等信息匹配,完整性校验通过

篡改拦截效果

篡改拦截效果
文件内容或哈希值被篡改后,后端立即阻断上传并返回明确错误信息