Author SHA1 Message Date
kevin 38cd09f723 fix: 安全加固,修复 P0/P1 安全漏洞
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 全绿
2026-08-19 12:33:09 +08:00
kevin c3f8be58c9 Merge pull request 'fix: 静态资源 URL 加版本号,避免浏览器缓存旧 404 导致持续白屏' (#3) from dsh/go_blog:fix/static-cache-busting into main
Reviewed-on: kevin/go_blog#3
2026-08-18 06:25:32 -04:00
dsh c5218f35a1 fix: cache-bust static asset URLs (?v=2)
Browsers cached the pre-fix 404 responses for /static/js/markdown.js and
/static/css/markdown.css (responses carry Cache-Control max-age=14400),
so users kept seeing broken pages after redeploy. A versioned query
string forces clients to refetch the assets; bump ?v= whenever the
static files change.
2026-08-18 06:25:01 -04:00
kevin 9c0ba8cd6d Merge pull request 'fix: static 资源嵌入二进制,修复部署后 Markdown 内容不显示' (#2) from dsh/go_blog:fix/embed-static-assets into main
Reviewed-on: kevin/go_blog#2
2026-08-18 06:18:47 -04:00
dsh bd3a875403 fix: embed static assets into the binary and never render blank content
The merged markdown-rendering change served /static from a disk directory,
but the live deployment only replaced the binary and templates, so
/static/js/markdown.js and /static/css/markdown.css 404'd and every
article/comment rendered empty (BlogMD was undefined and threw).

- main.go: serve /static from an embedded FS (go:embed) — deployments
  now only need to replace the executable; no static dir to copy.
- install_linux.sh: drop the static directory copy (no longer needed).
- article.html / comment_list.html: if BlogMD is unavailable, fall back
  to plain (HTML-escaped) text for the article body, comment bodies and
  the comment preview, so content is never blank.
- README: document the static/ directory.

Verified locally: /static/* serve 200 even with the disk directory
removed; normal render path (25 checks) and the fallback path both pass.
2026-08-18 06:18:08 -04:00
kevin 5f21c73044 Merge pull request '完善前端 Markdown 渲染:排版样式、代码高亮、标题锚点、图片灯箱、复制按钮' (#1) from dsh/go_blog:feat/markdown-rendering into main
Reviewed-on: kevin/go_blog#1
2026-08-18 06:12:12 -04:00
35 changed files with 1287 additions and 85 deletions
+3
View File
@@ -120,6 +120,9 @@ go_blog/
│ └── upload_validator.go # 上传文件校验
├── i18n/
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
├── static/
│ ├── css/markdown.css # Markdown 排版样式(go:embed 编入二进制)
│ └── js/markdown.js # 前端 Markdown 渲染器(BlogMD
├── templates/
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
│ ├── pages/
+154
View File
@@ -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(附件上传/删除、头像上传)经 `<meta name="csrf-token">` 下发 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)
- XSShtml/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 按迭代排入
+33 -15
View File
@@ -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
}
+57 -2
View File
@@ -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)
+24 -2
View File
@@ -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 {
+5 -2
View File
@@ -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
}
+6 -13
View File
@@ -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()
}
+20 -32
View File
@@ -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)
+380
View File
@@ -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
}
+1 -5
View File
@@ -40,15 +40,11 @@ install -m 0755 -o root -g root "${SCRIPT_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/$
rm -rf "${INSTALL_DIR}/templates"
cp -a "${SCRIPT_DIR}/templates" "${INSTALL_DIR}/templates"
chown -R root:root "${INSTALL_DIR}/templates"
rm -rf "${INSTALL_DIR}/static"
cp -a "${SCRIPT_DIR}/static" "${INSTALL_DIR}/static"
chown -R root:root "${INSTALL_DIR}/static"
# static 资源已通过 go:embed 编入二进制,无需单独拷贝
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
chmod 0755 "${INSTALL_DIR}"
find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \;
find "${INSTALL_DIR}/templates" -type f -exec chmod 0644 {} \;
find "${INSTALL_DIR}/static" -type d -exec chmod 0755 {} \;
find "${INSTALL_DIR}/static" -type f -exec chmod 0644 {} \;
SOCKET_PATH="${SOCKET_DIR}/web.sock"
+51 -5
View File
@@ -1,10 +1,13 @@
package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"github.com/gin-contrib/sessions"
@@ -17,6 +20,13 @@ import (
"go_blog/models"
)
// staticFiles embeds the static assets (Markdown CSS/JS) into the binary so a
// deployment only needs to replace the executable — no separate static
// directory has to be copied to the server.
//
//go:embed static
var staticFiles embed.FS
func main() {
// 0. Parse command-line flags.
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
@@ -35,26 +45,62 @@ 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")
// 6. Serve uploaded files (avatars etc.) from the storage path.
router.Static("/uploads", cfg.Path)
// 6b. Serve bundled static assets (CSS/JS for Markdown rendering).
router.Static("/static", "./static")
// 6b. Serve bundled static assets (embedded into the binary).
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatalf("Failed to open embedded static assets: %v", err)
}
router.StaticFS("/static", http.FS(staticFS))
// 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))
+59
View File
@@ -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)
}
}
+93
View File
@@ -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()
}
}
+154
View File
@@ -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)
}
}
}
+23
View File
@@ -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
}
+42
View File
@@ -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 <script> and
// <style> blocks (Go html/template is the XSS defense for those); the
// directive list restricts everything else (scripts can only load from the
// pinned CDN hosts, no third-party frames, no other origins for fetch).
// Tighten further once the third-party assets are vendored locally (see
// SECURITY_TODO P2-9).
const csp = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.tailwindcss.com; " +
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " +
"img-src 'self' data: https: http:; " +
"font-src 'self' data: https:; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// SecurityHeaders sets hardening response headers on every response:
// CSP, nosniff, clickjacking (X-Frame-Options + frame-ancestors),
// referrer policy, and HSTS when the request arrived over HTTPS.
// Register it before all other middleware so the headers are present even
// on rejected (403/redirect) responses.
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy", csp)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if IsHTTPSRequest(c) {
// includeSubDomains is deliberately omitted: some subdomains of
// the site may still be served over plain HTTP.
c.Header("Strict-Transport-Security", "max-age=31536000")
}
c.Next()
}
}
+102
View File
@@ -0,0 +1,102 @@
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
}
+12 -2
View File
@@ -12,6 +12,7 @@
{{end}}
<form action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Hidden: attachment ownership (token on create, id on edit) -->
<input type="hidden" name="session_token" value="{{.SessionToken}}">
@@ -142,6 +143,8 @@ var easyMDE = new EasyMDE({
(function () {
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
var sessionToken = "{{ .SessionToken }}";
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
var uploadBtn = document.getElementById('attachmentUploadBtn');
var fileInput = document.getElementById('attachmentInput');
var msgEl = document.getElementById('attachmentMsg');
@@ -203,7 +206,10 @@ var easyMDE = new EasyMDE({
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
delBtn.onclick = function () {
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
fetch('/admin/articles/attachments/' + att.id + '/delete', { method: 'POST' })
fetch('/admin/articles/attachments/' + att.id + '/delete', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken }
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) { tr.remove(); }
@@ -226,7 +232,11 @@ var easyMDE = new EasyMDE({
if (articleID) { fd.append('article_id', articleID); }
else { fd.append('session_token', sessionToken); }
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
fetch('/admin/articles/attachments', { method: 'POST', body: fd })
fetch('/admin/articles/attachments', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: fd
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.error) { msgEl.textContent = r.error; return; }
+1
View File
@@ -42,6 +42,7 @@
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/admin/articles/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
</form>
+9 -1
View File
@@ -58,16 +58,19 @@
<div class="flex justify-end gap-3 mt-3 text-sm">
{{if eq .Status 0}}
<form action="/admin/comments/{{.ID}}/approve" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="text-green-600 hover:text-green-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_approve"}}</button>
</form>
{{end}}
{{if ne .Status 2}}
<form action="/admin/comments/{{.ID}}/reject" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="text-orange-600 hover:text-orange-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_reject"}}</button>
</form>
{{end}}
<form action="/admin/comments/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "comment_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_delete"}}</button>
</form>
</div>
@@ -83,7 +86,12 @@
<script>
(function () {
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
BlogMD.renderInto(el, el.getAttribute('data-md') || '');
var raw = el.getAttribute('data-md') || '';
if (window.BlogMD) {
BlogMD.renderInto(el, raw);
} else {
el.textContent = raw;
}
});
})();
</script>
+1
View File
@@ -7,6 +7,7 @@
<p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p>
</div>
<form action="/logout" method="post" class="m-0">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button
type="submit"
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer"
+1
View File
@@ -16,6 +16,7 @@
{{end}}
<form action="/admin/settings/comments" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<label class="flex items-center gap-3 text-sm text-gray-700">
<input type="checkbox" name="enabled" value="1" {{if .CommentConfig.Enabled}}checked{{end}}
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
+4
View File
@@ -42,17 +42,20 @@
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="default">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_set_default"}}</button>
</form>
<form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form>
<form action="/admin/settings/download" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
@@ -70,6 +73,7 @@
<!-- Add base URL -->
<form action="/admin/settings/download" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_url"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
+4
View File
@@ -17,6 +17,7 @@
<!-- Add New Link Form -->
<form action="/admin/settings/navlinks" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_add"}}</h3>
@@ -91,6 +92,7 @@
<div class="flex items-center gap-2">
<!-- Toggle Button -->
<form action="/admin/settings/navlinks" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg border {{if .Enabled}}bg-green-50 border-green-300 text-green-700 hover:bg-green-100{{else}}bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200{{end}} transition-colors">
@@ -103,6 +105,7 @@
</button>
<!-- Delete Button -->
<form action="/admin/settings/navlinks" method="post" class="inline" onsubmit="return confirm('{{index $.Tr "navlinks_confirm_delete"}}')">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-sm px-3 py-1.5 bg-red-50 border border-red-300 text-red-700 rounded-lg hover:bg-red-100 transition-colors">
@@ -125,6 +128,7 @@
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">{{index .Tr "navlinks_edit"}}</h3>
<form action="/admin/settings/navlinks" method="post" id="editForm">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="id" id="edit_id">
+1
View File
@@ -16,6 +16,7 @@
{{end}}
<form action="/admin/settings/site" method="post" enctype="multipart/form-data" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Logo -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_logo"}}</label>
+5
View File
@@ -17,6 +17,7 @@
<!-- Global policy -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="save_config">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div>
@@ -66,6 +67,7 @@
<td class="px-4 py-3 text-sm text-gray-500">{{index $.Tr (printf "cat_%s" .Category)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">
<form action="/admin/settings/upload" method="post" class="flex items-center gap-1">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="size_type">
<input type="hidden" name="id" value="{{.ID}}">
<input type="number" name="max_size" min="0" step="0.1" value="{{.MaxSizeMB}}"
@@ -79,12 +81,14 @@
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/upload" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle_type">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form>
<form action="/admin/settings/upload" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete_type">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
@@ -98,6 +102,7 @@
<!-- Add file type -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add_type">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_type"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
+1
View File
@@ -10,6 +10,7 @@
{{end}}
<form action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Username (read-only on edit) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_username"}}</label>
+1
View File
@@ -61,6 +61,7 @@
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/admin/users/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
</form>
+8 -3
View File
@@ -4,6 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{.CSRFToken}}">
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
{{if .SiteFavicon}}
{{if .SiteFaviconIsURL}}
@@ -80,6 +81,7 @@
{{end}}
<div class="border-t border-gray-100 my-1"></div>
<form action="/logout" method="post" class="m-0">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
{{index .Tr "logout"}}
</button>
@@ -111,13 +113,16 @@
{{define "markdown_assets"}}
{{/* Markdown rendering assets: marked (pinned UMD build), DOMPurify,
highlight.js and the shared renderer + styles. Include on any page that
renders Markdown (articles, comments, editor previews). */}}
renders Markdown (articles, comments, editor previews).
Local static files carry a ?v= cache-buster: bump it whenever
static/js/markdown.js or static/css/markdown.css changes, otherwise
browsers may keep serving stale cached copies. */}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css">
<link rel="stylesheet" href="/static/css/markdown.css">
<link rel="stylesheet" href="/static/css/markdown.css?v=2">
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js"></script>
<script src="/static/js/markdown.js"></script>
<script src="/static/js/markdown.js?v=2"></script>
{{end}}
{{define "footer"}}
+23 -3
View File
@@ -75,6 +75,7 @@
<button type="button" id="cancelReply" class="text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer">{{index .Tr "comments_cancel_reply"}}</button>
</div>
<form id="commentForm" action="/article/{{.Article.Slug}}/comments" method="post">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="parent_id" id="parent_id" value="{{.CommentForm.ParentID}}">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div>
@@ -144,12 +145,27 @@
<script>
// Article body: render Markdown client-side through the shared BlogMD
// pipeline (marked + DOMPurify + highlight.js, see /static/js/markdown.js).
BlogMD.renderInto(document.getElementById('articleBody'), {{.Article.Content}});
// If the renderer failed to load, fall back to plain text so the article
// is never blank.
(function () {
var body = document.getElementById('articleBody');
var content = {{.Article.Content}};
if (window.BlogMD) {
BlogMD.renderInto(body, content);
} else {
body.textContent = content;
}
})();
// Comments are rendered from the escaped data-md attribute.
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
if (el.dataset.rendered) return;
BlogMD.renderInto(el, el.getAttribute('data-md') || '');
var raw = el.getAttribute('data-md') || '';
if (window.BlogMD) {
BlogMD.renderInto(el, raw);
} else {
el.textContent = raw;
}
el.dataset.rendered = '1';
});
@@ -191,7 +207,11 @@
var previewing = previewBox.classList.toggle('hidden') === false;
textarea.classList.toggle('hidden', previewing);
if (previewing) {
previewBox.innerHTML = BlogMD.render(textarea.value);
if (window.BlogMD) {
previewBox.innerHTML = BlogMD.render(textarea.value);
} else {
previewBox.textContent = textarea.value;
}
togglePreview.textContent = '{{index .Tr "comments_edit"}}';
} else {
togglePreview.textContent = '{{index .Tr "comments_preview"}}';
+1
View File
@@ -11,6 +11,7 @@
{{end}}
<form action="/login" method="post" class="space-y-5">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label>
<input
+5
View File
@@ -36,6 +36,7 @@
</div>
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Avatar Section -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
@@ -137,6 +138,9 @@
var i18nCropSuccess = "{{index .Tr "crop_success"}}";
var i18nCropConfirm = "{{index .Tr "crop_confirm"}}";
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
fileInput.addEventListener('change', function() {
var file = this.files[0];
if (!file) return;
@@ -193,6 +197,7 @@
fetch('/profile/avatar', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: formData,
credentials: 'same-origin'
})
+1
View File
@@ -11,6 +11,7 @@
{{end}}
<form action="/register" method="post" class="space-y-5">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label>
<input
+1
View File
@@ -13,6 +13,7 @@
{{end}}
<form action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
{{if .SessionToken}}
<input type="hidden" name="session_token" value="{{.SessionToken}}">
{{end}}
+1
View File
@@ -42,6 +42,7 @@
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/my/articles/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
</form>