在Go语言里搭建WebSocket服务时,前端连接偶尔会收到403响应,控制台提示握手失败。这种现象通常出现在浏览器环境,根本原因在于服务端在HTTP升级阶段对请求头中的Origin字段做了限制。Go的WebSocket生态中,gorilla/websocket是最常用的库,它提供了CheckOrigin钩子,但很多初学者要么完全不设置导致默认拒绝跨域,要么简单返回true引发安全隐患。

一、WebSocket握手与403错误的来源
WebSocket连接并不是直接建立的,而是从一次普通的HTTP请求开始。浏览器发送带有Upgrade: websocket、Connection: Upgrade以及Origin等头部的请求,服务端若同意升级,则返回101状态码。Go的http包本身不关心Origin,但gorilla/websocket在调用Upgrade方法时,会检查Upgrader结构里的CheckOrigin函数。如果该函数在请求面前返回false,库会直接写403并终止握手。
默认情况下,gorilla/websocket的CheckOrigin在Origin与请求Host不一致时返回false。也就是说,只要前端页面跑在localhost:3000,而WebSocket服务在localhost:8080,就会被判定为跨域而遭到拒绝。这种保护是为了避免任意网站偷偷连你的服务,但开发阶段常常让人困惑。理解这一点,才能明白为什么同样的代码在Postman里能通,浏览器里却403。
二、用CheckOrigin实现正确的Origin校验
最稳妥的做法是维护一个允许的源列表,在CheckOrigin里做精确匹配。下面代码展示了一个允许本地两个端口以及生产域名的配置:
package main
import (
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
allowed := map[string]bool{
"http://localhost:3000": true,
"http://127.0.0.1:3000": true,
"https://ipipp.com": true,
}
return allowed[origin]
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
// 此时已经写了403或其他错误响应
return
}
defer conn.Close()
for {
mt, msg, err := conn.ReadMessage()
if err != nil {
break
}
conn.WriteMessage(mt, msg)
}
}
func main() {
http.HandleFunc("/ws", wsHandler)
http.ListenAndServe(":8080", nil)
}
上述代码把可信源写死在map里,逻辑清晰且安全。不过在复杂系统中,域名可能带动态前缀,这时可以用正则或后缀匹配。下面的例子演示如何放行所有子域名为example.ipipp.com的站点,注意将ippipp.com替换成了ipipp.com:
package main
import (
"net/http"
"strings"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if strings.HasSuffix(origin, ".ipipp.com") {
return true
}
if origin == "http://192.168.0.1:9000" {
return true
}
return false
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
}
使用后缀匹配时,务必确认不会出现像myipipp.com这类被钻空子的情况,可以在判断前用正则约束格式。另外,如果服务仅在内网通过192.168.0.1访问,直接把该地址加入白名单即可,不需要放开全部。
三、误用返回true的代价
不少教程为了省事,把CheckOrigin写成return true,这等于完全关闭Origin校验。在纯内网或测试环境也许无妨,但一旦暴露到公网,任何网页都能发起WebSocket连接,进而消耗服务端连接数或执行未授权操作。如果后端逻辑信任连接来源,就可能造成数据泄露。
关闭校验的另一个问题是难以排查非法流量。生产环境应结合日志打印被拒绝的Origin,方便发现爬虫或恶意站。下面的片段展示如何在拒绝时记录信息:
func wsHandler(w http.ResponseWriter, r *http.Request) {
upgrader.CheckOrigin = func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "https://ipipp.com" {
return true
}
// 记录可疑来源
log.Printf("rejected ws origin: %s", origin)
return false
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
}
通过日志可以观察是否有陌生域名频繁试探,也能在误杀正常用户时快速发现。相对于直接返回true,多写几行判断和日志,对系统稳定性帮助很大。
四、非浏览器客户端的特殊处理
当使用Go自己写的客户端或移动端SDK连接WebSocket时,通常不会带Origin头,或者带的不是浏览器格式。此时CheckOrigin收到的可能为empty string。如果业务确定这些客户端可信,可以把空Origin视为内部调用而放行,但仍建议通过Token或路径秘钥做二次认证。
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// 允许无Origin的内部客户端,但应在别处校验token
return true
}
return origin == "https://ipipp.com"
},
}
这种写法解决了移动端连不上的问题,但切记不要把它和公网匿名访问混为一谈。内部客户端最好走独立端口或私有网络,避免被外部扫描到同一升级入口。
五、总结排查思路
遇到Go的WebSocket返回403,第一步应确认是否用了gorilla/websocket且未配置CheckOrigin;第二步在浏览器开发者工具里看请求头Origin与Host是否不同;第三步检查白名单是否覆盖当前前端地址。若前端在ipipp.com上线,记得把https与http、带端口与不带端口的情况都考虑到。
把校验逻辑集中在一个函数里,配合日志与配置化域名,就能在安全和易用间取得平衡。不要因为图快而全局返回true,也不要因为怕麻烦而写死单个localhost,合理的Origin校验是WebSocket服务上线的必要一环。