使用jub0bs/cors实现CORS配置错误的程序化处理
TL;DR
jub0bs/cors v0.5.0 现在允许您以编程方式处理 CORS 配置错误。如果您是多租户服务提供商,并允许租户为其实例配置 CORS,此功能将对您有用。
jub0bs/cors 对配置验证的承诺
jub0bs/cors 的一个长期且显著的特点是广泛的配置验证,这源于我希望排除功能失调的 CORS 中间件并阻止不安全 CORS 中间件的实例化。当您的 CORS 配置包含错误时,该库会检测到它们并将其报告为“多重错误”。
例如,您可能尝试(错误地)配置一个 CORS 中间件,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
package main
import (
"fmt"
"io"
"net/http"
"os"
"github.com/jub0bs/cors"
)
func main() {
_, err := cors.NewMiddleware(cors.Config{
Origins: []string{"*"},
Credentialed: true,
Methods: []string{"POS T"},
ResponseHeaders: []string{"Set-Cookie"},
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
|
幸运的是,该库拒绝实例化这种功能失调且不安全的 CORS 中间件,并发出以下错误消息:
1
2
3
|
cors: invalid method "POS T"
cors: forbidden response-header name "Set-Cookie"
cors: for security reasons, you cannot both allow all origins and enable credentialed access
|
程序化处理 CORS 配置错误的需求
如上所示的错误消息对于大多数导入者来说足以纠正其 CORS 配置并继续前进。然而,在某些(可能罕见)情况下,程序和 CORS 配置由不同的实体编写,这些情况需要比未区分的字符串错误消息更强大的东西。
例如,想象一个多租户服务提供商,它允许每个租户通过某些 Web 界面和/或命令行界面为其服务实例配置 CORS。当租户错误配置 CORS 时,服务提供商需要以某种方式向租户解释错误配置的原因,以便租户可以相应地采取补救措施。简单地将 jub0bs/cors 产生的错误消息转发给租户是诱人且方便的,但这远非理想:这些消息的详细程度、措辞、格式和/或自然语言(英语)可能确实不适合租户。
服务提供商可能希望产生更易接受的消息,例如:
1
2
3
4
5
|
[
"\"POS T\" is not a valid HTTP method. Did you mean \"POST\"?",
"\"Set-Cookie\" is not a response header that can be exposed.",
"You cannot allow access from all origins with cookies."
]
|
为此,服务提供商需要检查 jub0bs/cors 发出的错误,理解它们,并从中提取上下文信息(如 “POS T” 和 “Set-Cookie”)。不幸的是,直到最近,jub0bs/cors 在这方面一直令人遗憾地有限。我示例中的服务提供商别无选择,只能解析这些错误的消息(即其 Error 方法的输出),这是一种广泛不鼓励的做法,且有充分理由。
Go 团队成员 Jon Amsterdam 有句名言:
错误有两个受众:人和程序。
这句简洁的陈述反映了错误的双重性质。错误消息是供人消费的,而不是程序。很少有程序应该解析错误消息,因为这种解析极其脆弱:对错误消息格式的单一更改可能会破坏解析逻辑。此外,Go 社区的惯例规定错误消息不是包 API 的一部分,包作者可以自由地在版本之间更改它们(无论是主要版本、次要版本还是补丁版本)。因此,希望允许程序从其包的错误值中提取信息的包作者应提供一种程序化的方式来实现这一点。
为了正确支持程序化处理 CORS 配置错误,我意识到我需要引入并导出与各种 CORS 错误配置原因对应的具体错误类型。决心在某个阶段实现此功能,我在 GitHub 上提交了问题 9 并将其添加到我的待办事项中。
“一次做对的机会”
如果您像我一样,扩大包的导出表面区域应该让您充满恐惧。正如 Josh Bloch 在其传奇的 API 设计演讲中所说:
您只有一次做对的机会。
向包添加符号看似容易,但修改符号而不破坏现有客户端可能很困难,完全删除符号是痛苦的,因为它需要主要版本升级。犯一个设计错误,您可能会一直受其困扰,直到库的下一个主要版本发布。因此,导出更多内容的决定应该是深思熟虑和仔细考虑的;努力保持选项开放,避免做出未来可能想要打破的承诺。
由于 jub0bs/cors 尚未发布 v1 版本,任何破坏性更改在技术上都是公平游戏;然而,我像躲避瘟疫一样避免破坏性更改,担心用户流失。这些考虑可能解释了我为什么最终延迟处理问题 9。
通过定义消除某些错误
我很快意识到,在没有对库进行任何其他更改的情况下,所需的具体错误类型集将太大而难以管理。通常,我在 John Ousterhout 的著作中找到了答案:
消除异常处理复杂性的最佳方法是定义您的 API,以便没有(或更少)异常需要处理:通过定义消除错误。
(《软件设计哲学》,John Ousterhout,第二版,第 10.3 节)
本着这一设计原则,我修改了库,以优雅地容忍以前作为错误冒泡给调用者的良性配置不当。这种简化的一个直接副作用是,由于库的文档需要解释更少的失败情况,它现在更短且更易消化。
在新的子包中引入具体错误类型
通过定义消除某些错误使我能够将具体错误类型集减少到不超过八个正交元素:
type IncompatibleOriginPatternError
type IncompatiblePrivateNetworkAccessModesError
type IncompatibleWildcardResponseHeaderNameError
type MaxAgeOutOfBoundsError
type PreflightSuccessStatusOutOfBoundsError
type UnacceptableHeaderNameError
type UnacceptableMethodError
type UnacceptableOriginPatternError
回想一下,大多数 github.com/jub0bs/cors 的导入者不需要程序化处理其 CORS 配置错误。因为我不想让他们不知所措,我决定不从根包导出具体错误类型,而是从一个名为 “cfgerrors” 的新子包导出。
得益于这种方法,github.com/jub0bs/cors 的 API 保持紧凑, casual 导入者不会不必要地承受额外的认知负担。包 github.com/jub0bs/cors/cfgerrors 还提供了一个名为 “All” 的迭代器工厂,允许程序遍历错误值树中包含的 CORS 配置错误:
1
|
func All(err error) iter.Seq[error]
|
要受益于这些功能,请更新到 jub0bs/cors 的 v0.5.0。
一个相对简单的示例
下面的服务器允许租户配置其 CORS 中间件允许哪些 Web 来源以及是否允许凭据访问(例如,使用 cookie)。它以编程方式处理任何产生的错误,以便以人性化的方式通知租户其 CORS 配置错误。
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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"mime"
"net/http"
"github.com/jub0bs/cors"
"github.com/jub0bs/cors/cfgerrors"
)
func main() {
app := TenantApp{id: "jub0bs"}
mux := http.NewServeMux()
mux.HandleFunc("POST /configure-cors", app.handleReconfigureCORS)
api := http.NewServeMux()
api.HandleFunc("GET /hello", handleHello)
mux.Handle("/", app.corsMiddleware.Wrap(api))
if err := http.ListenAndServe(":8080", mux); err != http.ErrServerClosed {
log.Fatal(err)
}
}
type TenantApp struct {
id string
corsMiddleware cors.Middleware
}
func (app *TenantApp) handleReconfigureCORS(w http.ResponseWriter, r *http.Request) {
mediatype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediatype != "application/json" {
w.WriteHeader(http.StatusBadRequest)
return
}
var reqData struct {
Origins []string `json:"origins"`
Credentials bool `json:"credentials"`
}
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
cfg := cors.Config{
Origins: reqData.Origins,
Credentialed: reqData.Credentials,
}
if err := app.corsMiddleware.Reconfigure(&cfg); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
var resData = struct {
Errors []string `json:"errors"`
}{
Errors: adaptCORSConfigErrorMessagesForClient(err),
}
if err := json.NewEncoder(w).Encode(resData); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
}
func adaptCORSConfigErrorMessagesForClient(err error) []string {
var msgs []string
for err := range cfgerrors.All(err) {
switch err := err.(type) {
case *cfgerrors.UnacceptableOriginPatternError:
var msg string
switch err.Reason {
case "missing":
msg = "You must allow at least one Web origin."
case "invalid":
msg = fmt.Sprintf("%q is not a valid Web origin.", err.Value)
case "prohibited":
msg = fmt.Sprintf("For security reasons, you cannot allow Web origin %q.", err.Value)
default:
panic("unknown reason")
}
msgs = append(msgs, msg)
case *cfgerrors.IncompatibleOriginPatternError:
var msg string
switch err.Reason {
case "credentialed":
if err.Value == "*" {
msg = "For security reasons, you cannot both allow credentialed access and allow all Web origins."
} else {
const tmpl = "For security reasons, you cannot both allow credentialed access allow insecure origins like %q."
msg = fmt.Sprintf(tmpl, err.Value)
}
case "psl":
const tmpl = "For security reasons, you cannot specify %q as an origin pattern, because it covers all subdomains of a registrable domain."
msg = fmt.Sprintf(tmpl, err.Value)
default:
panic("unknown reason")
}
msgs = append(msgs, msg)
default:
}
}
return msgs
}
func handleHello(w http.ResponseWriter, _ *http.Request) {
io.WriteString(w, "Hello, World!")
}
|
诚然,编写这样的粘合代码是繁琐的,但在我看来并非 prohibitively so。
自己试试!启动服务器后,运行以下 shell 命令:
1
2
3
|
curl -v localhost:8080/configure-cors \
-H "Content-Type: application/json" \
--data '{"origins":["*"],"credentials":true}'
|
服务器对产生的 POST 请求响应如下:
1
2
3
4
5
6
|
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Wed, 15 Jan 2025 17:50:02 GMT
Content-Length: 106
{"errors":["For security reasons, you cannot both allow credentialed access and allow all Web origins."]}
|
一个更详细的示例,涉及包 cfgerrors 导出的更多具体错误类型,可在文档中找到。
自食其果
在 v0.5.0 之前,jub0bs/cors 测试套件中的一些断言依赖于库错误消息的精确措辞。这些断言一直困扰着我,原因有二:
- 维护负担:即使对错误消息内容的轻微更改也需要相应更改这些断言。
- 误导性契约:黑盒测试中使用的断言理想情况下应表达导入者可以依赖的保证;不多不少。对错误消息的断言可能给用户错误的印象,即他们可以安全地解析错误消息,而不必担心在更新依赖项时代码会中断。
借助 v0.5.0,我能够重构 jub0bs/cors 的测试套件,仅断言具体错误类型及其以编程方式可访问的数据,而不是其消息的精确措辞。
呼吁赞助
如果此功能对您的公司有用,请告诉我。如果您依赖 jub0bs/cors 并希望支持其开发和维护,请考虑在 GitHub 上赞助我。💸