一个 Safari 的小 Bug,探索出了 fetch 和 xhr的新玩法

 前言

从文字中能感受到激情澎湃。今日前端早读课文章由 @名字太短容易被记住投稿分享。

@名字太短容易被记住,一个喜欢探索知识盲区,充满好奇心的民间艺术家👨🏻🎨

正文从这开始~~

前段时间,项目在进行 JWT 改造,用户的身份认证从 cookie 改成了 token。

上线之后,用户反馈了一个 Bug:

我打开 A 页面之后,就自动跳转到登录页面了,但是打开其他页面是正常的。

询问了一些基本信息之后,发现他用的浏览器是 Safari,然后我按照他的操作路径模拟了一遍,果然复现了。

当时心想:既然能稳定复现,那就问题不大,应该很好解决 🤔 ~

你可以打开这个在线地址,点击按钮,在 Chrome 中会正常展示数据,在 Safari 中会提示 request error。

image.png

现象描述

P.S.:为避免泄露信息,以下都是虚假内容,但不影响阅读。

经过调试之后发现,是因为有一个接口由于请求地址不对,接口返回了 301,需要重定向到新的接口:

  • 前端请求的地址:/api/user/list

  • 后端需要的地址:/api/user/list-new

在 Safari 中具体请求如下(Safari 自动将原请求和重定向合并为了 1 个请求记录):

image.png

当浏览器收到 3XX 的重定向状态码后,会自动对新的地址发起请求(也就是响应体中 Location 的地址)。

然而 Safari 浏览器在自动发起新的请求时,没有携带自定义的 Authorization 请求头,所以导致接口鉴权失败,返回了 401(Unauthorized)。

前端在收到接口响应后,由于响应体里面也返回了未登录的业务 code,就自动跳转到了登录页面。

这里还发现了一个有意思的细节:Safari 在发起重定向请求时,虽然没有带上 Authorization 请求头,但是会带上 cookie,这也说明了为什么在改造为 JWT 之前,Safari 能正常使用的原因(图中没有携带 cookie 是因为 Demo 中没有 set-cookie)。

然后我又在 Chrome 中进行了相同的测试,发现 Chrome 在发起重定向请求时,会携带 Authorization 请求头,所以能够正常使用。

在 Chrome 中,具体请求如下(Chrome 中请求和重定向是 2 条独立的记录):
image.png
image.png

猜测可能

我当时的场景,后端返回的状态码是 301,开始以为是各浏览器针对 301 响应码的处理逻辑不一样。

当时脑子里有个印象是:浏览器没有按照规范处理 301 和 302,所以后续规范新增了 307 和 308。

尽管标准要求浏览器在收到该响应并进行重定向时不应该修改 http method 和 body,但是有一些浏览器可能会有问题。所以最好是在应对 GET 或 HEAD 方法时使用 301,其他情况使用 308 来替代 301。
— MDN:301 Moved Permanently

所以我想用 Charles 把请求的响应码改为 308 试试效果,搜了一些关于 Charles 修改 Status Code 的教程,找到了这个方法 is-it-possible-to-rewrite-a-status-code-with-charles-proxy,还有另一篇更为详细的教程 rewrite-modify-the-response,但是设置的流程比较繁琐。😫

不过好在,发现了一个另外的网络调试软件 Proxyman,看到他之后,有一种春天来了的感觉。

使用起来非常方便,甚至可以直接通过 JavaScript 动态修改 Request / Response 的任何内容,这对于前端来说实在是太友好了,而且免费版就足以使用,强烈推荐大家下载体验一下~

可惜的是,通过 Proxyman 将请求的响应码改为 308 后,发现 Safari 依旧不会携带 Authorization 请求头。😭

搜索问题

既然猜想的方向不对,那就只能请教万能的 Google 了。

首先是在 stackoverflow 找到了这个问题 safari-does-not-persist-the-authorization-header-on-redirect,但是并没有解决。(在我写这篇文章的时候,发现 @sideshowbarker 已经给了最新回复:已在 Safari 15.4 修复)。

不过,提问者给出了自己项目的解决方案:最终改为了使用 cookie 来做身份验证。🤣

