【版权声明】本文为百里飞洋原创博文,未经直接授权,禁止在任何平台以任何形式进行转载

【引用须知】本文仅接受适当合理地引用,并请附上本文博客的原文链接

【文章标题】由 OpenAI 人工智能大模型 GPT-3.5 拟定本文的章回体小标题,由作者百里飞洋审定修改

【文章插图】由通义万象绘制(人工智能艺术创作大模型 Powered by Alibaba Cloud)

温馨提示:本文涉及的所有数据已脱敏,包括但不限于文件名称、日期时间、邮箱地址、域名信息等,均为方便文章论述的仿造值,不代表真实情况。

00 本文目录

话说在那月黑风高的夜晚,寒鸦掠过孤月,飞向那怪爪乱生的阴森树枝,不时凄清的叫着。凉风拂过,卷着那黑云,遮住苍白的弯月。突然,树中传来一阵疾劲的风声,一道黑影闪过枝顶。一声轻响,黑影落在了屋檐上,同时屋中传来一阵疑惑的声音。原来,一位探案大侠名叫狄仁杰,在凌晨翻阅案文时,被电脑屏幕前显示的日志信息惊讶到了。

什么,古代没有电脑?我可没说故事发生在古代,我是说现在。

故事中“办案”的大侠就是我,这个凌晨加班备份系统数据的小小程序员。(流汗黄豆)

  • 【第一回】深夜寻踪排日志 找迹寻源严调查

  • 【第二回】极寒冰岛浮水面 狡兔三窟新域名

  • 【第三回】防不胜防前端页 测试完毕迷难解

  • 【第四回】逻辑欠缺后端码 修复完善尽开颜

话不多说,今天的故事咱们就按照上方的目录讲起。

01 日志追踪排查

第一回 “深夜寻踪排日志 找迹寻源严调查”

2024-4 (1)

今日在浏览自己接手的一个项目数据库时,发现头像上传日志里有几条不太合规的记录,大概如表格后三条这样:

id 文件名称 时间戳
42 正常图片 iMnGfF3KRD.jpg 1694594376124
43 异常文件 Y6ZM3L9sFV.jsp 1698822596987
44 异常文件 ptB5eUfodt.jsp 1698822611332
45 异常文件 Xs5JCB53IS.jsp 1698822656218

可以看到,有用户在头像接口上传了 3 个 JSP 文件,时间分别为 2023 年 11 月 01 日的 15:09:5615:10:1115:10:56,其中第一次与第二次操作间隔了 15 秒,第二次与第三次操作间隔了 45 秒。

经过进一步的排查,发现该用户于当日 15:04:58 使用邮箱账号 aba@example.xyz 获取了注册验证码,稍后又使用邮箱账号 aaa@example.xyz 重新获取了注册验证码,并于 15:07:39 注册成功。

02 邮箱域名调研

第二回 “极寒冰岛浮水面 狡兔三窟新域名”

2024-4 (2)

通过 Whois 查询,发现该邮箱所使用域名的注册信息如下(以下信息仅为论述需要,不代表真实情况):

查询信息 查询结果
域名 example.xyz
注册商 Namecheap
whois-ser whois.namecheap.com
更新日 2023-10-25 17:17:06 UTC+8
注册日 2023-10-14 22:52:24 UTC+8
过期日 2024-10-15 07:59:59 UTC+8
IANA_ID 1068
省/州 Capital Region
国别 冰岛 (IS)
状态 clientTransferProhibited (注册商禁止转移)
DNS dns1.registrar-servers.com
dns2.registrar-servers.com
DNSSEC 未配置

呦呵,刚刚注册一周的,这就有趣了,看着不像是正经域名,而且也无法访问。

03 前端功能测试

第三回 “防不胜防前端页 测试完毕迷难解”

2024-4 (3)

我先进行了前端页面的排查,测试是否能够直接上传 JSP 文件,看看我是否“错怪”了这位仁兄。

因为在选择文件时我已经做了 MIME 类型 校验[1](以下代码仅为演示需要,不保证功能完整性):

1
2
3
4
5
6
7
8
9
10
11
// 选择的图片文件改变时
onFileChange(file, fileList) {
// 校验文件的 MIME 类型
if (file.raw.type.indexOf('image/') === -1) {
// 文件类型校验未通过
this.$message.error('文件类型不合规,请上传常规图片类型的文件!')
} else {
// 文件类型校验通过,获取原始 File 对象的 URL,用于图片裁剪
this.croppedImg = URL.createObjectURL(file.raw)
}
}

经过测试,系统前端页面的头像裁剪组件是不能选择除图片外的文件的。但是为了更加规范图片类型,我就再上点儿强度,直接定死可选择的文件后缀名,省的以后再有用户上传奇奇怪怪的东西作为头像(比如 gif 动图):

1
2
3
4
5
6
7
8
9
10
// 选择的图片文件改变时
onFileChange(file) {
// 限制文件扩展名必须为 png、jpeg、jpg、webp
if (!/\.(png|jpe?g|webp)$/.test(file.raw.name.toLowerCase())) {
this.$message.error('请选择 png、jpeg、jpg、webp 格式的图片!')
} else {
// 获取原始 File 对象的 URL,用于图片裁剪
this.croppedImg = URL.createObjectURL(file.raw)
}
},

