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 全绿
103 lines
2.9 KiB
Go
103 lines
2.9 KiB
Go
package middleware
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func newHeadersTestRouter() *gin.Engine {
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.Use(SecurityHeaders())
|
|
r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
|
return r
|
|
}
|
|
|
|
func TestSecurityHeadersPresent(t *testing.T) {
|
|
r := newHeadersTestRouter()
|
|
|
|
// Plain HTTP request: hardening headers present, no HSTS.
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
for _, h := range []string{
|
|
"Content-Security-Policy",
|
|
"X-Content-Type-Options",
|
|
"X-Frame-Options",
|
|
"Referrer-Policy",
|
|
"Permissions-Policy",
|
|
} {
|
|
if v := w.Header().Get(h); v == "" {
|
|
t.Errorf("missing header %s", h)
|
|
}
|
|
}
|
|
if w.Header().Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Errorf("X-Content-Type-Options = %q, want nosniff", w.Header().Get("X-Content-Type-Options"))
|
|
}
|
|
if w.Header().Get("X-Frame-Options") != "DENY" {
|
|
t.Errorf("X-Frame-Options = %q, want DENY", w.Header().Get("X-Frame-Options"))
|
|
}
|
|
if w.Header().Get("Content-Security-Policy") == "" {
|
|
t.Error("CSP header missing")
|
|
}
|
|
if w.Header().Get("Strict-Transport-Security") != "" {
|
|
t.Errorf("HSTS must be absent over plain HTTP, got %q", w.Header().Get("Strict-Transport-Security"))
|
|
}
|
|
}
|
|
|
|
func TestSecurityHeadersHSTSOverHTTPS(t *testing.T) {
|
|
r := newHeadersTestRouter()
|
|
|
|
// TLS request: HSTS present.
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.TLS = &tls.ConnectionState{}
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if v := w.Header().Get("Strict-Transport-Security"); v != "max-age=31536000" {
|
|
t.Errorf("HSTS over TLS = %q, want max-age=31536000", v)
|
|
}
|
|
|
|
// Behind a trusted proxy (X-Forwarded-Proto: https): HSTS present.
|
|
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if v := w.Header().Get("Strict-Transport-Security"); v != "max-age=31536000" {
|
|
t.Errorf("HSTS behind proxy = %q, want max-age=31536000", v)
|
|
}
|
|
}
|
|
|
|
func TestIsHTTPSRequest(t *testing.T) {
|
|
// TLS request.
|
|
if !IsHTTPSRequest(&gin.Context{Request: mustTLSRequest()}) {
|
|
t.Error("TLS request must be HTTPS")
|
|
}
|
|
// Plain request.
|
|
c := &gin.Context{}
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
if IsHTTPSRequest(c) {
|
|
t.Error("plain request must not be HTTPS")
|
|
}
|
|
// X-Forwarded-Proto: https.
|
|
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
|
if !IsHTTPSRequest(c) {
|
|
t.Error("X-Forwarded-Proto https must be treated as HTTPS")
|
|
}
|
|
// X-Forwarded-Proto: http must not trigger HTTPS behavior.
|
|
c.Request.Header.Set("X-Forwarded-Proto", "http")
|
|
if IsHTTPSRequest(c) {
|
|
t.Error("X-Forwarded-Proto http must not be treated as HTTPS")
|
|
}
|
|
}
|
|
|
|
func mustTLSRequest() *http.Request {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.TLS = &tls.ConnectionState{}
|
|
return r
|
|
}
|