We have implemented other workaround for this. We have implemented cookie based authentication later on.

后面又找到了另外一个相关问题 How to prevent Safari from dropping the Authorization header when following a same-origin redirect?,当时也是没有解决。

不过,我看到里面有一个评论说,他准备去 https://bugs.webkit.org/ 提 Bug,但是我在这个网站里面搜了一圈,没发现相关的问题。

所以,我就去注册了账号,新建了一个 Bug:Safari does not persist the Authorization header on redirect,并且在那个帖子里同步了一下:我已经创建过 Bug 了,后续可以在那个链接里面跟进(主要也是方便后续有其他小伙伴遇到这个问题,可以追踪后续进展)。

由于我的账号声望不足,没办法直接在问题中追加评论,所以只能新建一个回答。

楼主收到回答之后,说这个不是解决问题的方案,所以帮我把链接贴到了评论区,然后把那个回答给删了。

image.png

跟进处理

给 webkit 团队提完 Bug 之后,大概过了 2 周,官方回复说:他在技术预览版的 Safari 中没有复现,并且给了一个他用来测试的 Demo,希望我也能够提供一个我这边复现的 Demo。

收到回复之后没多想,就立马着手准备代码。想着以最简单的方式复现,所以就用 Koa 来处理请求。

里面有一段逻辑是通过 ctx.headers 获取 Authorization 的值,但是 ctx.headers.Authorization 竟然是 undefined,后续将 ctx.headers 对象打印出来才发现,里面所有的 key 都是小写的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// html
// 省略其他代码 ...

fetch("/api/user/list", {
headers: { Authorization: `Bearer xxxxxxxxx` },
})

// 省略其他代码 ...

------

// koa server
app.use((ctx) => {
// 省略其他代码 ...

// koa 会自动将请求头的所有 key 转为小写
if (ctx.headers.authorization !== "Bearer xxxxxxxxx") {
ctx.status = 401;
}

// 省略其他代码 ...
});

开始以为是 Koa 自动把 request.header 中的 key 转为了小写,感到很疑惑。所以在 Koa 的 issues 中搜了一下,找到了这个问题,才知道这是 Node.js 的 http 模块做的处理。

Node.js 将 header 的字段统一转为小写的原因是 rfc2616#section-4.2 中规定 header 字段是大小写不敏感的:

Each header field consists of a name followed by a colon (“:”) and the field value. Field names are case-insensitive.

所以 Node.js 统一转为小写,避免使用方再重复做大小写转换的处理逻辑 [参考]。

the thing is that since you need to operate on them and they are case-insensitive you need to either upper or lower case them. The latter is the case of nodejs. Someone as to do it, in this case nodejs is saving you that step IMO. Also applications shouldn’t rely in the case of those fields since, again, they are case-insensitive.

不过统一处理也存在一些不合理的场景,比如使用 Node.js 做 HTTP 代理服务时,转发后的请求头都自动变为小写了,那么会导致下层服务获取不到原始的请求头字段,这样会在传输的过程中破坏原始数据 [参考]。

One example : Proxy.
If, as I was developing, you are proxying between a client and a server (two couchdb instances replicating in that case) for which case is important (it is probably wrong, but you can’t modify the code) : the client didn’t understand the answer if the headers’ case was not the original one. In that case, you need to forward headers exactly as received.

不过好在,Node.js 后续提供了新的 API,可以通过 req.rawHeaders 获取原始数据,具体可以看这个文档。

关于 Node.js http 模块自动将 header 字段转为小写的详细讨论可以看这个链接。

P.S.:HTTP/2 的 rfc7540#section-8.1.2 规范,已明确规定 header 必须使用小写:

However, header field names MUST be converted to lowercase prior to their encoding in HTTP/2.

好了,弄明白了 header 小写的问题之后,就把准备好的 Demo 代码提交到了 GitHub。

完事之后,想着要不顺便部署一下吧,方便他们测试,我也可以趁这个机会体验一下 vercel。

不过部署之后,访问一直是 404,看了官方文档才发现,处理请求的文件,需要在 /api 目录中才行。

