前端 <img> 标签加载对象存储里的私有图片,控制台报了一个看不懂的 ERR_BLOCKED_BY_ORB。你第一反应是 CORS 问题,开始配跨域——方向错了,而且怎么配都不会好。
现象
前端图片标签加载对象存储私有桶里的图片时,控制台报错:
net::ERR_BLOCKED_BY_ORB
头像、动态配图都显示不出来。因为图片是跨源加载的,很容易判断成"跨域问题",于是去配置 CORS 规则——配完还是不生效,越配越迷惑。
值得注意的反直觉之处:如果在浏览器地址栏里直接打开那个图片链接,看到的往往是一段 XML 文本(或者一个"拒绝访问"的页面),而不是一张裂图。地址栏能"打开"、<img> 却加载不出来,这个反差本身就暗示问题不在跨域,而在"返回的根本不是图片"。
根因
这里有两个机制,都不是 CORS。
第一,跨源图片本来就不需要 CORS。 <img> 加载图片是浏览器的"无 CORS 请求"(no-cors 模式),跨源加载图片是常规操作,不受同源策略限制。所以问题根本不在跨域。
第二,ERR_BLOCKED_BY_ORB 的真实含义是 ORB(Opaque Response Blocking)拦截。 浏览器检查到"这个响应声明的内容类型是 application/xml,但你是从 <img> 发起的"——响应不是图片,浏览器判定这是不安全的内容,就直接拦掉了。
那为什么会返回 XML? 因为私有桶的裸链接没有签名,返回的是 403 错误,而对象存储的 403 返回XML 格式的错误响应体:
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>Access Denied.</Message>
</Error>
响应头里是 Content-Type: application/xml。浏览器看到 <img> 请求拿到 XML,直接 ORB 拦截。
这里可以顺便理解一下 ORB 的设计意图:它是一种安全机制,用来阻止攻击者把非资源类型的内容(比如 HTML、JSON、XML)通过 <img>、<script> 之类的标签"嗅探"成可执行内容。浏览器此时的行为是"宁可拦掉,也不冒险解析"。
还有第三层隐患:数据库里存的是带签名的临时链接。 预签名 URL 有有效期,几天后就失效,失效后同样返回 XML——于是图片"用着用着就裂了",而不是一开始就裂。这个"延迟失败"的现象会让人误以为是偶发问题。
解决
第一,图片一律走"私有桶 + 动态换取预签名"的方式,不要直链裸 URL。流程是:前端请求后端 → 后端用 SDK 生成一个短有效期的预签名 URL → 前端拿去加载。
第二,不要把预签名 URL 落库长期使用。 数据库里存的是对象的 key,加载时临时换签名:
// 后端:每次请求动态生成
const url = await s3.getSignedUrl('getObject', {
Bucket: 'bucket',
Key: key,
Expires: 300, // 5 分钟
});
有效期设短一点(几分钟),因为它是"访问时生成、用完即弃"的,不需要留很久。签发时间也不宜过长——URL 一旦泄漏,有效期就是泄漏窗口。
第三,注意版本兼容坑。 旧版对象存储的"设置公开读"接口与 S3 标准不兼容,别指望用那个接口把桶设成公开读来解决问题——那不是标准 S3 API 的行为。要么用支持标准 API 的版本,要么老老实实走预签名。
延伸与预防
遇到"图裂",先看响应体而不是先看 CORS 配置。
打开开发者工具的网络面板,点开那个失败的请求,看 Response 里到底是什么。如果是 XML / JSON 错误体,那就不是图片,问题在鉴权或权限;如果是空白(0 字节),那是服务端没返回内容;如果确实是图片二进制但被拦,那才轮到看 CORS 和响应头。
这个排查动作能省掉大量在错误方向上的时间。同一个原则也适用于其他"资源加载失败"的场景:先确认拿到的东西到底是不是你以为的东西,再去研究为什么它没被接受。
工程上还可以把"签名"这件事收敛到一个出口:所有图片 URL 统一由后端的签发接口生成,前端不做任何拼接。这样有效期策略、权限校验都只有一个地方要维护,也不会再出现"某处硬编码了一个过期直链"的情况。