一句话导读:附件模块代码不多,出事的地方基本都不在代码里。我们这套系统用的是浏览器直传 + 后端签发下载链接 + 数据库只存路径的组合,跑了两年多,中间因为配置漏项翻过车。这篇把控制台怎么配、前端怎么传、后端怎么签、库里怎么存讲一遍,最后重点说说把密钥放在前端之后,你实际承担了什么。文中的域名、Bucket 名和密钥都是占位符。
先说这套东西是干什么的
我们系统里有线路、客户、供应商、员工这几类业务对象,每一个都得挂附件。合同扫描件、设备现场照片、客户交上来的资质材料、员工的证件照,杂七杂八加起来几万个文件。
最省事的做法当然是都塞数据库。真塞进去你就知道了:一张业务表要开十几个附件字段,或者干脆上一个 blob 列把二进制内容存进去。结果就是备份文件一天比一天大,导出报表的时候数据库在那吭哧吭哧搬图片,查询慢得离谱。有个同事算过,我们全库备份里附件占了九成以上的体积,业务数据反而是零头。
所以文件放对象存储,数据库里只留一条索引:这个附件叫什么、在哪个目录。取的时候按索引去拿。
听起来挺简单,真写起来麻烦的地方就三个:
- 文件别从应用服务器过,直接让浏览器传给对象存储
- 文件是私有的,下载链接得临时签发
- 表里到底存什么,才能保证以后换桶的时候不用改数据
前两个是技术选型,第三个是数据设计。第三个没想清楚,前两个做得再漂亮,将来迁移的时候还是得哭。
为什么没让文件走服务器
最开始我们确实想过让文件先落到应用服务器,再由服务器转手传到对象存储。链路是这样:
浏览器 -> 应用服务器(落盘) -> 对象存储
试了一周就放弃了,三个问题凑在一起实在难受。
文件在服务器磁盘上多待一次,就得管临时文件的清理,不然 /tmp 慢慢就被撑满了。上传一个 100MB 的包,Tomcat 那条连接从头到尾被占着,用户那边进度条在走,服务器这边线程也在那干等。更麻烦的是我们后来上了两台应用服务器做负载,一个请求落在 A 机器上,下一个落在 B 机器上,临时目录还得想办法共享。
直传就把中间那一段摘掉了:
浏览器 -> 对象存储
浏览器 -> 应用服务器(只拿签名、写索引)
服务器只剩两件轻活:上传前给个通行证,传完记一条库。文件数据一个字节都不经过它。带宽省了,连接数省了,多实例也不用操心。
代价也很明确:浏览器手里必须有一把能上传的钥匙。这把钥匙怎么给、给多大权限,后面单独开一节讲。
控制台里那几项配置
建 Bucket
三个选项要留意。
地域跟 ECS、RDS 放同一个。跨地域走公网,延迟高,流量还另算钱;同地域走内网,白名单放通就行。
读写权限选私有。公开读意味着谁拿到 URL 谁就能下,业务附件显然不能这么放。
命名用纯小写加短横线。别用下划线,别用大写,更别用中文。Bucket 名会出现在域名里(https://<bucket>.oss-cn-<region>.aliyuncs.com),不规范的字符会在签名和解析上给你制造一堆麻烦,而且是那种报错信息完全看不出原因的麻烦。
申请 AccessKey
不要用主账号的密钥。主账号那把钥匙能开整个云账号下所有的门,泄露了就是账号级别的损失。
在 RAM 里建个子账号,只给它目标 Bucket 的读写权限,用这个子账号的 AccessKey。这样万一泄露,攻击者能动的也只有这一个桶。
密钥在控制台只完整显示一次,拿到之后立刻存好。我们当时图省事截了个图扔在群里,后来被安全那边点名批评了一次,这事儿别学。
配 CORS
这一步是直传能不能跑起来的前提,也是我们踩过的第一个坑。
浏览器有个跨域限制:页面在 http://your-domain,上传请求打到 https://<bucket>.oss-cn-shanghai.aliyuncs.com,这属于跨域,桶上必须显式允许,否则浏览器直接把请求拦下来,根本不发出去。
我们第一次配的时候,上传按钮点了没反应,进度条不动,控制台干干净净。查了半天以为密钥错了,又怀疑桶名写错了,最后才想起来打开浏览器开发者工具,Network 里那条请求标着 CORS error,连发都没发出去。
一条够用的规则:
| 配置项 | 值 | 说明 |
|---|---|---|
| 来源 Sources | http://your-domain |
精确到协议和端口,写 * 等于对所有网站开放 |
| 允许 Methods | PUT、POST、GET、HEAD |
上传用 POST,读取用 GET |
| 允许 Headers | * |
直传会带 Content-Type、x-oss-* 这些头 |
| 暴露 Headers | ETag、x-oss-request-id |
前端要读回 ETag 做校验的话必须暴露 |
| 缓存时间 | 600 秒 | 预检请求的结果缓存多久 |
来源那一栏别写 *。写 * 之后,任何一个网页都能拿着这套配置往你桶里传东西。生产环境把实际来源逐个列出来,本地调试临时加一条 http://localhost:8080,调完删掉。
顺带说一句,CORS 配错了的表现和"密钥错""桶名错"很像——都是上传失败、服务端日志没动静。区别在于 CORS 是浏览器拦的,请求压根没出去,所以服务端日志里连一条记录都不会有。以后遇到上传失败,先看浏览器控制台,再看服务端日志,能少走一半弯路。
最后记下这几个参数
配完之后,前后端各要一组,别混:
- 后端要
endpoint、AccessKeyId、AccessKeySecret、Bucket,用来签下载链接; - 前端要
AccessKeyId、AccessKeySecret、上传域名host,用来在上传前算签名。
AccessKey 前后端都要,这一点就是后面所有安全问题的源头。
数据库里到底存什么
这是整套设计里我改动最多的一处,第一版写完被自己推翻了一次。
表结构最后是这样的:
CREATE TABLE tb_file (
file_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
file_code VARCHAR(64) COMMENT '业务归属标识',
file_catalogue VARCHAR(64) COMMENT '目录,如 contract',
file_name VARCHAR(255) COMMENT '文件名,如 合同_8f3ka92mx1.pdf',
file_icon VARCHAR(64) COMMENT '图标类型',
file_date DATETIME COMMENT '上传时间',
PRIMARY KEY (file_id)
) COMMENT='附件索引表';
file_code 管归属,回答"这条附件属于哪个业务对象"。同一份合同可能同时挂在几个对象下,所以用它做关联,而不是按业务拆成七八张附件表。
file_catalogue 加 file_name,拼起来正好是桶里的完整 key:contract/合同_8f3ka92mx1.pdf。
file_date 就是列表展示和排序用。
第一版我加了两列,file_url 存完整地址,还有一列存桶名。后来想明白了,这两列是在给自己挖坑。
Bucket 名一旦写进表里,存储位置就焊死在数据上了。将来附件要迁到新桶,或者哪天真要换个云厂商,改代码是一行配置的事,改数据是几百万行记录批量更新,中间还不能出错,还得分批灰度。把存储位置留在代码配置里、表里只留桶内路径,迁移就变成:新桶建好,文件搬过去,配置改一行。
代价是这套逻辑一个对象存储只能对一个桶生效。如果真有多个桶并存的需求,那就补一列 bucket,给个默认值兜底。但那是需求真出现之后才做的事,不是一开始就预留的——预留的那一列,十有八九永远都是默认值。
还有个细节。上传时前端拿到的对象名是"目录/文件名"的完整形式,写库的时候拆成两段分别存:
param.fileCatalogue = filePath.split("/")[0];
param.fileName = filePath.split("/")[1];
下载时再拼回去:
param.fileName = data.fileCatalogue + "/" + data.fileName;
拆开存的好处很实在:列表页能直接展示文件名,按目录筛选就是一个 where 条件,不用每次都对字符串做切割。
前端直传
通行证是本地算出来的
对象存储不认登录态,它只认签名。前端要上传,就得先在本地造一份凭证,跟文件一起 POST 过去。
凭证三部分:
- policy:一段 Base64 编码的 JSON,声明这次上传的有效期和约束
- signature:用 AccessKeySecret 对 policy 做 HMAC-SHA1 的结果
- OSSAccessKeyId:告诉服务端是哪个 AccessKey 签的
policy 说"允许传多大、传到什么时候",signature 证明"这段 policy 确实由持密钥的人签的"。服务端自己验一遍,过了才收文件。
策略本体:
var policyText = {
"expiration": "2030-01-01T12:00:00.000Z", // 策略失效时间
"conditions": [
["content-length-range", 0, 1048576000] // 单文件上限,约 1GB
]
};
content-length-range 是这套方案里唯一能约束上传者行为的条款,务必写。不写,任何拿到密钥的人都能往你桶里灌几个 TB,账单出来的时候你才知道。
签名:
var policyBase64 = Base64.encode(JSON.stringify(policyText));
var bytes = Crypto.HMAC(Crypto.SHA1, policyBase64, accesskey, { asBytes: true });
var signature = Crypto.util.bytesToBase64(bytes);
这里的 expiration 是策略层面的失效时间,一般设得很长——我们直接写到了 2030 年。因为 policy 本身不随请求变化,前端不需要每次重算。真正让链接短期有效的是下载那边的预签名 URL,两者不是一回事,别搞混。
上传器和参数装配
上传用的 plupload,多文件、进度条、HTML5 降级都封装好了,省事。核心是 BeforeUpload 钩子里装配参数:
function set_upload_param(up, filename, ret) {
g_object_name = g_dirname;
if (filename != '') {
suffix = get_suffix(filename);
calculate_object_name(filename);
}
new_multipart_params = {
'key': g_object_name,
'policy': policyBase64,
'OSSAccessKeyId': accessid,
'success_action_status': '200',
'signature': signature
};
up.setOption({
'url': host,
'multipart_params': new_multipart_params
});
up.start();
}
四个参数里有两个埋着雷。
success_action_status 必须写成 '200'。对象存储上传成功的默认返回码是 204,而前端判断成功的逻辑通常写的是 info.status == 200。不改这一项,文件老老实实传上去了,页面却告诉你失败,然后用户重复上传,桶里多出一堆重复文件。
key 决定文件在桶里的最终位置,在 BeforeUpload 里动态算,因为 plupload 每次处理的文件名不一样。
对象名怎么拼
规则是"目录 + 原名 + 随机串 + 后缀":
function calculate_object_name(filename) {
suffix = get_suffix(filename);
g_object_name = g_dirname + filename.split('.')[0] + "_" + random_string(10) + suffix;
}
那十位随机串是防覆盖的。业务场景里"合同.pdf""照片.jpg"这种名字重复率极高,两个人上传同名文件,后传的就把先传的盖掉了。而数据库里旧记录那个 key 还指向同一个对象,结果就是旧附件的内容变成了新文件——用户点开一看,是别人的合同。这种事出一次就够喝一壶的。
目录前缀不写死在代码里,从按钮的 dir 属性读:
<a id="postfiles" dir="contract" href="javascript:void(0);" class='btn'>开始上传</a>
function get_dirname() {
dir = $("#postfiles").attr("dir");
if (dir != '' && dir.indexOf('/') != dir.length - 1) {
dir = dir + '/';
}
g_dirname = dir;
}
这样一份上传脚本能被多个页面复用,每个页面只改 dir 的值,附件就分门别类落到不同目录:
| 页面 | dir 取值 | 桶内路径 |
|---|---|---|
| 客户资料 | client |
client/营业执照_8f3ka92mx1.jpg |
| 供应商资料 | supplier |
supplier/合作协议_2bx91lpq7z.pdf |
| 员工档案 | staff |
staff/身份证_77mn0qwe5t.png |
| 线路项目文档 | project |
project/竣工图_1ac8vu3z4k.pdf |
按业务对象分目录,比按上传时间分(2026/10/)好用得多。迁移、清理、权限收紧都能按目录整体操作。真有按时间归档的需求,dir 里写多级前缀也行。
传完之后
文件传完,前端拿到对象名,紧接着写索引:
FileUploaded: function(up, file, info) {
if (info.status == 200) {
insertFileDate(get_uploaded_object_name(file.name));
}
}
function insertFileDate(filePath) {
var param = {};
param.fileCode = "order" + fileCode; // 业务归属
param.fileCatalogue = filePath.split("/")[0]; // 目录
param.fileName = filePath.split("/")[1]; // 文件名
ajaxLoad(MetgodAccount.file_add, param, function(rs) {
if (rs.success) { table_file.ajax.reload(); }
}, "POST", false);
}
这里的顺序值得想一想:文件已经躺在桶里了,写库却可能失败。网络抖一下、登录态过期、数据库抽风,任何一种都会留下一个孤儿对象——桶里有文件,库里没记录,用户看不见也删不掉。
反过来先写库再传文件,上传失败就会留一条指向不存在对象的死记录,用户点下载直接报错。
两种顺序都有残留,我们选的是先传后写。孤儿对象代价低,不占业务逻辑,定期扫一遍桶和库的差集就能清掉;死记录是直接暴露给用户的,体验上更难受。真要根治得上两阶段确认或者定期对账任务,那是另一个话题了。
后端的两个接口
依赖
一个包:
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>2.7.0</version>
</dependency>
配置集中在一个类里
域名、密钥、桶名,全部收在一个类里,别散到各个业务代码中去:
public class OssConfigUtils {
private static String endpoint = "https://oss-cn-shanghai.aliyuncs.com";
private static String accessKeyId = "LTAI********************";
private static String accessKeySecret = "************************";
public static String defaultBucket = "your-bucket";
public static OSSClient getOSSClient() {
return new OSSClient(endpoint, accessKeyId, accessKeySecret);
}
}
集中配置的好处迁移的时候最明显。换桶改 defaultBucket 一行,换地域改 endpoint 一行。前面数据库不存桶名省下来的力气,到这一步才算真正兑现。
有一点得提醒:OSSClient 本身是线程安全的,每次请求都 new 一个再 shutdown,并发上来之后会白白多出不少连接开销。稳妥的做法是做成单例,或者交给 Spring 托管成 Bean。
预签名下载
私有桶的文件不能直接给 URL,给了就是 AccessDenied。正确做法是后端用 SDK 签一个临时链接:
@RequestMapping(value = "presign", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> presign(@RequestBody UploadFile file) throws Exception {
OSSClient ossClient = OssConfigUtils.getOSSClient();
// 链接有效期
Date expiration = new Date(new Date().getTime() + 3600 * 1000);
URL url = ossClient.generatePresignedUrl(
OssConfigUtils.defaultBucket,
file.getFileName(),
expiration);
ossClient.shutdown();
return getSuccessMessage(url.toString());
}
generatePresignedUrl 干的事:把桶名、对象名、过期时间拼成待签字符串,用 AccessKeySecret 签一下,再把签名作为查询参数挂到 URL 上。签出来的链接大概长这样:
https://your-bucket.oss-cn-shanghai.aliyuncs.com/contract/合同_8f3ka92mx1.pdf
?Expires=1791623636
&OSSAccessKeyId=LTAI********************
&Signature=********************
拿到链接的人,在有效期内能直接下;过了时间,服务端验签失败,拒绝。
这里有个单位问题,很容易看走眼。Date 的构造参数是毫秒,3600 * 1000 才是一小时。如果写成 3600 * 10000,实际有效期会变成十小时——代码照跑,注释还写着"1 小时",谁也不觉得有问题。等到某天一条链接被转发到外部,才发现暴露窗口比预想的大了一个数量级。这种偏差不会报错,只能靠人看出来。
有效期定多长是个取舍。太短,用户点了下载还没存完链接就失效了;太长,链接一旦泄露,暴露时间就长。业务附件我们用的是一小时。
为什么不用后端转发文件流:转发要让文件穿过应用服务器,大文件长时间占着 Tomcat 连接,多实例部署时带宽还要重复消耗一份。预签名把文件传输留在浏览器和对象存储之间,服务器只做一次几毫秒的签名计算。
下载
前端三步:取索引、换链接、存文件。
$('#dataTables-file').on('click', 'a#download', function() {
var data = $('#dataTables-file').DataTable().row($(this).parents('tr')).data();
// 1. 两段 key 拼回完整对象名
var param = {};
param.fileName = data.fileCatalogue + "/" + data.fileName;
// 2. 换预签名链接
ajaxLoad(MetgodAccount.downFile, param, function(rs) {
if (rs.success) {
const url = rs.msg.replace(/\\/g, '/');
// 3. 取二进制内容再保存
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
xhr.onload = () => {
if (xhr.status === 200) {
saveAs(xhr.response, data.fileName);
}
};
xhr.send();
}
}, "POST", false);
});
为什么不用 window.open(url) 直接开?
因为文件名。直接开链接,浏览器保存的时候用的是对象名,也就是 合同_8f3ka92mx1.pdf 这种带随机串的名字。用户拿到手完全不知道这是啥。转成 blob 之后前端能自己指定保存名,把随机串去掉,还原成用户当初上传的名字。多这几行代码,换来的是用户不用去猜哪个文件是哪个。
rs.msg.replace(/\\/g, '/') 这句也别省。后端返回的 URL 在某些序列化路径下会把正斜杠转义成反斜杠,直接拿去请求会得到一个无效地址,而且报错信息看不出所以然。
换桶的时候要动哪些地方
| 位置 | 改什么 | 数量 |
|---|---|---|
| 后端配置类 | defaultBucket |
1 处 |
| 后端配置类 | 跨地域的话还有 endpoint |
1 处 |
| 前端上传脚本 | host(上传域名) |
每个引用该脚本的文件 1 处 |
| 前端上传脚本 | g_dirname 初始值 |
同上 |
| 控制台 | 新桶的 CORS 规则 | 1 条 |
| 数据库 | 不动 | 0 |
数据库一列都不用改,这就是"只存桶内路径"换来的。
前端那两处要当心。如果上传脚本在多个页面里各存了一份副本,就得逐个改到位。漏掉一个页面,那个页面的上传会安安静静地写进旧桶,谁也不会发现,直到某天有人问"这个附件怎么找不到了"。
新桶的 CORS 一定提前配好。忘了这一步,表现就是上传请求被浏览器拦下,控制台有跨域报错,服务端日志一片空白。前面说过,这个现象和密钥错误、桶名错误很像,排查顺序记住是"先浏览器、后服务端"。
安全边界:把密钥放进前端之后,你实际承担了什么
前面一直绕着一个事没细说:AccessKeySecret 就明明白白写在前端 JS 里。任何人打开开发者工具,F12 一下,密钥就在那儿。
先说清楚这事的性质。这不是配置疏忽,也不是我们偷懒,而是浏览器直传这个模式的必然结果——浏览器要自己算签名,就必须拿到能算签名的密钥。你把算签名这一步放在哪,密钥就得放在哪。
那问题就变成:接受这个前提之后,风险到底有多大,能收窄到什么程度。
密钥泄露之后,对方能干什么
很多人第一反应是"大不了被人传点文件"。实际情况比这难受,对方能做四件事。
当免费图床和网盘用。 这是最常见的。密钥流出去之后,别人拿去当自己的存储用,传图片、传视频、传备份包。你这边看到的是存储量往上涨,流量费用跟着走。桶的容量没有上限,账单也没有。
传违规内容。 这个最要命。桶是你的,域名是你的,内容是从你这里出去的。真出了事,找的是桶的所有者,不是上传的人。轻则桶被限制访问,重则整个账号受影响。而且你根本不知道对方传了什么,除非去翻访问日志。
覆盖你已有的对象。 前面说过对象名里有十位随机串,撞上的概率很低,所以这条不是主要威胁。但如果某些历史文件是按旧规则命名的、没有随机串,那就等于敞开让人改。附件被换成别的内容,业务上可能很久都发现不了。
列举并下载全部附件。 这条取决于权限给得多宽。如果子账号的权限里带了 ListObjects,对方就能把整个桶的对象列出来,再一个个下走。所以给 RAM 子账号授权的时候,ListObjects 能不给就不给。
要强调的是,这几件事的前提都只是"拿到密钥",不需要登录你的系统,也不需要知道任何业务逻辑。
policy 能拦住什么
policy 是前端唯一能自主控制的东西,但它的能力边界很窄,得认清。
能做的:
content-length-range限单文件大小,防止有人拿你桶当视频站conditions里可以加 key 前缀约束,把上传范围锁死在某个目录- 可以限制
content-type,只允许特定类型 - 可以显式禁止上传者改 ACL,避免文件被设成公开读
不能做的:限制上传次数、识别上传者是谁、判断内容合不合规。policy 是一张写死的通行证,谁拿着都一样管用。
加前缀约束的写法:
var policyText = {
"expiration": "2030-01-01T12:00:00.000Z",
"conditions": [
["content-length-range", 0, 104857600], // 上限压到 100MB
["starts-with", "$key", "contract/"], // 只能传到 contract/ 下
["eq", "$success_action_status", "200"]
]
};
前缀约束只能拦住"手滑"和"顺手乱传",拦不住真想搞事的人——对方换个目录名照样传。它的价值在于把损失范围缩小,不在于防御。
三种收敛方案
按成本从低到高排。
第一种,只收紧配置。 密钥限权到最小、CORS 来源写具体域名、policy 加上前缀和大小限制。改动全在控制台,代码一行不动。这能挡掉大部分顺手拿密钥的人,成本最低,建议先做。
第二种,把签名挪回后端。 前端不再持有 Secret,改成上传前向后端要一份 policy 和 signature,拿到之后照样直传。
这是我最推荐的一步。改动不大,但把密钥从浏览器里彻底拿掉了。
后端加一个签发接口:
@RequestMapping(value = "sign", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> sign(@RequestBody SignRequest req) throws Exception {
long expireEndTime = System.currentTimeMillis() + 60 * 1000; // 60 秒内有效
Date expiration = new Date(expireEndTime);
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 104857600);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, req.getDir());
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
Map<String, String> resp = new HashMap<>();
resp.put("policy", encodedPolicy);
resp.put("signature", postSignature);
resp.put("accessKeyId", OssConfigUtils.accessKeyId);
resp.put("dir", req.getDir());
resp.put("host", OssConfigUtils.host);
return getSuccessMessage(resp);
}
前端相应地把本地算签名的代码删掉,改成先请求这个接口,把返回的 policy、signature 塞进 multipart_params。注意后端返回的 policy 是 Base64 之后的字符串,不是原始 JSON。
这样改完,policy 的失效时间可以压到 60 秒——它只在"拿到签名到发出上传请求"这段时间里有效。就算有人截获了这份签名,窗口也只有一分钟。而且签名是后端按目录生成的,对方没法把文件传到别的目录去。
代价是上传前多一次请求。相比把密钥暴露在浏览器里,这个代价可以忽略。
第三种,用 STS 临时凭证。 后端申请一个有效期很短的临时凭证(比如 15 分钟),带上权限策略下发到前端,前端用它去上传。凭证过期自动失效,权限范围也在策略里写死。
这是最彻底的做法,适合安全要求高的场景。复杂度也最高,前端要改用 STS 凭证算签名,后端要处理凭证申请和缓存,SDK 版本也得跟上。附件模块一般用不上,但值得知道有这么一条路。
下载这边也有口子
预签名链接有个特点:有效期内对任何人都有效,不需要登录。
意味着一条链接被转发到群里,这个文件在剩下的有效期内就是公开的。链接越长,窗口越大。
我们一开始把有效期设成十小时(就是前面那个 3600 * 10000 的单位问题),后来发现这个窗口实在太宽了,改成了……其实现在还是一小时,因为改短了用户投诉"点慢了就下不了"。这是个真实的取舍,不是纯技术问题。
几个能做的:
- 有效期尽量短。5 到 15 分钟对绝大多数场景够用,用户体验和安全性都能接受。
- 桶保持私有,绝不开放
ListObjects。链接泄露只能泄露单个文件,泄露不了整个桶。 - 敏感附件单独走一套逻辑,有效期压到几分钟,前端拿到链接立刻触发下载。
- 真要防转发,可以在下载接口里记一条日志:谁在什么时候取了哪个文件。事后追查有依据。
Referer 防盗链对这套方案帮助有限,因为预签名链接是直接给浏览器的,来源信息在跨域场景下并不可靠。别把防盗链当成主要防线。
兜底的两件事
开访问日志。 桶的访问日志打开,定期看两样东西:请求来源 IP 有没有陌生的,请求量有没有突然抬头。前面说的"被人当网盘用",在日志里看得很清楚——一个 IP 一天传了几万个对象,那不可能是正常业务。
开版本控制。 成本不高,但能救命。对象被覆盖或者误删之后还能找回上一个版本。我们有过一次运维误操作删了一批附件,靠版本控制捞回来的,那次之后这个开关就没关过。
费用告警也建议配上。存储量和流量设个阈值,超了就发通知。真被人拿去当网盘,早一天知道,少付一天的钱。
这一节的结论
浏览器直传省下来的服务器资源是真的,密钥暴露在前端也是真的,这两件事绑在一起,拆不开。
能做的是三件事:把密钥的权限收到最小,把签名的位置从浏览器挪回服务器,把下载链接的有效期压短。前两件加起来大概半天的改动量,做完之后这套方案的风险就从"任何会用 F12 的人都能往你桶里塞东西",降到"需要拿到一份 60 秒内有效的签名"。
值不值,自己判断。但如果你的桶里放的是客户资料和合同,我建议做。
收尾
回过头看,这套实现真正花心思的地方就三个。
直传决定了文件不经过服务器,把带宽和连接数从服务器上卸了下来。预签名决定了私有文件能安全地临时分享,服务器只签名不转发。数据库只存桶内路径,决定了存储位置和业务数据解耦,以后换桶改的是配置不是数据。
三件事配合起来,前端一份上传脚本、后端两个接口、库里一张四字段的表,就撑起了一个能挂多业务对象、能横向扩容、能平滑迁移的附件模块。
至于密钥这回事,写这篇文章的时候我又去看了一遍现在的实现,还是老样子。前端持有密钥这件事,属于"知道有问题,但一直排在优先级列表后面"。如果你正在做类似的模块,建议一开始就把签名放在后端——事后补比一开始就做,麻烦得多。
评论 (0)
还没有评论,来抢沙发。