可是我不太想修改文件目录,因为修改之后,访问的页面路径,也需要加上 /api 前缀。

既然这样的话,那顺便试试 Next.js ?

没想到,代码写完之后,部署非常丝滑,一行命令直接搞定,而且给了对应的访问域名,重点是完全免费。

https://safari-redirect-demo.vercel.app/,这个是部署后生成的域名,二级域名是我 GitHub Demo 仓库的名称。

好了,Demo 准备完毕之后,就去回帖了,最终得出的结论是:这个 Bug 已经在 macOS 12.3 中修好了。

image.png

让我没想到的是,之前已经有人提过一个 Authorization header lost on 30x redirects 的类似 bug 了,可是我当初怎么没有搜到这个 😂。

另外,让我震惊的是外国友人也这么卷,快 23:00 了还在工作 🤔。

解决方案

现在来聊聊,在这整个过程中,我整理的 3 种解决方案。

升级版本(不靠谱)

目前 Safari 15.4(iOS 15.4, macOS 12.3) 已经修复了此问题,所以升级版本即可解决。

如果是公司内部系统,则可以根据实际情况来决定是否通过升级版本来解决此问题。

如果是对外项目,那这个方法肯定是没戏了,毕竟我们没办法控制用户升级系统。

存储到 cookie(可行)

在前面搜索的过程中,也有人通过把 token 放到 cookie 中存储来解决这个问题的,因为 Safari 重定向时,虽然不会携带 Authorization,但是会把 cookie 带上。

但是这样需要后端配合,需要把鉴权的整个流程都改为从 cookie 中取值,这就要看你怎么说服后端大哥配合了。

那么话又说回来了,既然要把 token 储存到 cookie,如果没有什么特殊场景的话,那可以直接考虑放弃 JWT 这套方案了 😝。

手动处理(可行,Hack 思路)
Fetch

