P0(高危): - 新增全局 CSRF 中间件(同步器令牌),覆盖全部 30 个表单与 AJAX 请求 - 修复附件上传/列表/删除越权(IDOR),增加 admin/上传者/文章作者所有权校验 - 登录/注册成功后会话轮换,修复会话固定 - 会话密钥改用 crypto/rand 生成,配置缺失 secret 时拒绝启动 P1(中危): - session 与 comment_uid cookie 增加 Secure/SameSite 标志 - 新增安全响应头:CSP、X-Content-Type-Options、X-Frame-Options、HSTS 等 - 新增 web.trusted_proxies 配置,修复 X-Forwarded-For 伪造 - 修复浏览量记录 goroutine 访问已回收 gin.Context 的数据竞争 补充 17 个安全回归测试(middleware/handlers),go test -race 全绿
155 lines
4.4 KiB
Go
155 lines
4.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-contrib/sessions/cookie"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func newCSRFTestRouter() *gin.Engine {
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
store := cookie.NewStore([]byte("test-secret"))
|
|
r.Use(sessions.Sessions("test_session", store))
|
|
r.Use(CSRFProtect())
|
|
|
|
r.GET("/form", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "TOKEN="+c.GetString(CSRFContextKey))
|
|
})
|
|
r.HEAD("/form", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "TOKEN="+c.GetString(CSRFContextKey))
|
|
})
|
|
r.POST("/action", func(c *gin.Context) {
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
return r
|
|
}
|
|
|
|
// tokenFromForm performs GET /form with the given session cookie and returns
|
|
// the issued CSRF token plus the (possibly new) session cookie.
|
|
func tokenFromForm(t *testing.T, r *gin.Engine, sessionCookie string) (token, cookie string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/form", nil)
|
|
if sessionCookie != "" {
|
|
req.Header.Set("Cookie", sessionCookie)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /form: status = %d, want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
const prefix = "TOKEN="
|
|
if !strings.HasPrefix(body, prefix) {
|
|
t.Fatalf("GET /form: unexpected body %q", body)
|
|
}
|
|
token = strings.TrimPrefix(body, prefix)
|
|
cookie = w.Header().Get("Set-Cookie")
|
|
return token, cookie
|
|
}
|
|
|
|
func postAction(r *gin.Engine, sessionCookie, token string, useHeader bool) *httptest.ResponseRecorder {
|
|
form := url.Values{}
|
|
if !useHeader {
|
|
form.Set(CSRFFieldName, token)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if sessionCookie != "" {
|
|
req.Header.Set("Cookie", sessionCookie)
|
|
}
|
|
if useHeader {
|
|
req.Header.Set(CSRFHeaderName, token)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestCSRFTokenIssuedOnGET(t *testing.T) {
|
|
r := newCSRFTestRouter()
|
|
token, cookie := tokenFromForm(t, r, "")
|
|
if token == "" {
|
|
t.Fatal("expected a token to be issued on GET")
|
|
}
|
|
if !strings.Contains(cookie, "test_session=") {
|
|
t.Fatalf("expected session cookie to be set, got %q", cookie)
|
|
}
|
|
|
|
// A second GET with the same session must return the same token.
|
|
token2, _ := tokenFromForm(t, r, cookie)
|
|
if token2 != token {
|
|
t.Fatalf("token changed between requests: %q vs %q", token, token2)
|
|
}
|
|
}
|
|
|
|
func TestCSRFPostRejectedWithoutToken(t *testing.T) {
|
|
r := newCSRFTestRouter()
|
|
_, cookie := tokenFromForm(t, r, "")
|
|
|
|
w := postAction(r, cookie, "", false)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("POST without token: status = %d, want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCSRFPostRejectedWithWrongToken(t *testing.T) {
|
|
r := newCSRFTestRouter()
|
|
_, cookie := tokenFromForm(t, r, "")
|
|
|
|
w := postAction(r, cookie, "bogus-token", false)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("POST with wrong token: status = %d, want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCSRFPostRejectedWithoutSession(t *testing.T) {
|
|
r := newCSRFTestRouter()
|
|
// No prior GET: no session, no token issued.
|
|
w := postAction(r, "", "some-token", false)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("POST without session: status = %d, want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCSRFPostAcceptedWithFormField(t *testing.T) {
|
|
r := newCSRFTestRouter()
|
|
token, cookie := tokenFromForm(t, r, "")
|
|
|
|
w := postAction(r, cookie, token, false)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("POST with valid token: status = %d, want 200 (body: %s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCSRFPostAcceptedWithHeader(t *testing.T) {
|
|
r := newCSRFTestRouter()
|
|
token, cookie := tokenFromForm(t, r, "")
|
|
|
|
w := postAction(r, cookie, token, true)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("POST with token in header: status = %d, want 200 (body: %s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCSRFSafeMethodsPassWithoutToken(t *testing.T) {
|
|
r := newCSRFTestRouter()
|
|
// GET and HEAD are registered routes; OPTIONS is not (gin does not
|
|
// auto-register it), so it falls to noRoute - but in all cases the CSRF
|
|
// middleware itself must not reject with 403.
|
|
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
|
req := httptest.NewRequest(method, "/form", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code == http.StatusForbidden {
|
|
t.Fatalf("%s /form: status = 403, CSRF middleware must not reject safe methods", method)
|
|
}
|
|
}
|
|
}
|