那既然前端功能没问题,就只能是后端没做好文件校验了,让人直接调用了接口上传了不该传的东西。

04 后端处理审查

第四回 “逻辑欠缺后端码 修复完善尽开颜”

2024-4 (4)

在前端图片裁剪完毕后,会获取裁剪后的 blob 数据,并还原为 File 对象,然后通过 POST 请求以 form-data 表单的形式提交给后端。在后端我使用的是 Node.js 的中间件 Multer 来处理 multipart/form-data 类型的表单数据[2],并执行相关文件校验和存储逻辑。(生产环境中推荐使用第三方 OSS 等服务存储文件,本文为了方便复现代码逻辑,存储到了后端目录)

于是我又检查了一遍后端代码(以下代码仅为演示需要,不保证功能完整性):

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
const express = require("express");
// 创建路由对象
const router = express.Router();
// 导入处理路径的核心模块
const path = require("path");
// 导入解析 form-data 格式表单数据的中间件
const multer = require("multer");
// 引入文件系统模块
const fs = require("fs");
// 导入生成字符串的工具函数
const generateString = require("../utils/generateString");

// 使用磁盘存储引擎,控制文件的存储
const avatarStorage = multer.diskStorage({
// 指定文件存储路径
destination: function (req, file, cb) {
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
// 记录文件存储的子目录
const subdirectory = `${year}/${month.toString().padStart(2, "0")}/${day.toString().padStart(2, "0")}/`;
file.subdirectory = subdirectory;

// __dirname 表示本文件所处目录
const fullDirectory = path.join(__dirname, "../uploads/avatar/" + subdirectory);

// 判断目录是否存在
if (!fs.existsSync(fullDirectory)) {
// 如果不存在,则递归创建目录
fs.mkdirSync(fullDirectory, { recursive: true });
}
cb(null, fullDirectory);
},
// 指定文件名
filename: function (req, file, cb) {
// 随机生成 10 位由字母和数字组成的字符串
const randomStr = generateString.lettersAndNumbers(10);
// 获取文件后缀名
const ext = path.extname(file.originalname);
// 拼接文件名
cb(null, Date.now() + "_" + randomStr + ext);
},
});
// 创建 multer 实例
const avatarUpload = multer({
// 文件存储路径,注意 dest 属性与 storage 属性不能同时存在
storage: avatarStorage,
// 限制上传的数据
limits: {
fields: 0, // 非文件 field 的最大数量
fileSize: 1024 * 1024 * 3, // 在 multipart 表单中,文件最大长度 (字节单位) 限制为 3MB
files: 1, // 限制文件数量
},
});
// 更新用户头像
router.post("/updateAvatar", avatarUpload.single("avatar"), userAdminHandler.updateAvatar);

可以发现,在后端代码中似乎缺少文件后缀名校验的逻辑。查阅官方文档,发现可以传递给 Multer 的选项 multer(opts) 有:

Key Description
dest or storage 在哪里存储文件
fileFilter 文件过滤器,控制哪些文件可以被接受
limits 限制上传的数据
preservePath 保存包含文件名的完整文件路径

其中 fileFilter 选项貌似可以满足我们的需求。它可以设置一个函数来控制什么文件可以上传以及什么文件应该跳过,这个函数应该看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function fileFilter (req, file, cb) {

// 这个函数应该调用 `cb` 用boolean值来
// 指示是否应接受该文件

// 拒绝这个文件,使用`false`,像这样:
cb(null, false)

// 接受这个文件,使用`true`,像这样:
cb(null, true)

// 如果有问题,你可以总是这样发送一个错误:
cb(new Error('I don\'t have a clue!'))

}

所以我们就可以将上方 multer 实例那部分的代码改造为:

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
// 创建 multer 实例
const avatarUpload = multer({
// 文件存储路径,注意 dest 属性与 storage 属性不能同时存在
storage: avatarStorage,
// 文件过滤器,控制哪些文件可以被接受
fileFilter: function (req, file, cb) {
// console.log(file)
// 限制文件扩展名必须为 png、jpeg、jpg
if (!/\.(png|jpe?g|webp)$/.test(file.originalname.toLowerCase())) {
// 拒绝这个文件,使用 `false`
cb(null, false)
// 发送错误信息
cb(new Error('请选择 png、jpeg、jpg、webp 格式的图片!'))
} else {
// 文件类型校验通过,接受这个文件,使用 `true`
cb(null, true)
}
},
// 限制上传的数据
limits: {
fields: 0, // 非文件 field 的最大数量
fileSize: 1024 * 1024 * 3, // 在 multipart 表单中,文件最大长度 (字节单位) 限制为 3MB
files: 1, // 限制文件数量
},
// 保存包含文件名的完整文件路径
// preservePath: true,
});

如此一来,后端也添加了文件扩展名校验,进一步规范了文件上传功能,补充了代码的逻辑缺失。

关于狄仁杰探案的故事,还有很多很多。欲知后事如何,且听下回分解!(本文完)


【参考内容】

[1] MIME 类型(IANA 媒体类型)

一种表示文档、文件或一组数据的性质和格式的标准,也通常称为多用途互联网邮件扩展MIME 类型。

[2] expressjs/multer

一个 node.js 中间件,用于处理 multipart/form-data 类型的表单数据,它主要用于上传文件。