返回博客

OSS 预签名 URL 存库 = 定时炸弹,批量 403 的根因与根治

某天中午,运营突然反馈某后台管理系统里的图片全部挂了。打开列表页,所有封面图清一色 403。更诡异的是:我在本机 Chrome 里看完全正常,同事的电脑一打开就红叉。排查一圈以为是 CDN 缓存问题,折腾了三轮之后才找到真相——问题根本不在 CDN,而在两个月前的一行上传代码。

现象

  • 图片 URL 直接 curlAccessDenied: Request has expired
  • 同一条 URL 在自己浏览器里正常显示(同事电脑不行)
  • 所有失效图片大致在同一时刻(中午 12:46)开始出错,不是逐渐劣化
  • 数据库里捞出来的字段值长这样:https://xxx.oss-cn-shanghai.aliyuncs.com/img/abc.jpg?Expires=1745812000&OSSAccessKeyId=LTAI...&Signature=xxx

根因

OSS / S3 的预签名 URL(presigned URL)本质是「带过期时间的一次性授权」,URL 里的 Expires 是一个 Unix 时间戳,服务端到期即拒。

问题出在上传接口直接把预签名 URL 返回给前端,前端表单回显时又把这个 URL 原样提交回写 API,写 API 不做任何处理就入库了。于是数据库里所有新增的记录,保存的都是带签名 query 的 URL。它们的签名 TTL 统一是 7200 秒——两个小时后同一批图片一起 403,规模越大,炸得越整齐。

坑一:public-read 桶不等于「签名 query 被忽略」

很多人误以为桶设成公共读就没事了:「反正匿名访问也能看,签名过期了不影响」。

实测并非如此。 只要 URL 里带了签名 query(?Expires=...&OSSAccessKeyId=...&Signature=...),OSS 就会校验这个签名。校验失败即 403,不管桶 ACL 是什么。「公共读」只意味着「没有签名 query 的匿名请求可以通过」,不意味着「失效的签名被忽略」。

坑二:浏览器磁盘缓存让 bug 变成薛定谔

图片一旦被浏览器缓存(ETag/Last-Modified),后续请求直接走 (disk cache),根本不碰 OSS。所以「我这边看着没问题」完全不能作为有效信号。

诊断方法:Chrome DevTools → Network → 找图片请求 → 看 Size 列:

  • 显示具体字节数(如 64.1 kB)→ 真实请求,Status 可信
  • 显示 (disk cache) / (memory cache) → 浏览器没发出请求,不算数

最快验证:把 URL 粘进无痕窗口地址栏,或勾选 DevTools 里的 Disable cache 再刷新。

正解

需要四层同时上:

Layer 1:写路径拦截(堵源头)

所有接收前端 URL 字段的写接口,入库前先 strip 掉签名 query:

function alioss_strip_sign_query(string $url): string {
    if (empty($url) || stripos($url, '.oss-cn-') === false) return $url;
    $pattern = '/[?&]Expires=\d+&OSSAccessKeyId=[^&]+&Signature=[^&\s",]+/i';
    return preg_replace($pattern, '', $url);
}
// 写接口调用处
$coverImage = alioss_strip_sign_query($request->post('cover_image', ''));

Java 版:

public static String stripSignQuery(String url) {
    if (url == null || !url.contains(".oss-cn-")) return url;
    return SIGN_PATTERN.matcher(url).replaceAll("");  // regex同上
}

Layer 2:读路径兜底(处理历史脏数据流出)

写路径修好之前,DB 里已有的脏数据仍会通过 GET 接口流出。用 ResponseBodyAdvice 全局拦截,strip 后对私有桶重新签名,公共读桶直接返回裸 URL。

Layer 3:数据库清库

不要手工枚举表——用 information_schema 扫出所有可能存图片 URL 的字段,再用 WHERE col LIKE '%OSSAccessKeyId=%' 二次过滤确认脏行:

SELECT TABLE_NAME, COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
  AND DATA_TYPE IN ('varchar','text','mediumtext')
  AND (COLUMN_NAME LIKE '%image%' OR COLUMN_NAME LIKE '%picture%'
    OR COLUMN_NAME LIKE '%avatar%' OR COLUMN_NAME LIKE '%cover%'
    OR COLUMN_NAME LIKE '%poster%' OR COLUMN_NAME LIKE '%banner%');

实测:手工列出的 19 张表只覆盖了约 50% 的脏数据,information_schema 多挖出 4 张表、6 个字段。

正则注意:如果字段存的是逗号分隔的多 URL,字符类必须排除 ,",否则会跨边界匹配破坏整行:

[?&]Expires=\d+&OSSAccessKeyId=[^&]+&Signature=[^&\s",]+
                                                    ^^^

Layer 4:上传接口契约(治本)

上传接口只返回裸 object URL,不返回预签名 URL:

// ✅ 正确:只返回裸 URL
String url = "https://" + bucket + "." + endpoint + "/" + objectKey;
return ResponseUtil.ok(url);
 
// ❌ 错误:调用方会存进 DB
return ResponseUtil.ok(ossClient.generatePresignedUrl(bucket, key, expiry).toString());

如果前端预览需要签名,单独提供一个「仅用于预览」的接口,并在接口名 / 注释里明确标注「不可持久化」。

正解:裸 URL 入库,签名只在读路径按需生成

数据库字段永远存裸 object URL(不含签名 query)。签名逻辑集中在读路径的 service 或 ResponseBodyAdvice 里,每次读取时按需生成短 TTL(1-2h)的签名。这样 DB 里的数据永不过期,签名 TTL 设多短都无所谓。

一句话外卖

预签名 URL 是「临时通行证」,通行证不能存档案;OSS object key(裸 URL)才是档案,签名只在取用时现签。