Fetch 有一个属性是 redirect,它目前支持 3 个属性(摘自 javascript.info:fetch-api#redirect):

  • “follow” —— 默认值,遵循 HTTP 重定向,

  • “error” —— HTTP 重定向时报错,

  • “manual” —— 允许手动处理 HTTP 重定向。在重定向的情况下,我们将获得一个特殊的响应对象,其中包含 response.type=”opaqueredirect” 和归零 / 空状态以及大多数其他属性。

当时看到 manual 属性的时候,虽然描述看起来有点懵,但是想着可以手动处理重定向的请求,那肯定没毛病。

当需要重定向时,我们从 header 中的 location 中获取到新地址,然后手动对新地址发起一个请求,并且把 Authorization 带上,这样总可以了吧~

于是我开心的写了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fetch("/api/user/list", {
headers: { Authorization: `Bearer xxxxxxxxx` },
redirect: "manual",
})
.then((response) => {
console.log("response", response);
if (response.status === 307) {
const newURL = response.headers.get("location");
console.log(newURL);
}
})
.catch((e) => {
console.log(e);
});

可是执行之后,并没有打印 newURL,然后看了一下返回的 response 对象,里面没啥有用的信息。

status 的值是 0,headers 是个空对象

image.png

感觉很奇怪,但是又看了看上面对 manual 的定义,好像明白了 归零 / 空状态 是什么意思 😂。

然后又开始了探索之路。

最终在 Fetch 规范仓库中搜到了一个 issue:Cannot get next URL for redirect=”manual”。

这位同学和我有一样的疑惑(再次验证了那句话,你遇到过的 90% 的问题,其实别人都早已遇到了🤔):

  • 问:设置了 manual 之后,获取不到 redirect_url。[参考]

  • 答:规范设计如此。[参考]

  • 问:是不是应该完善一下文档,说明一下这个情况,或者把 manual 换个名称更好?否则会引起误解。[参考]

  • 答:你说的有道理,但是现在改名称已经为时过晚,因为浏览器都已经实现了这个功能。[参考]

好了,没戏了,万万没想到,manual 的意思不是手动处理,而是让浏览器不做处理 😳。

难道这就是传说中的定义不规范,开发两行泪么 😭(这让我想起了请求头中 referer 字段拼写错误的问题,小声 BB 🤫)。

不过好消息是,社区已经意识到这个问题,并且在讨论解决方案了,不过,这个问题从 2017 年被提出,到现在已经 5 年过去了,还没有标准落地,具体讨论可以查看此链接跟进。

既然 Fetch 无法获取到重定向的 URL,那 XMLHttpRequest 呢?

XMLHttpRequest

用 XMLHttpRequest 写了一个 Demo,发现浏览器也是会自动对重定向做出处理,打印的是重定向后最终的状态码,值为 200,并不会打印 307,并且会获取到重定向后的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const xhr = new XMLHttpRequest();

xhr.open("GET", "/api/user/list");

xhr.setRequestHeader("Authorization", "Bearer xxxxxxxxx");

xhr.send();

xhr.onload = function () {
console.log(`${xhr.status}: ${xhr.statusText}`);
console.log(xhr.response);
};

xhr.onerror = function () {
console.log("Request failed");
};

然后通过搜索之后,找到这 2 个很有价值的问题:

总结来说,按照规定 XMLHttpRequest 在收到重定向请求时,会自动对新 URL 发起请求,并且规范中没有提供阻止重定向的方法。

但是可以通过 responseURL 属性获取到重定向的 URL:

1
2
3
4
5
xhr.onreadystatechange = function () {
if (this.readyState === this.DONE) {
console.log("responseURL", this.responseURL);
}
};

我试了一下,responseURL 这个属性有 2 种取值逻辑,当本次请求:

  • 没有触发重定向时,打印的是本次请求的 URL。

  • 触发重定向时,打印的是重定向的 URL。

但是规范中提到,他有可能是空字符串:

The responseURL getter steps are to return the empty string if this’s response’s URL is null; otherwise its serialization with the exclude fragment flag set.

所以,如果你一定要终止重定向请求,那么可以通过 responseURL 和原始的请求 URL 进行对比,如果不同,则表明存在重定向,但是不推荐使用这种逻辑判断,因为这不是官方标准。另外,这里的 status 取到的是重定向后的值,所以不能用它对比。

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
const xhr = new XMLHttpRequest();

const requestURL = "/api/user/list";

xhr.open("GET", requestURL);

xhr.setRequestHeader("Authorization", "Bearer xxxxxxxxx");

xhr.send();

xhr.onload = function () {
// 如果 status 等于 0,则表明当前请求被终止
if (xhr.status === 0) {
console.log("当前请求被终止");
return;
}
console.log(`${xhr.status}: ${xhr.statusText}`);
console.log(xhr.response);
};

xhr.onreadystatechange = function () {
if (this.readyState === this.DONE) {
// 在这里判断 responseURL 是否 和 原始 URL 一致(this.responseURL 也有可能为空)
if(this.responseURL && this.responseURL !== requestURL){
console.log("重定向地址为:", this.responseURL);
// 如果不一致,则终止请求
this.abort();
}
}
};

xhr.onerror = function () {
console.log("请求失败");
};

另外,通过这种逻辑进行重定向判断的,需要注意以下两点:

  • 通过 abort 终止重定向请求后,需要在 onload 事件中做一层判断,因为 Safari 在请求终止后,还是会进入到 onload 事件中。可通过 status 进行判断,终止之后的请求,status 的值为 0。

  • 通过 abort 终止重定向请求后,浏览器还是会对重定向的新 URL 发起请求,服务器也会正常处理并响应,所以需要注意此请求是否有「副作用」。

到了这个时候就很有意思了,原来 XMLHttpRequest 不仅可以获取重定向的 URL,而且还可以通过 abort 终止重定向(不过并不推荐这种判断逻辑来终止请求)。

好了,现在开始我们的 Hack 思路~

Hack 思路

先来说一下基本逻辑:

  • 通过 Fetch 阻止浏览器自动重定向。

  • 通过 XMLHttpRequest 获取重定向的 URL。

  • 自动对重定向的 URL 发起请求。

相关链接:

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
/**
* 获取重定向的 URL
*/
const getRedirectURL = (url, options) => {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
const { headers = {} } = options;
// 设置请求头
Reflect.ownKeys(headers).forEach((key) => {
xhr.setRequestHeader(key, headers[key]);
});
xhr.send();
xhr.onreadystatechange = function () {
if (this.readyState === this.DONE) {
// 在这里判断 responseURL 是否 和 原始 URL 一致(this.responseURL 也有可能为空)
if (this.responseURL && this.responseURL !== url) {
// 如果不一致,则终止请求
resolve(this.responseURL);
// 终止请求之后,this.responseURL 的值会被置空,所以需要在最后调用。
this.abort();
return;
}
console.log("未发生重定向,responseUR 的值为:", this.responseUR);
resolve();
}
};
xhr.onerror = function (e) {
console.log("请求失败", e);
resolve();
};
});
};

