security: 补上认证面与输入边界的四处防护

链路功能已经跑通,缺的是边界。这几处默认值都是仓库里公开已知的,忘配就等于没有认证。

- 认证端点限速:/auth/login、/auth/register 此前完全没接限速器(Limiter 只挂在代理
  Auth 上),爆破与批量建号零成本。新增 AllowWindow 滑动窗口,登录按「单 IP」和
  「单 IP×账号」两个桶(默认 30/min、10/min),注册按 IP(5/min)且在 argon2 之前拦,
  避免光靠建号请求打满 CPU;超限返回 429 + Retry-After。
- 生产启动断言:env=production 时拒绝默认 OT_JWT_SECRET、缺失或短于 16 字节的
  OT_MASTER_KEY(缺失时 NewEncryptor 会用 sha256("") 派生,渠道密文可被公开还原)、
  默认管理员密码;一次列全所有问题,不再改一个试一个。development 不拦。
- 请求体上限:parseBody 原来全量 io.ReadAll 且全项目没有一处 MaxBytesReader,
  合法密钥可把任意大 body 整份压进内存再转发。/v1 组挂 BodyLimit(默认 32MB,
  OT_PROXY_MAX_BODY_MB),超限按客户端协议返回 413 request_too_large 并点名上限。
- CORS:不再无条件反射 Origin + Allow-Credentials: true——那唯一挡住跨站凭据读取的
  是 refresh cookie 的 SameSite=Lax,改成 None 就立刻变成窃取面。改为显式白名单,
  空白名单完全不发跨域头(同源部署与 vite proxy 本来就不需要),通配 * 不配凭据;
  允许头补上 X-Api-Key / Anthropic-Version / OpenAI-*,否则浏览器里跑官方 SDK 预检会挂。
- 配套 OT_SERVER_TRUSTED_PROXIES:不配置就不信 X-Forwarded-For(防伪造头绕过 IP 限速)。

顺手删掉 .env.example 末尾重复的 OT_PROXY_LOG_RAW 段。
This commit is contained in:
Sakurasan committed 2026-10-17 03:25:30 +08:00
1 parent d4c5263f1e
commit ad10bdc400
14 files changed
+521 -28

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
package middleware
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func newServer(allow []string) *httptest.Server {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(CORS(allow))
r.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") })
r.POST("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") })
return httptest.NewServer(r)
}
func TestCORSWhitelist(t *testing.T) {
srv := newServer([]string{"http://ok.com"})
defer srv.Close()
get := func(origin string) http.Header {
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ping", nil)
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
return resp.Header
}
h := get("http://ok.com")
if h.Get("Access-Control-Allow-Origin") != "http://ok.com" {
t.Fatalf("白名单内应回显 origin,实际 %q", h.Get("Access-Control-Allow-Origin"))
}
if h.Get("Access-Control-Allow-Credentials") != "true" {
t.Fatal("白名单内应允许凭据")
}
// Anthropic / OpenAI 客户端要带的头必须在允许列表里,否则浏览器预检直接失败
// (头名大小写不敏感,浏览器实际发的是小写 x-api-key)
ah := strings.ToLower(h.Get("Access-Control-Allow-Headers"))
if !strings.Contains(ah, "x-api-key") || !strings.Contains(ah, "anthropic-version") {
t.Fatalf("允许的请求头缺失:%s", ah)
}
if h := get("http://evil.com"); h.Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("白名单外不应发任何 CORS 头,实际 %q", h.Get("Access-Control-Allow-Origin"))
}
if h := get(""); h.Get("Access-Control-Allow-Origin") != "" {
t.Fatal("同源请求(无 Origin 头)不该带 CORS 头")
}
}
func TestWildcardNeverCarriesCredentials(t *testing.T) {
srv := newServer([]string{"*"})
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ping", nil)
req.Header.Set("Origin", "http://anywhere.test")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("通配应回 *,实际 %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("通配模式下不能允许凭据,实际 %q", got)
}
}
func TestEmptyWhitelistDisablesCORS(t *testing.T) {
srv := newServer(nil)
defer srv.Close()
req, _ := http.NewRequest(http.MethodOptions, srv.URL+"/ping", nil)
req.Header.Set("Origin", "http://evil.com")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("预检应短路为 204,实际 %d", resp.StatusCode)
}
if resp.Header.Get("Access-Control-Allow-Origin") != "" {
t.Fatal("空白名单不应发 CORS 头")
}
}
func TestBodyLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(BodyLimit(16))
var readErr error
r.POST("/echo", func(c *gin.Context) {
_, readErr = io.ReadAll(c.Request.Body)
c.Status(http.StatusOK)
})
srv := httptest.NewServer(r)
defer srv.Close()
// 未超限:正常读到
if _, err := http.Post(srv.URL+"/echo", "text/plain", strings.NewReader("tiny")); err != nil {
t.Fatal(err)
}
if readErr != nil {
t.Fatalf("小 body 读取失败:%v", readErr)
}
// 超限:读的时候拿到 MaxBytesError(代理层据此回 413)
if _, err := http.Post(srv.URL+"/echo", "text/plain", strings.NewReader(strings.Repeat("x", 64))); err != nil {
t.Fatal(err)
}
var mb *http.MaxBytesError
if !errors.As(readErr, &mb) {
t.Fatalf("应报 MaxBytesError,实际 %T: %v", readErr, readErr)
}
}
+39 -6
View File
@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/app"
@@ -59,16 +60,37 @@ func AdminOnly(c *gin.Context) {
c.Next()
}
// CORS 开发期放开;生产按配置白名单(PLANNING §8)。
func CORS() gin.HandlerFunc {
// CORS 按显式 origin 白名单放行跨域;allow 为空则完全不输出 CORS 头。
//
// 同源部署(网关自己托管 web/dist)和 vite dev 的 proxy 都不需要 CORS,所以默认关是安全的。
// 之前无条件反射 Origin 并带 Allow-Credentials: true,唯一挡住跨站凭据读取的是
// refresh cookie 的 SameSite=Lax——那是巧合而非防线,一旦有人为了嵌 iframe 把它改成
// None,任意站点就能带着 cookie 调 /auth/refresh 读回新 token。
func CORS(allow []string) gin.HandlerFunc {
allowed := make(map[string]bool, len(allow))
wildcard := false
for _, o := range allow {
if o == "*" {
wildcard = true
continue
}
allowed[strings.TrimRight(o, "/")] = true
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
if origin != "" && (wildcard || allowed[strings.TrimRight(origin, "/")]) {
if wildcard {
// 通配不配凭据:/v1 靠 Authorization / x-api-key 头认证,不吃 cookie
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
}
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
c.Header("Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Requested-With, X-Api-Key, Anthropic-Version, OpenAI-Beta, OpenAI-Organization, OpenAI-Project")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Max-Age", "600")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
@@ -77,3 +99,14 @@ func CORS() gin.HandlerFunc {
c.Next()
}
}
// BodyLimit 限制请求体字节数。超限后 ReadAll 会拿到 *http.MaxBytesError,
// 由代理层按客户端协议返回 413;0 表示不限制。
func BodyLimit(maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) {
if maxBytes > 0 {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
}
c.Next()
}
}