diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md
new file mode 100644
index 0000000..3b28ca6
--- /dev/null
+++ b/SECURITY_TODO.md
@@ -0,0 +1,154 @@
+# 安全修复 TODO
+
+基于 2026-08-19 的安全审计(源码 + haibara.ai 线上验证)整理。
+按优先级排序,完成后勾选并标注日期。
+
+---
+
+## P0 — 立即修复
+
+### [x] 1. 会话密钥弱回退(可伪造管理员会话)✅ 2026-08-19
+- **位置**: `config/config.go`(`generateSecret` / `applyDefaults` 回退)
+- **问题**: secret 缺失时回退为 SHA-256(主机名+PID),两者均可被外部推测/爆破,攻击者可离线伪造任意用户会话 cookie。
+- **修复**:
+ - [x] `generateSecret()` 改用 `crypto/rand` 生成 32 字节随机数
+ - [x] 已有配置加载路径中 secret 为空时:拒绝启动(`log.Fatalf`),不再静默回退;配置文件读取失败也改为直接退出
+ - [x] 首次生成配置文件时写入强随机 secret(保持 `install_linux.sh` 的 openssl 路径不变)
+- **验证**: ✅ 新密钥为 crypto/rand 输出;缺失 secret 时启动直接报错
+
+### [x] 2. 全站无 CSRF 防护 ✅ 2026-08-19
+- **位置**: 全部 POST 路由(登录/注册/文章/评论/管理后台/设置/附件)
+- **问题**: 仅靠 cookie 认证,无 CSRF token;线上 cookie 无 SameSite 属性,浏览器默认 Lax 保护不完整(Chrome Lax+POST 豁免、Safari 差异)。
+- **修复**:
+ - [x] 新增 `middleware/csrf.go`:同步器令牌模式(session 存储、常量时间比较),表单 `_csrf` 字段或 `X-CSRF-Token` 头二选一,不匹配返回 403
+ - [x] 覆盖全部 30 个 POST 表单(含游客评论表单);AJAX(附件上传/删除、头像上传)经 `` 下发 token 并以请求头携带
+ - [x] `/article/:slug/comments` 游客 POST 一并覆盖(游客同样有 session)
+- **验证**: ✅ `middleware/csrf_test.go` 7 用例 + 端到端 curl 冒烟(无 token/伪造 token 403,有效 token 302)
+
+### [x] 3. 附件接口越权(IDOR)✅ 2026-08-19
+- **位置**: `handlers/attachment.go`(DeleteAttachment / ListAttachments / UploadAttachment 的 article_id)
+- **问题**: `/my/articles/attachments/*` 仅要求登录,无所有权校验;任意登录用户可删除/列出全站任意附件、向他人文章挂附件。
+- **修复**:
+ - [x] `DeleteAttachment`:admin / 上传者 / 所属文章作者三者之一,否则 403
+ - [x] `ListAttachments`:文章作者或 admin,否则 403
+ - [x] `UploadAttachment`:`article_id != 0` 时校验文章归属(admin 除外),否则 403
+ - [x] 单元测试(`handlers/security_test.go`:越权 403 / 本人 200 / admin 覆盖)
+- **验证**: ✅ 普通用户 A 删除用户 B 的附件 -> 403(测试覆盖)
+
+### [x] 4. 会话固定(Session Fixation)✅ 2026-08-19
+- **位置**: `handlers/auth.go`(Login / Register 自动登录)
+- **问题**: 登录成功后未清空旧 session,直接写入 user_id,固定攻击可劫持登录后会话。
+- **修复**:
+ - [x] 认证成功后先 `session.Clear()` 再写入 `user_id`/`username` 并 Save;保留 lang 与 csrf_token(避免多标签页已渲染表单失效)
+- **验证**: ✅ 登录前后 cookie 值不同,旧 cookie 无法访问受保护路由(`TestLoginRotatesSession`)
+
+---
+
+## P1 — 近期修复
+
+### [x] 5. Cookie 缺 Secure / SameSite 标志 ✅ 2026-08-19
+- **位置**: `main.go`(session store)、`handlers/comment.go:82`(comment_uid)
+- **修复**:
+ - [x] store 默认 `SameSite: Lax`;`Secure` 按请求动态设置(`middleware/https.go` 检测 TLS 或 X-Forwarded-Proto),通过中间件在每次请求时应用到 session cookie
+ - [x] `comment_uid` 游客 cookie 同步补齐 `SameSite=Lax` + HTTPS 下 `Secure`
+- **验证**: ✅ 模拟 HTTPS 请求响应头 `Set-Cookie: ... HttpOnly; Secure; SameSite=Lax`;冒烟测试通过
+
+### [x] 6. 缺失安全响应头 ✅ 2026-08-19
+- **位置**: 新增 `middleware/security_headers.go`(全局第一个注册)
+- **修复**:
+ - [x] `Content-Security-Policy`(default-src 'self' + 现有 CDN 白名单 + frame-ancestors 'none' 等)
+ - [x] `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy`、`Permissions-Policy`
+ - [x] `Strict-Transport-Security`(仅 HTTPS 请求下发,未加 includeSubDomains 以免影响 HTTP 子域)
+- **说明**: CSP 含 `'unsafe-inline'`(模板内联 script/style 必需);待 P2-9 CDN 本地化后可进一步收紧
+- **验证**: ✅ `middleware/security_headers_test.go`(headers 存在性、HSTS 条件下发)+ 冒烟 curl 确认
+
+### [x] 7. X-Forwarded-For 伪造(IP 审计/浏览量可刷)✅ 2026-08-19
+- **位置**: `handlers/helpers.go`(GetClientIP)、`config/config.go`(WebConfig.TrustedProxies)、`main.go`
+- **修复**:
+ - [x] 删除手动解析 XFF 首值逻辑,`GetClientIP` 改为 `c.ClientIP()`
+ - [x] `router.SetTrustedProxies(cfg.Web.TrustedProxies)`;新增 `web.trusted_proxies` 配置项(默认 `["127.0.0.1", "::1"]`,unix socket 部署自动信任)
+ - [x] gin 内部 XFF 从右往左取第一个不可信 IP:直接客户端伪造的 XFF 被忽略
+- **验证**: ✅ `middleware/clientip_test.go`(直接连接带假 XFF 取真实 IP / 代理链取最右不可信条目)
+
+### [x] 8. goroutine 数据竞争(use-after-return)✅ 2026-08-19
+- **位置**: `handlers/home.go`(ArticleDetail → recordArticleView)
+- **修复**:
+ - [x] goroutine 启动前同步提取 userID / ip / UA 为局部变量,`recordArticleView` 不再触碰 gin.Context 与 session
+- **验证**: ✅ `go test -race ./...` 全绿
+
+---
+
+## P2 — 计划修复
+
+### [ ] 9. 第三方 CDN 无 SRI / Tailwind dev CDN
+- **位置**: `templates/layouts/base.html:16-20`、`:118-122`
+- **修复**:
+ - [ ] 将 marked / DOMPurify / highlight.js / cropperjs / easymde 下载到 `static/vendor/`,走 go:embed 本地分发(静态管线已具备)
+ - [ ] 替换 `cdn.tailwindcss.com` 为构建期生成的静态 CSS(或至少加 SRI)
+ - [ ] 本地化后配合 #6 收紧 CSP 为 `default-src 'self'`
+- **验证**: 断网第三方域名后页面渲染功能完整;CSP 无违规报告
+
+### [ ] 10. 登录无速率限制
+- **位置**: `handlers/auth.go:33`
+- **修复**:
+ - [ ] 按 IP + 用户名维度做失败计数(内存或 DB),如 5 次失败锁定 15 分钟
+ - [ ] 失败提示保持统一(现有 `?error=1` 已做用户名枚举防护,保持)
+- **验证**: 连续错误登录后返回锁定提示
+
+### [ ] 11. 配置文件权限过宽
+- **位置**: `config/config.go:115`
+- **修复**: `os.WriteFile(configFile, data, 0640)`;secret 写入后可选 `os.Chmod`
+
+### [ ] 12. 首启弱凭据 admin/admin
+- **位置**: `models/db.go:58-81`
+- **修复**:
+ - [ ] 方案 A:首启生成随机密码打印一次性提示
+ - [ ] 方案 B:admin 账户标记"必须改密",登录后强制跳转改密页
+- **说明**: 线上已改密(已验证),此项为防御新部署
+
+### [ ] 13. Unix socket 权限 666
+- **位置**: `install_linux.sh:80`
+- **修复**: `chmod 660` + `chown root:blog_go`(反向代理进程加入同组),避免本机任意用户绕过 Cloudflare 直连
+
+---
+
+## P3 — 低优先级 / 观察项
+
+### [ ] 14. 上传不校验文件真实类型
+- **位置**: `handlers/upload_validator.go:33-58`
+- **修复**: 用 `github.com/gabriel-vasile/mimetype`(已在依赖树中)校验 magic bytes 与扩展名/MIME 一致;不一致则拒绝
+- **说明**: 白名单无 .svg/.html,存储型 XSS 风险低,主要是恶意文件托管风险
+
+### [ ] 15. Gravatar MD5 邮箱哈希可反查
+- **位置**: `handlers/comment.go:88-91`
+- **说明**: Gravatar 协议本身如此;若在意隐私可加后台开关(已有 UseGravatar 开关可关闭)
+
+### [ ] 16. RSS 以 Host 头构造 baseURL
+- **位置**: `handlers/rss.go:60-64`
+- **修复**: 从站点设置中读取固定站点 URL,仅在与请求 Host 不符时告警
+- **说明**: Cloudflare 会校验 Host,实际可利用性低
+
+### [ ] 17. bcrypt cost 偏低
+- **位置**: `models/user.go:41`(DefaultCost=10)
+- **修复**: 提升到 12;已有哈希在用户下次改密时自然升级
+
+---
+
+## 不需要修复(已确认安全)
+
+- SQL 注入:全参数化查询(GORM)
+- XSS:html/template 自动转义 + 评论双防御(服务端 strip + DOMPurify)
+- 密码哈希:bcrypt
+- 附件路径穿越:SHA-256 内容寻址文件名
+- 线上默认凭据:已修改(已验证)
+- 注册接口:已关闭(已验证)
+
+---
+
+## 建议执行顺序
+
+1. **#1 → #4 → #5**(一次提交:会话安全三件套,改动小、风险低)
+2. **#3**(附件越权,纯 handler 层校验)
+3. **#2**(CSRF,涉及全站表单,改动面最大,单独一个 PR 充分回归)
+4. **#7 → #8 → #6**(IP/竞态/响应头)
+5. P2/P3 按迭代排入
diff --git a/config/config.go b/config/config.go
index 064d4b7..bd40507 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,8 +1,8 @@
package config
import (
- "crypto/sha256"
- "fmt"
+ "crypto/rand"
+ "encoding/hex"
"log"
"os"
"path/filepath"
@@ -27,10 +27,19 @@ type DatabaseConfig struct {
// WebConfig holds web-server listening configuration.
type WebConfig struct {
- Port string `yaml:"port"` // TCP port, "" or "0" to disable
- Socket string `yaml:"socket"` // Unix socket path, "" to disable
+ Port string `yaml:"port"` // TCP port, "" or "0" to disable
+ Socket string `yaml:"socket"` // Unix socket path, "" to disable
+ // TrustedProxies lists proxy IPs/CIDRs whose X-Forwarded-For /
+ // X-Forwarded-Proto headers are trusted (e.g. the Caddy/nginx box in
+ // front of the app). Defaults to loopback. If the app is exposed
+ // directly to clients, leave the default so client-supplied
+ // X-Forwarded-For cannot spoof the logged IP.
+ TrustedProxies []string `yaml:"trusted_proxies"`
}
+// defaultTrustedProxies is used when the config omits trusted_proxies.
+var defaultTrustedProxies = []string{"127.0.0.1", "::1"}
+
const defaultPort = "8080"
// mysqlExampleDSN is written into new config files as a reference.
@@ -62,12 +71,15 @@ func getDefaultStoragePath() string {
}
}
-// generateSecret returns a random-ish hex string for the session secret.
+// generateSecret returns a cryptographically random hex string for the
+// session secret. A failure of crypto/rand is unrecoverable, so the program
+// terminates instead of falling back to a predictable value.
func generateSecret() string {
- hostname, _ := os.Hostname()
- input := fmt.Sprintf("%s-%d", hostname, os.Getpid())
- hash := sha256.Sum256([]byte(input))
- return fmt.Sprintf("%x", hash)
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ log.Fatalf("Failed to generate session secret: %v", err)
+ }
+ return hex.EncodeToString(b)
}
// getDefaultSocketPath returns the OS-aware default unix socket path.
@@ -122,9 +134,7 @@ func LoadConfig(customPath string) *Config {
// Read existing config file.
data, err := os.ReadFile(configFile)
if err != nil {
- log.Printf("Warning: could not read config file %s: %v, using defaults", configFile, err)
- cfg := &Config{}
- return applyDefaults(cfg, defaultPath)
+ log.Fatalf("Failed to read config file %s: %v", configFile, err)
}
cfg := &Config{}
@@ -132,16 +142,19 @@ func LoadConfig(customPath string) *Config {
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
}
- return applyDefaults(cfg, defaultPath)
+ return applyDefaults(cfg, defaultPath, configFile)
}
// applyDefaults fills zero-value fields with sensible defaults.
-func applyDefaults(cfg *Config, defaultPath string) *Config {
+func applyDefaults(cfg *Config, defaultPath, configFile string) *Config {
// If the entire web block is empty (old config without "web" key),
// fill default port so the app still starts on 8080.
if cfg.Web.Port == "" && cfg.Web.Socket == "" {
cfg.Web.Port = defaultPort
}
+ if len(cfg.Web.TrustedProxies) == 0 {
+ cfg.Web.TrustedProxies = defaultTrustedProxies
+ }
if cfg.Database.Type == "" {
cfg.Database.Type = "sqlite"
}
@@ -149,7 +162,12 @@ func applyDefaults(cfg *Config, defaultPath string) *Config {
cfg.Path = defaultPath
}
if cfg.Secret == "" {
- cfg.Secret = generateSecret()
+ // The config file exists but has no secret. Refuse to start: a
+ // silently generated fallback would either be predictable (old
+ // hostname+pid scheme) or invalidate all sessions on every restart.
+ log.Fatalf("Config file %s is missing a session secret. "+
+ "Add a random value, e.g. `secret: %s`, and restart.",
+ configFile, generateSecret())
}
return cfg
}
diff --git a/handlers/attachment.go b/handlers/attachment.go
index f97f482..abe778c 100644
--- a/handlers/attachment.go
+++ b/handlers/attachment.go
@@ -49,6 +49,32 @@ func attachmentURL(stored string) string {
// ---------------- Upload ----------------
+// currentUserIsAdmin reports whether the authenticated user has the admin
+// role, based on the context populated by the SetUserContext middleware.
+func currentUserIsAdmin(c *gin.Context) bool {
+ role, _ := c.Get("role")
+ r, _ := role.(string)
+ return r == models.RoleAdmin
+}
+
+// canManageArticle reports whether the current user may attach files to (or
+// manage attachments of) the given article: admins always, the article
+// author otherwise.
+func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool {
+ if currentUserIsAdmin(c) {
+ return true
+ }
+ uid, ok := sessionAuthorID(c)
+ if !ok || articleID == 0 {
+ return false
+ }
+ var article models.Article
+ if err := db.First(&article, "id = ?", articleID).Error; err != nil {
+ return false
+ }
+ return article.AuthorID == uid
+}
+
// UploadAttachment handles AJAX attachment uploads from the article create/edit
// form. The request carries either a real article_id (edit page) or a
// session_token (create page, pending binding). Files are content-addressed by
@@ -69,6 +95,12 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return
}
+ // Ownership check: a non-admin may only attach to their own articles.
+ if articleID != 0 && !canManageArticle(c, db, articleID) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
+ return
+ }
+
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
@@ -146,7 +178,8 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
// DeleteAttachment soft-deletes an attachment record and removes the on-disk
// file only when no remaining records reference it (reference counting, since
-// content-addressed files may be shared).
+// content-addressed files may be shared). Only admins, the uploader, or the
+// author of the article the file is attached to may delete it.
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
id := parseUintParam(c, "id")
@@ -155,6 +188,19 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
+
+ // Ownership check: admin, uploader, or the owning article's author.
+ if !currentUserIsAdmin(c) {
+ uid, ok := sessionAuthorID(c)
+ owned := ok && att.UploaderID == uid
+ if !owned && att.ArticleID != 0 {
+ owned = canManageArticle(c, db, att.ArticleID)
+ }
+ if !owned {
+ c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
+ return
+ }
+ }
stored := att.StoredName
if err := db.Delete(&att).Error; err != nil {
@@ -175,10 +221,19 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
// ---------------- List ----------------
// ListAttachments returns the attachments for an article as JSON (used by the
-// edit page to repopulate the list on load).
+// edit page to repopulate the list on load). Only the article's author (or an
+// admin) may list them.
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
articleID := parseUintParam(c, "id")
+ if articleID == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid article id"})
+ return
+ }
+ if !canManageArticle(c, db, articleID) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
+ return
+ }
var atts []models.Attachment
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
diff --git a/handlers/auth.go b/handlers/auth.go
index da80688..c6c8258 100644
--- a/handlers/auth.go
+++ b/handlers/auth.go
@@ -52,8 +52,20 @@ func Login(db *gorm.DB) gin.HandlerFunc {
return
}
- // Create session.
+ // Rotate the session on privilege change to prevent session
+ // fixation: drop all pre-authentication state, keep only the
+ // harmless UI preferences (language and CSRF token so forms
+ // already rendered in other tabs stay valid).
session := sessions.Default(c)
+ lang, _ := session.Get("lang").(string)
+ csrfTok, _ := session.Get("csrf_token").(string)
+ session.Clear()
+ if lang != "" {
+ session.Set("lang", lang)
+ }
+ if csrfTok != "" {
+ session.Set("csrf_token", csrfTok)
+ }
session.Set("user_id", user.ID)
session.Set("username", user.Username)
if err := session.Save(); err != nil {
@@ -169,8 +181,18 @@ func Register(db *gorm.DB) gin.HandlerFunc {
return
}
- // Auto-login after successful registration
+ // Auto-login after successful registration (with session
+ // rotation, mirroring the login handler).
session := sessions.Default(c)
+ lang, _ := session.Get("lang").(string)
+ csrfTok, _ := session.Get("csrf_token").(string)
+ session.Clear()
+ if lang != "" {
+ session.Set("lang", lang)
+ }
+ if csrfTok != "" {
+ session.Set("csrf_token", csrfTok)
+ }
session.Set("user_id", user.ID)
session.Set("username", user.Username)
if err := session.Save(); err != nil {
diff --git a/handlers/comment.go b/handlers/comment.go
index 3ffdf02..b2322bf 100644
--- a/handlers/comment.go
+++ b/handlers/comment.go
@@ -16,6 +16,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
+ "go_blog/middleware"
"go_blog/models"
)
@@ -78,8 +79,10 @@ func guestTokenFrom(c *gin.Context) string {
token = newGuestToken()
}
// (Re)set the cookie so returning visitors keep their identity. HttpOnly
- // prevents JS access; SameSite=Lax is the gin default and is appropriate.
- c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", false, true)
+ // prevents JS access; SameSite=Lax plus Secure-over-HTTPS mirror the
+ // session cookie hardening.
+ c.SetSameSite(http.SameSiteLaxMode)
+ c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", middleware.IsHTTPSRequest(c), true)
return token
}
diff --git a/handlers/helpers.go b/handlers/helpers.go
index ed2c01d..66d18b3 100644
--- a/handlers/helpers.go
+++ b/handlers/helpers.go
@@ -1,8 +1,6 @@
package handlers
import (
- "strings"
-
"github.com/gin-gonic/gin"
)
@@ -29,6 +27,7 @@ func DefaultData(c *gin.Context) gin.H {
siteHomeSubtitle, _ := c.Get("site_home_subtitle")
siteFooterText, _ := c.Get("site_footer_text")
navLinks, _ := c.Get("nav_links")
+ csrfToken, _ := c.Get("csrf_token")
return gin.H{
"Tr": tr,
@@ -50,6 +49,7 @@ func DefaultData(c *gin.Context) gin.H {
"SiteHomeSubtitle": siteHomeSubtitle,
"SiteFooterText": siteFooterText,
"NavLinks": navLinks,
+ "CSRFToken": csrfToken,
}
}
@@ -67,17 +67,10 @@ func getTr(c *gin.Context) map[string]string {
return m
}
-// GetClientIP extracts the real client IP address, accounting for CDN/reverse proxy setups.
-// It checks X-Forwarded-For and X-Real-IP headers before falling back to c.ClientIP().
+// GetClientIP returns the real client IP. It relies on gin's proxy-aware
+// ClientIP(), which honors the trusted_proxies config: only IPs in that list
+// are allowed to influence X-Forwarded-For, so direct clients cannot spoof
+// the value.
func GetClientIP(c *gin.Context) string {
- if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
- if i := strings.IndexByte(xff, ','); i != -1 {
- return strings.TrimSpace(xff[:i])
- }
- return strings.TrimSpace(xff)
- }
- if xri := c.GetHeader("X-Real-IP"); xri != "" {
- return strings.TrimSpace(xri)
- }
return c.ClientIP()
}
diff --git a/handlers/home.go b/handlers/home.go
index 4253841..e61e5f3 100644
--- a/handlers/home.go
+++ b/handlers/home.go
@@ -198,8 +198,18 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
db.Model(&models.Article{}).Where("id = ?", article.ID).
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
- // Record unique article view asynchronously (doesn't block page response).
- go recordArticleView(db, article.ID, c)
+ // Record unique article view asynchronously (doesn't block page
+ // response). All request-derived values are extracted synchronously:
+ // the gin context is pool-reused and must never be touched from
+ // another goroutine after the handler returns.
+ uid := userIDFromSession(c)
+ var userID *uint
+ if uid != 0 {
+ userID = &uid
+ }
+ ip := GetClientIP(c)
+ ua := c.Request.UserAgent()
+ go recordArticleView(db, article.ID, userID, ip, ua)
// One-time flash notice (set by PostComment on success/pending). Reading
// consumes the flash, so refreshing the page no longer re-shows it.
@@ -281,35 +291,13 @@ func readCommentFlash(c *gin.Context) string {
}
// recordArticleView records a unique article view in the database.
-// This function is designed to be called asynchronously (via goroutine) to avoid
-// blocking the page response. It checks for existing records to ensure each
-// user/IP combination only records one view per article.
-func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
- session := sessions.Default(c)
-
- // Extract user ID from session if logged in
- var userID *uint
- if uid := session.Get("user_id"); uid != nil {
- switch v := uid.(type) {
- case uint:
- userID = &v
- case int:
- u := uint(v)
- userID = &u
- case int64:
- u := uint(v)
- userID = &u
- case float64:
- u := uint(v)
- userID = &u
- }
- }
-
- // Get client IP and User-Agent
- ip := GetClientIP(c)
- userAgent := c.Request.UserAgent()
- isBot := models.IsBot(userAgent)
-
+// This function is designed to be called asynchronously (via goroutine) to
+// avoid blocking the page response. It checks for existing records to ensure
+// each user/IP combination only records one view per article. All request
+// derived values (userID, ip, userAgent) must be extracted by the caller
+// before the goroutine is spawned - this function never touches the gin
+// context.
+func recordArticleView(db *gorm.DB, articleID uint, userID *uint, ip, userAgent string) {
// Check if this view already exists (deduplication)
var count int64
query := db.Model(&models.ArticleView{}).
@@ -337,7 +325,7 @@ func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
UserID: userID,
IP: ip,
UserAgent: userAgent,
- IsBot: isBot,
+ IsBot: models.IsBot(userAgent),
}
// Create the view record (BeforeCreate hook in model handles deduplication)
diff --git a/handlers/security_test.go b/handlers/security_test.go
new file mode 100644
index 0000000..492c5d8
--- /dev/null
+++ b/handlers/security_test.go
@@ -0,0 +1,380 @@
+package handlers
+
+import (
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-contrib/sessions/cookie"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+
+ "go_blog/middleware"
+ "go_blog/models"
+)
+
+// securityTestEnv wires a router that mirrors the production middleware chain
+// (sessions -> CSRF -> user context) plus the routes under test.
+type securityTestEnv struct {
+ router *gin.Engine
+ db *gorm.DB
+ storageDir string
+}
+
+var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
+
+func newSecurityTestEnv(t *testing.T) *securityTestEnv {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.Attachment{}, &models.SiteSetting{},
+ &models.UploadConfig{}, &models.UploadFileType{}, &models.CommentConfig{}, &models.NavLink{},
+ &models.DownloadBaseURL{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ storageDir := t.TempDir()
+
+ // Seed the upload policy so ValidateUpload accepts .txt files.
+ db.Create(&models.SiteSetting{ID: 1})
+ db.Create(&models.CommentConfig{ID: 1, Enabled: true, AllowGuest: true})
+ db.Create(&models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: 1024 * 1024, StorageDir: "attachments"})
+ db.Create(&models.UploadFileType{Extension: ".txt", MimeType: "text/plain", Category: models.CategoryDocument, Enabled: true})
+ models.LoadConfigCache(db)
+
+ // Seed users.
+ mustUser(t, db, "admin", models.RoleAdmin)
+ alice := mustUser(t, db, "alice", models.RoleAuthor)
+ bob := mustUser(t, db, "bob", models.RoleAuthor)
+
+ // Seed one article per author.
+ aliceArt := models.Article{AuthorID: alice.ID, Title: "alice post", Slug: "alice-post", Content: "x", Status: models.ArticlePublished}
+ bobArt := models.Article{AuthorID: bob.ID, Title: "bob post", Slug: "bob-post", Content: "x", Status: models.ArticlePublished}
+ db.Create(&aliceArt)
+ db.Create(&bobArt)
+
+ r := gin.New()
+ if err := r.SetTrustedProxies(nil); err != nil {
+ t.Fatalf("set trusted proxies: %v", err)
+ }
+ r.LoadHTMLGlob("../templates/**/*.html")
+ store := cookie.NewStore([]byte("test-secret"))
+ r.Use(sessions.Sessions("blog_session", store))
+ r.Use(middleware.CSRFProtect())
+ r.Use(middleware.SetUserContext(db))
+
+ r.GET("/login", LoginPage())
+ r.POST("/login", Login(db))
+ r.POST("/logout", Logout())
+
+ protected := r.Group("/my", middleware.AuthRequired())
+ {
+ protected.POST("/articles/attachments", UploadAttachment(db, storageDir))
+ protected.POST("/articles/attachments/:id/delete", DeleteAttachment(db, storageDir))
+ protected.GET("/articles/:id/attachments", ListAttachments(db))
+ protected.GET("/whoami", func(c *gin.Context) {
+ uid, _ := sessionAuthorID(c)
+ c.String(http.StatusOK, "uid=%d", uid)
+ })
+ }
+
+ return &securityTestEnv{router: r, db: db, storageDir: storageDir}
+}
+
+func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User {
+ t.Helper()
+ u := models.User{Username: username, DisplayName: username, Role: role, Status: models.StatusNormal}
+ if err := u.SetPassword("pw-" + username); err != nil {
+ t.Fatalf("set password: %v", err)
+ }
+ if err := db.Create(&u).Error; err != nil {
+ t.Fatalf("create user %s: %v", username, err)
+ }
+ return u
+}
+
+// login performs the full login flow (GET the form for a CSRF token, then POST
+// credentials) and returns the authenticated session cookie.
+func (e *securityTestEnv) login(t *testing.T, username string) string {
+ t.Helper()
+
+ // Anonymous GET to obtain CSRF token + session cookie.
+ req := httptest.NewRequest(http.MethodGet, "/login", nil)
+ w := httptest.NewRecorder()
+ e.router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /login: status = %d", w.Code)
+ }
+ m := csrfTokenRe.FindStringSubmatch(w.Body.String())
+ if m == nil {
+ t.Fatal("login page did not render a CSRF token")
+ }
+ cookie := e.sessionCookie(w)
+
+ // POST credentials with the token.
+ form := url.Values{}
+ form.Set("username", username)
+ form.Set("password", "pw-"+username)
+ form.Set("_csrf", m[1])
+ req = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if cookie != "" {
+ req.Header.Set("Cookie", cookie)
+ }
+ w = httptest.NewRecorder()
+ e.router.ServeHTTP(w, req)
+ if w.Code != http.StatusFound {
+ t.Fatalf("POST /login (%s): status = %d, body %s", username, w.Code, w.Body.String())
+ }
+ authCookie := e.sessionCookie(w)
+ if authCookie == "" {
+ t.Fatal("login did not set a session cookie")
+ }
+ return authCookie
+}
+
+// sessionCookie extracts the blog_session cookie from a recorder. When
+// several Set-Cookie headers are present (e.g. middleware and handler both
+// save the session), the LAST one is the effective value - browsers apply
+// them in order.
+func (e *securityTestEnv) sessionCookie(w *httptest.ResponseRecorder) string {
+ cookie := ""
+ for _, c := range w.Result().Cookies() {
+ if c.Name == "blog_session" {
+ cookie = c.Name + "=" + c.Value
+ }
+ }
+ return cookie
+}
+
+func (e *securityTestEnv) do(method, path, cookie string, body io.Reader, contentType string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, body)
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ if cookie != "" {
+ req.Header.Set("Cookie", cookie)
+ }
+ w := httptest.NewRecorder()
+ e.router.ServeHTTP(w, req)
+ return w
+}
+
+func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID string) *httptest.ResponseRecorder {
+ t.Helper()
+ var buf strings.Builder
+ mw := multipart.NewWriter(&buf)
+ if articleID != "" {
+ mw.WriteField("article_id", articleID)
+ } else {
+ mw.WriteField("session_token", "test-pending-token")
+ }
+ mw.WriteField("_csrf", csrfToken)
+ fw, _ := mw.CreateFormFile("file", "hello.txt")
+ fw.Write([]byte("hello world"))
+ mw.Close()
+ return e.do(http.MethodPost, "/my/articles/attachments", cookie, strings.NewReader(buf.String()), mw.FormDataContentType())
+}
+
+// csrfTokenFor fetches a fresh CSRF token for an authenticated session.
+func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string {
+ t.Helper()
+ w := e.do(http.MethodGet, "/login", cookie, nil, "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /login: status = %d", w.Code)
+ }
+ m := csrfTokenRe.FindStringSubmatch(w.Body.String())
+ if m == nil {
+ t.Fatal("login page did not render a CSRF token")
+ }
+ return m[1]
+}
+
+func TestLoginRotatesSession(t *testing.T) {
+ e := newSecurityTestEnv(t)
+
+ // Obtain an anonymous session (pre-login cookie).
+ req := httptest.NewRequest(http.MethodGet, "/login", nil)
+ w := httptest.NewRecorder()
+ e.router.ServeHTTP(w, req)
+ preLoginCookie := e.sessionCookie(w)
+ if preLoginCookie == "" {
+ t.Fatal("expected anonymous session cookie")
+ }
+
+ authCookie := e.login(t, "alice")
+ if authCookie == preLoginCookie {
+ t.Fatal("session cookie was not rotated on login (fixation risk)")
+ }
+
+ // The authenticated session works.
+ w = e.do(http.MethodGet, "/my/whoami", authCookie, nil, "")
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "uid=") {
+ t.Fatalf("authenticated request failed: status=%d body=%s", w.Code, w.Body.String())
+ }
+
+ // The old (fixated) session must NOT carry the login.
+ w = e.do(http.MethodGet, "/my/whoami", preLoginCookie, nil, "")
+ if w.Code != http.StatusFound {
+ t.Fatalf("pre-login session still authenticated after login: status=%d", w.Code)
+ }
+}
+
+func TestAttachmentListRequiresOwnership(t *testing.T) {
+ e := newSecurityTestEnv(t)
+ var aliceArt, bobArt models.Article
+ e.db.Where("slug = ?", "alice-post").First(&aliceArt)
+ e.db.Where("slug = ?", "bob-post").First(&bobArt)
+
+ alice := e.login(t, "alice")
+
+ // Own article: allowed.
+ w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", aliceArt.ID), alice, nil, "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("list own attachments: status = %d, want 200", w.Code)
+ }
+
+ // Someone else's article: forbidden.
+ w = e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), alice, nil, "")
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("list other user's attachments: status = %d, want 403", w.Code)
+ }
+}
+
+func TestAttachmentUploadRequiresOwnership(t *testing.T) {
+ e := newSecurityTestEnv(t)
+ var bobArt models.Article
+ e.db.Where("slug = ?", "bob-post").First(&bobArt)
+
+ alice := e.login(t, "alice")
+ token := e.csrfTokenFor(t, alice)
+
+ // Upload pending (article_id=0 + session token): allowed.
+ w := e.upload(t, alice, token, "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("pending upload: status = %d, body %s", w.Code, w.Body.String())
+ }
+
+ // Upload to someone else's article: forbidden.
+ w = e.upload(t, alice, token, fmt.Sprint(bobArt.ID))
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("upload to other user's article: status = %d, want 403", w.Code)
+ }
+}
+
+func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
+ e := newSecurityTestEnv(t)
+ var aliceArt, bobArt models.Article
+ e.db.Where("slug = ?", "alice-post").First(&aliceArt)
+ e.db.Where("slug = ?", "bob-post").First(&bobArt)
+
+ alice := e.login(t, "alice")
+ token := e.csrfTokenFor(t, alice)
+
+ // Alice uploads an attachment to her own article.
+ w := e.upload(t, alice, token, fmt.Sprint(aliceArt.ID))
+ if w.Code != http.StatusOK {
+ t.Fatalf("upload: status = %d, body %s", w.Code, w.Body.String())
+ }
+
+ // Bob uploads an attachment to his own article.
+ bob := e.login(t, "bob")
+ bobToken := e.csrfTokenFor(t, bob)
+ w = e.upload(t, bob, bobToken, fmt.Sprint(bobArt.ID))
+ if w.Code != http.StatusOK {
+ t.Fatalf("upload (bob): status = %d, body %s", w.Code, w.Body.String())
+ }
+
+ var bobAtt models.Attachment
+ if err := e.db.Where("uploader_id = ?", userIDByUsername(t, e.db, "bob")).First(&bobAtt).Error; err != nil {
+ t.Fatalf("bob attachment not found: %v", err)
+ }
+
+ // Alice cannot delete Bob's attachment.
+ form := url.Values{}
+ form.Set("_csrf", token)
+ w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), alice,
+ strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("delete other user's attachment: status = %d, want 403", w.Code)
+ }
+
+ // Bob can delete his own.
+ form.Set("_csrf", bobToken)
+ w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), bob,
+ strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
+ if w.Code != http.StatusOK {
+ t.Fatalf("delete own attachment: status = %d, want 200 (body %s)", w.Code, w.Body.String())
+ }
+
+ // Bob's attachment record should be gone.
+ var count int64
+ e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count)
+ if count != 0 {
+ t.Fatal("attachment was not deleted")
+ }
+}
+
+func TestAttachmentCSRFEnforced(t *testing.T) {
+ e := newSecurityTestEnv(t)
+ alice := e.login(t, "alice")
+
+ // POST without a CSRF token must be rejected before reaching the handler.
+ var buf strings.Builder
+ mw := multipart.NewWriter(&buf)
+ fw, _ := mw.CreateFormFile("file", "hello.txt")
+ fw.Write([]byte("hello"))
+ mw.Close()
+ w := e.do(http.MethodPost, "/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType())
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("upload without CSRF token: status = %d, want 403", w.Code)
+ }
+}
+
+func TestAttachmentAdminOverride(t *testing.T) {
+ e := newSecurityTestEnv(t)
+ var bobArt models.Article
+ e.db.Where("slug = ?", "bob-post").First(&bobArt)
+
+ admin := e.login(t, "admin")
+ token := e.csrfTokenFor(t, admin)
+
+ // Admin may list and upload to any article.
+ w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), admin, nil, "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("admin list: status = %d, want 200", w.Code)
+ }
+ w = e.upload(t, admin, token, fmt.Sprint(bobArt.ID))
+ if w.Code != http.StatusOK {
+ t.Fatalf("admin upload: status = %d, want 200 (body %s)", w.Code, w.Body.String())
+ }
+
+ // Clean up files created during the test (best effort).
+ entries, _ := os.ReadDir(filepath.Join(e.storageDir, "attachments"))
+ for _, ent := range entries {
+ os.Remove(filepath.Join(e.storageDir, "attachments", ent.Name()))
+ }
+}
+
+func userIDByUsername(t *testing.T, db *gorm.DB, username string) uint {
+ t.Helper()
+ var u models.User
+ if err := db.Where("username = ?", username).First(&u).Error; err != nil {
+ t.Fatalf("user %s not found: %v", username, err)
+ }
+ return u.ID
+}
diff --git a/main.go b/main.go
index 4c5466a..22da3f1 100644
--- a/main.go
+++ b/main.go
@@ -45,14 +45,26 @@ func main() {
store := cookie.NewStore([]byte(cfg.Secret))
store.Options(sessions.Options{
Path: "/",
- MaxAge: 86400, // 24 hours
- HttpOnly: true, // prevent XSS access
- Secure: false, // set true in production with HTTPS
+ MaxAge: 86400, // 24 hours
+ HttpOnly: true, // prevent XSS access
+ SameSite: http.SameSiteLaxMode, // CSRF defense-in-depth; token check is the primary control
+ // Secure is set per request (over HTTPS only) in the middleware below.
})
// 4. Create Gin router.
router := gin.Default()
+ // 4b. Trusted proxies: only IPs listed here may influence the client IP
+ // (X-Forwarded-For). Without this, gin trusts every proxy and a client
+ // can spoof the IP recorded for comments/article views.
+ if err := router.SetTrustedProxies(cfg.Web.TrustedProxies); err != nil {
+ log.Fatalf("Invalid trusted_proxies in config: %v", err)
+ }
+
+ // 4c. Security response headers (registered first so they are present
+ // even on rejected responses).
+ router.Use(middleware.SecurityHeaders())
+
// 5. Load HTML templates.
router.LoadHTMLGlob("templates/**/*.html")
@@ -69,6 +81,26 @@ func main() {
// 6. Global session middleware.
router.Use(sessions.Sessions("blog_session", store))
+ // 6a. Per-request session cookie hardening: Secure only over HTTPS, and
+ // SameSite=Lax. Applied per request because the app sits behind a TLS
+ // terminator (Caddy/Cloudflare) and cannot know at startup whether the
+ // client connection is encrypted.
+ router.Use(func(c *gin.Context) {
+ opts := sessions.Options{
+ Path: "/",
+ MaxAge: 86400,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ }
+ if middleware.IsHTTPSRequest(c) {
+ opts.Secure = true
+ }
+ sessions.Default(c).Options(opts)
+ })
+
+ // 6b. CSRF protection (must run after the session middleware).
+ router.Use(middleware.CSRFProtect())
+
// 7. Global context middleware (sets IsLoggedIn, Username for templates).
router.Use(middleware.SetUserContext(db))
diff --git a/middleware/clientip_test.go b/middleware/clientip_test.go
new file mode 100644
index 0000000..1b2e83d
--- /dev/null
+++ b/middleware/clientip_test.go
@@ -0,0 +1,59 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+// newClientIPRouter mirrors the production trusted-proxy configuration:
+// only loopback is trusted (the Caddy/nginx host).
+func newClientIPRouter() *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ if err := r.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil {
+ panic(err)
+ }
+ r.GET("/ip", func(c *gin.Context) {
+ c.String(http.StatusOK, c.ClientIP())
+ })
+ return r
+}
+
+func TestClientIPSpoofingBlocked(t *testing.T) {
+ r := newClientIPRouter()
+
+ // A direct (untrusted) client sending a forged X-Forwarded-For must not
+ // be able to change the recorded IP.
+ req := httptest.NewRequest(http.MethodGet, "/ip", nil)
+ req.RemoteAddr = "203.0.113.5:12345"
+ req.Header.Set("X-Forwarded-For", "6.6.6.6")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if got := w.Body.String(); got != "203.0.113.5" {
+ t.Errorf("direct client with forged XFF: got %q, want 203.0.113.5", got)
+ }
+
+ // A trusted proxy (loopback) forwarding a real chain: the rightmost
+ // untrusted entry wins, earlier (client-supplied) entries are ignored.
+ req = httptest.NewRequest(http.MethodGet, "/ip", nil)
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Header.Set("X-Forwarded-For", "6.6.6.6, 198.51.100.42")
+ w = httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if got := w.Body.String(); got != "198.51.100.42" {
+ t.Errorf("proxy-forwarded chain: got %q, want 198.51.100.42 (client-supplied entry must be ignored)", got)
+ }
+
+ // A trusted proxy forwarding a single entry: that entry is the client.
+ req = httptest.NewRequest(http.MethodGet, "/ip", nil)
+ req.RemoteAddr = "127.0.0.1:54321"
+ req.Header.Set("X-Forwarded-For", "198.51.100.42")
+ w = httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if got := w.Body.String(); got != "198.51.100.42" {
+ t.Errorf("proxy-forwarded single entry: got %q, want 198.51.100.42", got)
+ }
+}
diff --git a/middleware/csrf.go b/middleware/csrf.go
new file mode 100644
index 0000000..d404607
--- /dev/null
+++ b/middleware/csrf.go
@@ -0,0 +1,93 @@
+package middleware
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "net/http"
+ "strconv"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+)
+
+// CSRF protection uses the synchronizer-token pattern on top of the existing
+// session store:
+// - Safe methods (GET/HEAD/OPTIONS): a per-session token is created on first
+// use and exposed to templates / JS so it can be embedded in forms.
+// - Unsafe methods (POST/PUT/PATCH/DELETE): the request must carry the token
+// either as the "_csrf" form field (regular forms, multipart uploads) or
+// in the "X-CSRF-Token" header (AJAX). A mismatch aborts with 403.
+//
+// The token is bound to the session, so it works for anonymous visitors (e.g.
+// the comment form) as well as for logged-in users.
+
+const (
+ // CSRFFieldName is the form field carrying the token.
+ CSRFFieldName = "_csrf"
+ // CSRFHeaderName is the HTTP header carrying the token (AJAX).
+ CSRFHeaderName = "X-CSRF-Token"
+ // CSRFSessionKey stores the token server-side.
+ CSRFSessionKey = "csrf_token"
+ // CSRFContextKey exposes the token to handlers/templates via c.Set.
+ CSRFContextKey = "csrf_token"
+)
+
+// newCSRFToken returns a 256-bit random hex token. A crypto/rand failure is
+// unrecoverable; panic rather than degrade the defense.
+func newCSRFToken() string {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ panic("csrf: failed to read random bytes: " + err.Error())
+ }
+ return hex.EncodeToString(b)
+}
+
+// csrfTokensEqual compares two tokens in constant time.
+func csrfTokensEqual(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ var v byte
+ for i := 0; i < len(a); i++ {
+ v |= a[i] ^ b[i]
+ }
+ return v == 0
+}
+
+// CSRFProtect validates unsafe requests against the per-session CSRF token.
+// It must be registered after the sessions middleware.
+func CSRFProtect() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ session := sessions.Default(c)
+ token, _ := session.Get(CSRFSessionKey).(string)
+
+ switch c.Request.Method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
+ // Safe method: make sure a token exists and hand it to the
+ // template layer.
+ if token == "" {
+ token = newCSRFToken()
+ session.Set(CSRFSessionKey, token)
+ _ = session.Save()
+ }
+ c.Set(CSRFContextKey, token)
+ c.Next()
+ return
+ }
+
+ // Unsafe method: require a matching token.
+ supplied := c.PostForm(CSRFFieldName)
+ if supplied == "" {
+ supplied = c.GetHeader(CSRFHeaderName)
+ }
+ if token == "" || supplied == "" || !csrfTokensEqual(token, supplied) {
+ c.Header("Cache-Control", "no-store")
+ c.Header("Content-Type", "text/plain; charset=utf-8")
+ c.String(http.StatusForbidden, "403 Forbidden: CSRF token missing or invalid ("+strconv.Quote(c.Request.Method)+" "+c.Request.URL.Path+")")
+ c.Abort()
+ return
+ }
+ c.Set(CSRFContextKey, token)
+ c.Next()
+ }
+}
diff --git a/middleware/csrf_test.go b/middleware/csrf_test.go
new file mode 100644
index 0000000..04ac19e
--- /dev/null
+++ b/middleware/csrf_test.go
@@ -0,0 +1,154 @@
+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)
+ }
+ }
+}
diff --git a/middleware/https.go b/middleware/https.go
new file mode 100644
index 0000000..f3deca5
--- /dev/null
+++ b/middleware/https.go
@@ -0,0 +1,23 @@
+package middleware
+
+import (
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// IsHTTPSRequest reports whether the client reached us over TLS. Behind a
+// reverse proxy (Caddy/nginx) the app itself usually terminates plain
+// connections, so X-Forwarded-Proto is consulted as well. The header is only
+// honored when a trusted proxy forwarded the request - untrusted clients
+// spoofing it can at worst break their own session (the cookie turns Secure
+// and is refused over plain HTTP).
+func IsHTTPSRequest(c *gin.Context) bool {
+ if c.Request.TLS != nil {
+ return true
+ }
+ if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
+ return true
+ }
+ return false
+}
diff --git a/middleware/security_headers.go b/middleware/security_headers.go
new file mode 100644
index 0000000..89ebb72
--- /dev/null
+++ b/middleware/security_headers.go
@@ -0,0 +1,42 @@
+package middleware
+
+import "github.com/gin-gonic/gin"
+
+// csp is the Content-Security-Policy for HTML responses.
+//
+// 'unsafe-inline' is required because templates embed inline