package web import ( "net/http" "net/http/httptest" "testing" ) // sameOriginRequest 是 CSRF 纵深防御的核心判据。会话 cookie 已经是 SameSite=Lax, // 这层是防「将来有人把 SameSite 放宽」以及同站不同源。 func TestSameOriginRequest(t *testing.T) { cases := []struct { name string host string origin string ref string xfh string want bool }{ {name: "无任何来源头(curl/脚本)", host: "site.example", want: true}, {name: "本站 Origin", host: "site.example", origin: "http://site.example", want: true}, {name: "本站 Origin(带端口)", host: "site.example:8080", origin: "http://site.example:8080", want: true}, {name: "本站 Origin(https)", host: "site.example", origin: "https://site.example", want: true}, {name: "跨站 Origin", host: "site.example", origin: "http://evil.com", want: false}, {name: "跨站 Origin 同后缀", host: "site.example", origin: "http://site.example.evil.com", want: false}, {name: "Origin 为 null(沙箱 iframe)", host: "site.example", origin: "null", want: false}, {name: "Origin 不可解析", host: "site.example", origin: "://", want: false}, {name: "跨站 Referer", host: "site.example", ref: "http://evil.com/admin", want: false}, {name: "本站 Referer", host: "site.example", ref: "http://site.example/admin/notes", want: true}, {name: "Origin 优先于 Referer", host: "site.example", origin: "http://evil.com", ref: "http://site.example/x", want: false}, {name: "反代改写 Host,靠 XFH 兜底", host: "10.0.0.5:8080", origin: "https://site.example", xfh: "site.example", want: true}, {name: "反代 XFH 多段取首段", host: "10.0.0.5:8080", origin: "https://site.example", xfh: "site.example, proxy.internal", want: true}, {name: "反代 XFH 也不匹配", host: "10.0.0.5:8080", origin: "https://evil.com", xfh: "site.example", want: false}, } for _, c := range cases { r := httptest.NewRequest("POST", "/admin/logout", nil) r.Host = c.host if c.origin != "" { r.Header.Set("Origin", c.origin) } if c.ref != "" { r.Header.Set("Referer", c.ref) } if c.xfh != "" { r.Header.Set("X-Forwarded-Host", c.xfh) } if got := sameOriginRequest(r); got != c.want { t.Errorf("%s: sameOriginRequest = %v,期望 %v", c.name, got, c.want) } } } // 走完整中间件链验证接线:写请求跨站被 403,读请求不受影响。 func TestHandlerRejectsCrossOriginWrite(t *testing.T) { f := newLoginFixture(t) h := f.srv.Handler() post := func(method, path, origin, ref string) int { t.Helper() r := httptest.NewRequest(method, path, nil) if origin != "" { r.Header.Set("Origin", origin) } if ref != "" { r.Header.Set("Referer", ref) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w.Code } if code := post("POST", "/admin/logout", "http://evil.com", ""); code != http.StatusForbidden { t.Errorf("跨站 POST 应 403,实际 %d", code) } if code := post("POST", "/admin/logout", "null", ""); code != http.StatusForbidden { t.Errorf("Origin: null 的 POST 应 403,实际 %d", code) } if code := post("POST", "/admin/logout", "", "http://evil.com/x"); code != http.StatusForbidden { t.Errorf("跨站 Referer 的 POST 应 403,实际 %d", code) } // 本站来源与「无来源头」都要放行到业务逻辑(不是 403 即可) for _, c := range []struct{ name, origin, ref string }{ {"本站 Origin", "http://example.com", ""}, {"无来源头", "", ""}, } { if code := post("POST", "/admin/logout", c.origin, c.ref); code == http.StatusForbidden { t.Errorf("%s 的 POST 不该被拦,实际 403", c.name) } } // GET 是读,带跨站 Origin 也不能拦(否则外链图片/预取就废了) if code := post("GET", "/healthz", "http://evil.com", ""); code == http.StatusForbidden { t.Errorf("GET 不该被 CSRF 守卫拦,实际 403") } }