/**
* 封装处理 重定向 的 Fetch
*/
const request = (url, options) => {
return fetch(url, options).then(async (response) => {
// 手动处理 HTTP 重定向时,type 的值为 "opaqueredirect"
if (response.type === "opaqueredirect") {
const redirectURL = await getRedirectURL(url, options);
if (!redirectURL) {
throw new Error("未获取到重定向 URL");
}
// 自动对重定向的 URL 发起请求
return request(redirectURL, options);
}
return response.json();
});
};


(async () => {
// 发请求
const result = await request("/api/user/list", {
headers: { Authorization: `Bearer xxxxxxxxx`, foo: "bar" },
redirect: "manual",
});
console.log(result);
})();

使用上述方法,虽然在 Safari 中可完美运行,但是控制台还是会打印 401 的错误,暂时还没有找到去除这个错误的方法,不过他并不会影响 JS 的运行逻辑,可暂时忽略。

image.png

另外一个需要注意的点是:最好根据浏览器做一层判断,如果是 Safari,则将 redirect 设置为 manual,否则不进行处理。这样可以避免 Chrome 发起过多的无用请求(Chrome 总共会发出 5 个请求)。

image.png

总结

这篇文章,前前后后总共写了 1 个多月,从最开始遇到这个问题,到 Safari 官方回复已在新版本中解决,再到写文章时梳理思路的整个过程,一直在刷新自己已有的认知,也使得这个过程变得非常有意思。

最开始遇到这个问题时,搜索了大量的资料,最终得出的结论是:可能是 Safari 的问题,只能等待官方解决,所以我给官方提了 Bug。

但是在搜索的过程中,我也发现了一些比较有意思的思路,所以就把那些链接记录了下来,准备空闲的时候整理一下。在这个过程中,其实大脑已经有了一个大概的解决方案和思路,就是通过 cookie 或者 Fetch 的 redirect 属性解决。

所以我在写这篇文章的时候,重点内容是 Fetch 解决方案,但是我在动手尝试的时候,发现 redirect 的 manual 属性,不是手动处理的意思。

然后又开始搜索「如何获取重定向的 URL」。最终发现 XMLHttpRequest 可以获取到,所以就有了最后的 Hack 思路,也算是画了一个完满的句号。

那么,我采用的是哪个方案呢?🤔

答案是,我没有选择上述的任何一个方案。因为我的场景只是单纯的把请求地址写错了,导致后端重定向到正确的地址。所以只需要把 URL 改一下即可。😝

收获

虽然整个过程非常的曲折漫长,但是这也让我意外的有了这些收获:

  • 发现了一个非官方的 charles 文档,比较不错。

  • 发现了一个非常好用的网络调试软件:Proxyman。

  • HTTP/1.x 的请求头字段大小不明感,并且 Node.js 自动将他们都转为了小写。

  • HTTP/2.0 的请求头字段明确要求小写,并且还会进行 HPACK 算法压缩。

  • 体验了 vercel 部署流程,部署 Next.js 应用真的很丝滑,而且还免费。

  • Fetch 的 redirect=manual 配置,并不是手动处理重定向的意思,而是让浏览器不处理重定向。

  • XMLHttpRequest 可以通过 responseURL 获取到重定向的 URL。

参考

关于本文
作者:@名字太短容易被记住
原文:https://github.com/mrlmx/blogs/issues/2