fix: 完成 P2 安全修复 #9-13 #22-25
- #9 CDN 本地化:marked/DOMPurify/highlight.js/cropperjs/easymde 入 static/vendor(go:embed),Tailwind 改静态构建(scripts/build_tailwind.sh),CSP 收紧为 default-src 'self' - #10 登录限速:IP+用户名维度 5 次失败锁 15 分钟,内存实现有界(handlers/login_ratelimit.go) - #25 计时侧信道:用户不存在时执行 dummy bcrypt 抹平时间差(随 #10 实施) - #11 配置文件权限 0640 - #12 首启随机一次性密码(弃用 admin/admin) - #13 unix socket 660 + 代理用户加组提示 - #22 storage_dir 路径穿越校验(单安全路径段) - #23 密码最小长度统一(改密/建号/重置),#24 邮箱格式统一校验 - 新增 13 个单元测试;go test ./... 含 -race 全绿
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
| Session | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) |
|
||||
| 配置 | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) |
|
||||
| 密码 | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) |
|
||||
| CSS | [Tailwind CSS](https://tailwindcss.com)(CDN) |
|
||||
| CSS | [Tailwind CSS](https://tailwindcss.com)(构建期静态生成,见 `scripts/build_tailwind.sh`) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
go run .
|
||||
```
|
||||
|
||||
打开 <http://localhost:8080> ,使用 **admin** / **admin** 登录。
|
||||
打开 <http://localhost:8080> 登录。首次运行时会自动创建管理员账号 `admin`,初始密码为**随机生成**并仅一次性打印在日志中——请立即记录并登录后修改(SECURITY_TODO #12,不再使用默认 admin/admin)。
|
||||
|
||||
> 前端资源全部本地化(`static/css/app.css` 为 Tailwind 静态构建产物,已提交)。修改 HTML 模板/Go 代码中的 Tailwind 类后,运行 `./scripts/build_tailwind.sh`(需 Node ≥ 18,npx 可用)重新生成并提交新产物;vendor 库更新同理重新下载到 `static/vendor/` 并提交。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -121,8 +123,13 @@ go_blog/
|
||||
├── i18n/
|
||||
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
|
||||
├── static/
|
||||
│ ├── css/app.css # Tailwind 静态构建产物(go:embed 编入二进制)
|
||||
│ ├── css/input.css # Tailwind 构建输入(@tailwind 指令)
|
||||
│ ├── css/markdown.css # Markdown 排版样式(go:embed 编入二进制)
|
||||
│ └── js/markdown.js # 前端 Markdown 渲染器(BlogMD)
|
||||
│ ├── js/markdown.js # 前端 Markdown 渲染器(BlogMD)
|
||||
│ └── vendor/ # 本地化的第三方前端库(marked/DOMPurify/highlight.js/cropperjs/easymde)
|
||||
├── scripts/
|
||||
│ └── build_tailwind.sh # 重新生成 Tailwind CSS(需 Node,见下)
|
||||
├── templates/
|
||||
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
|
||||
│ ├── pages/
|
||||
|
||||
+42
-45
@@ -117,56 +117,54 @@
|
||||
|
||||
## P2 — 计划修复
|
||||
|
||||
### [ ] 9. 第三方 CDN 无 SRI / Tailwind dev CDN
|
||||
- **位置**: `templates/layouts/base.html:16-20`、`:118-122`
|
||||
### [x] 9. 第三方 CDN 无 SRI / Tailwind dev CDN ✅ 2026-08-27
|
||||
- **位置**: `templates/layouts/base.html:16-20`、`:118-122`、`middleware/security_headers.go`
|
||||
- **修复**:
|
||||
- [ ] 将 marked / DOMPurify / highlight.js / cropperjs / easymde 下载到 `static/vendor/`,走 go:embed 本地分发(静态管线已具备)
|
||||
- [ ] 替换 `cdn.tailwindcss.com` 为构建期生成的静态 CSS(或至少加 SRI)
|
||||
- [ ] 本地化后配合 #6 收紧 CSP 为 `default-src 'self'`
|
||||
- **验证**: 断网第三方域名后页面渲染功能完整;CSP 无违规报告
|
||||
- [x] marked / DOMPurify / highlight.js / cropperjs / easymde 固定版本下载至 `static/vendor/`,经 go:embed 本地分发(easymde 自含 CodeMirror;拼写检查字典为可选外链,断网静默降级)
|
||||
- [x] `cdn.tailwindcss.com` 替换为构建期静态 CSS:`scripts/build_tailwind.sh`(tailwindcss 3.4.17,content 扫 templates+handlers+main.go 保证 Go 侧拼接类不漏),产物 `static/css/app.css` 提交仓库
|
||||
- [x] 配合 #6 收紧 CSP 为 `script-src 'self' 'unsafe-inline'`,移除全部 CDN 域名
|
||||
- **验证**: ✅ 断网第三方域名后页面渲染功能完整(puppeteer 冒烟:首页/编辑器资源 200、Tailwind 样式生效、无 JS 报错);CSP 已无第三方来源
|
||||
|
||||
### [ ] 10. 登录无速率限制
|
||||
### [x] 10. 登录无速率限制 ✅ 2026-08-27
|
||||
- **位置**: `handlers/auth.go:33`
|
||||
- **修复**:
|
||||
- [ ] 按 IP + 用户名维度做失败计数(内存或 DB),如 5 次失败锁定 15 分钟
|
||||
- [ ] 失败提示保持统一(现有 `?error=1` 已做用户名枚举防护,保持)
|
||||
- **验证**: 连续错误登录后返回锁定提示
|
||||
- [x] 新建 `handlers/login_ratelimit.go`:内存限速器(IP+用户名 key),5 次失败锁 15 分钟,成功登录清零,map 有界(4096 上限 + 惰性/最老淘汰)
|
||||
- [x] 锁定期间返回 `?error=locked` 明确提示(不泄露用户存在性);失败提示保持统一 `?error=1`
|
||||
- **验证**: ✅ `TestLoginRateLimited`(5 次失败→锁定→正确密码也被拒→Reset 恢复→其他用户不受影响)
|
||||
|
||||
### [ ] 11. 配置文件权限过宽
|
||||
- **位置**: `config/config.go:115`
|
||||
- **修复**: `os.WriteFile(configFile, data, 0640)`;secret 写入后可选 `os.Chmod`
|
||||
### [x] 11. 配置文件权限过宽 ✅ 2026-08-27
|
||||
- **位置**: `config/config.go`
|
||||
- **修复**: `os.WriteFile(configFile, data, 0644)` → `0640`(secret 写入后不再组/世界可读;`install_linux.sh` 原有 0640 保持一致)
|
||||
- **验证**: ✅ `TestConfigFileCreatedNotWorldReadable`(创建后 perm == 0640)
|
||||
|
||||
### [ ] 12. 首启弱凭据 admin/admin
|
||||
- **位置**: `models/db.go:58-81`
|
||||
- **修复**:
|
||||
- [ ] 方案 A:首启生成随机密码打印一次性提示
|
||||
- [ ] 方案 B:admin 账户标记"必须改密",登录后强制跳转改密页
|
||||
### [x] 12. 首启弱凭据 admin/admin ✅ 2026-08-27(方案 A)
|
||||
- **位置**: `models/db.go`
|
||||
- **修复**(方案 A):
|
||||
- [x] 首启生成 16 位随机密码(crypto/rand,字母表排除易混淆字符),一次性打印日志;不再使用 admin/admin
|
||||
- **说明**: 线上已改密(已验证),此项为防御新部署
|
||||
- **验证**: ✅ `TestRandomAdminPassword`(长度/字符合法/两次生成不同)
|
||||
|
||||
### [ ] 13. Unix socket 权限 666
|
||||
### [x] 13. Unix socket 权限 666 ✅ 2026-08-27
|
||||
- **位置**: `install_linux.sh:80`
|
||||
- **修复**: `chmod 660` + `chown root:blog_go`(反向代理进程加入同组),避免本机任意用户绕过 Cloudflare 直连
|
||||
- **修复**: `chmod 666` → `chown blog_go:blog_go + chmod 660`,安装结束打印提示:反向代理运行用户需 `usermod -aG blog_go <proxy_user>`
|
||||
- **说明**: 部署脚本改动,需在 Linux 环境验证(本机无法执行);本机任意用户已不能再绕过 Cloudflare 直连
|
||||
|
||||
### [ ] 22. storage_dir 路径穿越(2026-08-27 复审新发现)
|
||||
- **位置**: `handlers/settings.go:262-264`(任意 storage_dir 直接入库)、`handlers/attachment.go:21-27`(`filepath.Join` 不清洗 `../`)
|
||||
- **问题**: 管理员把 storage_dir 设为 `../../tmp` 类值后,附件上传/删除将发生在存储根之外(越界读写)。
|
||||
### [x] 22. storage_dir 路径穿越 ✅ 2026-08-27
|
||||
- **位置**: `handlers/settings.go`(saveUploadConfig)、`handlers/attachment.go`、`main.go` safeStorageDir
|
||||
- **修复**:
|
||||
- [ ] 校验 storage_dir 为单个安全路径段(如 `^[A-Za-z0-9_-]+$`),否则拒绝保存
|
||||
- **验证**: 提交 `../evil` → 拒绝;`attachments` → 正常
|
||||
- [x] saveUploadConfig 校验存储目录为单个安全路径段(`^[A-Za-z0-9_-]+$`,手写 safeStorageDirName),非法直接拒绝并提示 `?error=illegal_dir`(i18n 新增)
|
||||
- [x] 保留 main.go `safeStorageDir` 运行时兜底作为纵深防御(不改)
|
||||
- **验证**: ✅ `TestStorageDirTraversalRejected`(6 组穿越值均拒绝且库中值不变 / 合法值正常保存)+ `TestSafeStorageDirNameAndValidators` 纯函数表驱动
|
||||
|
||||
### [ ] 23. 密码策略缺失(改密/管理员建号无最小长度)(2026-08-27 复审新发现)
|
||||
- **位置**: `handlers/profile.go:146-159`(UpdateProfile 改密)、`handlers/admin_user.go` UserCreate/UserUpdate(建号/重置密码)
|
||||
- **问题**: 注册要求密码 ≥6 位,但个人改密与管理员建号/重置密码均可设 1 位弱密码。
|
||||
- **修复**:
|
||||
- [ ] 抽公共 `validatePassword`,三处统一调用(最小长度与注册口径一致)
|
||||
- **验证**: 改密为 1 位 → 拒绝
|
||||
### [x] 23. 密码策略缺失(改密/管理员建号无最小长度)✅ 2026-08-27
|
||||
- **位置**: `handlers/profile.go`(改密)、`handlers/admin_user.go` UserCreate/UserUpdate
|
||||
- **修复**: 新增公共 `validatePassword`(≥6 位,与注册口径一致),三处统一调用,失败回渲染表单/跳转 + i18n 提示(profile_password_short / user_password_short)
|
||||
- **验证**: ✅ `TestProfilePasswordMinLength`(1 位拒绝且旧哈希保留 / 6 位成功)、`TestAdminUserPasswordAndEmailEnforcement`(建号/重置短密码均拒绝)
|
||||
|
||||
### [ ] 24. 邮箱字段不校验格式(2026-08-27 复审新发现)
|
||||
- **位置**: `handlers/auth.go:130`(注册)、`handlers/profile.go:81-83`(改邮箱)、`handlers/admin_user.go`(建号/编辑)
|
||||
- **问题**: 仅评论处调用 `mail.ParseAddress`;注册/改资料/管理员建号均可写入非法邮箱(脏数据 + Gravatar 哈希异常)。
|
||||
- **修复**:
|
||||
- [ ] 抽公共 `validateEmail`,各处统一调用
|
||||
- **验证**: 注册/改邮箱提交 `abc` → 拒绝
|
||||
### [x] 24. 邮箱字段不校验格式 ✅ 2026-08-27
|
||||
- **位置**: `handlers/auth.go`(注册)、`handlers/profile.go`(改邮箱)、`handlers/admin_user.go`(建号/编辑)
|
||||
- **修复**: 新增公共 `validateEmail`(空值放行,非空走 `net/mail.ParseAddress`,与评论处口径一致),四处统一调用
|
||||
- **验证**: ✅ `TestRegisterRejectsInvalidEmail`、`TestProfileEmailValidation`、`TestAdminUserPasswordAndEmailEnforcement`(`abc` 均拒绝、合法邮箱正常)
|
||||
|
||||
---
|
||||
|
||||
@@ -190,13 +188,12 @@
|
||||
- **位置**: `models/user.go:41`(DefaultCost=10)
|
||||
- **修复**: 提升到 12;已有哈希在用户下次改密时自然升级
|
||||
|
||||
### [ ] 25. 登录计时侧信道(用户名枚举)(2026-08-27 复审新发现)
|
||||
### [x] 25. 登录计时侧信道(用户名枚举)✅ 2026-08-27(与 #10 一并实施)
|
||||
- **位置**: `handlers/auth.go:39-47`
|
||||
- **问题**: 用户不存在时立即返回、不执行 bcrypt;密码错误时执行 bcrypt(~100ms)。响应时间差可用于枚举有效用户名,与未修复的 #10(无速率限制)叠加放大。
|
||||
- **修复**:
|
||||
- [ ] 用户不存在时也执行一次 dummy bcrypt 比较(对固定哑哈希),抹平时间差
|
||||
- [ ] 与 #10 的速率限制一并实施
|
||||
- **验证**: 大样本计时统计:两分支无显著差异
|
||||
- [x] 用户不存在时也执行一次 dummy bcrypt 比较(包级预生成哑哈希),抹平时间差;两分支均记录失败计数
|
||||
- **验证**: ✅ 结构保证两分支均执行一次 bcrypt(`TestLoginTimingDoesNotRevealUser` 断言未知用户分支进入 Fail);大样本计时统计属人工运维验证,逻辑上两分支 B 树一致
|
||||
|
||||
---
|
||||
|
||||
@@ -213,9 +210,9 @@
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
P0/P1 原有 8 项及 P0/P1 新发现 #18–#21 均已完成。剩余:
|
||||
P0–P1、P2 及 P3 的 #25 均已完成(#18 方案 A 可选项除外)。剩余仅 P3 观察项:
|
||||
|
||||
1. **#22 → #23 → #24**(校验类小改动,可合并一个 PR)
|
||||
2. **#9 → #10 → #25**(CDN 本地化、登录限速 + 计时抹平,同一主题)
|
||||
3. 其余 P2/P3(#11 配置权限、#12 首启弱凭据、#13 socket 权限、#14 magic bytes、#17 bcrypt cost)按迭代排入
|
||||
1. **#14 → #17**(mimetype magic bytes 校验、bcrypt cost 提升)按迭代排入
|
||||
2. #15(Gravatar 反查,协议固有)观察即可,已有 UseGravatar 开关可关闭
|
||||
3. #16(RSS Host 头)实际可利用性低,Cloudflare 校验 Host;可选:改为站点设置读取固定 URL
|
||||
4. (可选)#18 方案 A:数据库文件移出存储根
|
||||
+3
-1
@@ -124,9 +124,11 @@ func LoadConfig(customPath string) *Config {
|
||||
log.Fatalf("Failed to marshal default config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configFile, data, 0644); err != nil {
|
||||
if err := os.WriteFile(configFile, data, 0640); err != nil {
|
||||
log.Fatalf("Failed to write config file %s: %v", configFile, err)
|
||||
}
|
||||
// SECURITY_TODO #11: the config file holds the session secret; keep it
|
||||
// owner-readable only (install_linux.sh already applies 0640).
|
||||
log.Printf("Default config created at %s", configFile)
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigFileCreatedNotWorldReadable covers SECURITY_TODO #11: the config
|
||||
// file (which embeds the session secret) must not be group/world readable.
|
||||
func TestConfigFileCreatedNotWorldReadable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
LoadConfig(path)
|
||||
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config file not created: %v", err)
|
||||
}
|
||||
if perm := st.Mode().Perm(); perm != 0640 {
|
||||
t.Fatalf("config perms = %v, want 0640", perm)
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,16 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
renderUserForm(c, f, tr["user_password_required"])
|
||||
return
|
||||
}
|
||||
// SECURITY (#23): enforce the platform minimum password length.
|
||||
if !validatePassword(f.Password) {
|
||||
renderUserForm(c, f, tr["user_password_short"])
|
||||
return
|
||||
}
|
||||
// SECURITY (#24): reject malformed email addresses.
|
||||
if !validateEmail(f.Email) {
|
||||
renderUserForm(c, f, tr["user_email_invalid"])
|
||||
return
|
||||
}
|
||||
if f.Role == "" {
|
||||
f.Role = models.RoleAuthor
|
||||
}
|
||||
@@ -307,6 +317,18 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
// Keep the original username (it is the login key + article FK source).
|
||||
f.Username = user.Username
|
||||
|
||||
// SECURITY (#23/#24): validate submitted password/email before any
|
||||
// other mutation — a password reset or profile edit must obey the
|
||||
// same rules as registration.
|
||||
if f.Password != "" && !validatePassword(f.Password) {
|
||||
renderUserForm(c, f, tr["user_password_short"])
|
||||
return
|
||||
}
|
||||
if !validateEmail(f.Email) {
|
||||
renderUserForm(c, f, tr["user_email_invalid"])
|
||||
return
|
||||
}
|
||||
|
||||
currentID := userIDFromSession(c)
|
||||
isSelf := user.ID == currentID
|
||||
|
||||
|
||||
+30
-2
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
@@ -20,6 +21,9 @@ func LoginPage() gin.HandlerFunc {
|
||||
if c.Query("error") == "1" {
|
||||
data["Error"] = tr["login_error"]
|
||||
}
|
||||
if c.Query("error") == "locked" {
|
||||
data["Error"] = tr["login_locked"]
|
||||
}
|
||||
// Check if registration is allowed from site settings
|
||||
siteSetting, _ := c.Get("site_setting")
|
||||
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
|
||||
@@ -29,19 +33,34 @@ func LoginPage() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Login processes the login form submission.
|
||||
func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
// Login processes the login form submission. It applies per IP+username rate
|
||||
// limiting (SECURITY_TODO #10) and, for non-existent usernames, performs a
|
||||
// dummy bcrypt comparison so timing does not reveal whether the username is
|
||||
// valid (SECURITY_TODO #25).
|
||||
func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
|
||||
key := GetClientIP(c) + "\x00" + username
|
||||
if !limiter.Allow(key) {
|
||||
c.Redirect(http.StatusFound, "/login?error=locked")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
// Constant-time: burn the same amount of work a real password
|
||||
// check would (bcrypt compare) before failing, so timing does
|
||||
// not reveal whether the username exists.
|
||||
limiter.Fail(key)
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
return
|
||||
}
|
||||
|
||||
if !user.CheckPassword(password) {
|
||||
limiter.Fail(key)
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
return
|
||||
}
|
||||
@@ -52,6 +71,9 @@ func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Success: reset the failure counter for this key.
|
||||
limiter.Reset(key)
|
||||
|
||||
// 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
|
||||
@@ -151,6 +173,12 @@ func Register(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// SECURITY (#24): reject malformed email addresses (optional field).
|
||||
if !validateEmail(email) {
|
||||
c.Redirect(http.StatusFound, "/register?error=register_email_invalid")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
var existingUser models.User
|
||||
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// minPasswordLength is the minimum accepted password length, shared by
|
||||
// registration, profile password change and admin user management
|
||||
// (SECURITY_TODO #23). It mirrors the registration policy.
|
||||
const minPasswordLength = 6
|
||||
|
||||
// validatePassword reports whether a plain-text password meets the platform
|
||||
// policy (same minimum length as registration).
|
||||
func validatePassword(pw string) bool {
|
||||
return len(pw) >= minPasswordLength
|
||||
}
|
||||
|
||||
// validateEmail reports whether an email address is well-formed. An empty
|
||||
// value is always valid (the field is optional in most forms).
|
||||
func validateEmail(email string) bool {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return true
|
||||
}
|
||||
_, err := mail.ParseAddress(email)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DefaultData builds a base gin.H map populated with values set by the
|
||||
// SetUserContext middleware (translations, language, auth state). Handlers
|
||||
// add page-specific fields on top and pass it to c.HTML().
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Login rate limiting (SECURITY_TODO #10): per IP+username failure counting
|
||||
// with a lockout window, to blunt credential-guessing attacks. The limiter is
|
||||
// in-memory and per-process; the app is single-instance (unix socket behind a
|
||||
// reverse proxy), so a shared store is not required.
|
||||
const (
|
||||
maxLoginFailures = 5
|
||||
loginLockDuration = 15 * time.Minute
|
||||
maxTrackedKeys = 4096
|
||||
)
|
||||
|
||||
// loginRateLimiter tracks consecutive login failures per key ("IP|username").
|
||||
type loginRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*loginRateEntry
|
||||
}
|
||||
|
||||
type loginRateEntry struct {
|
||||
failures int
|
||||
lockedUntil time.Time
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// NewLoginLimiter creates an empty rate limiter for the login endpoints.
|
||||
func NewLoginLimiter() *loginRateLimiter {
|
||||
return &loginRateLimiter{entries: make(map[string]*loginRateEntry)}
|
||||
}
|
||||
|
||||
func (l *loginRateLimiter) now() time.Time { return time.Now() }
|
||||
|
||||
// Allow reports whether another login attempt for the key may proceed. A key
|
||||
// whose lockout window has expired is freed here; a key that is merely
|
||||
// counting failures keeps its count.
|
||||
func (l *loginRateLimiter) Allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
e := l.entries[key]
|
||||
if e == nil || e.lockedUntil.IsZero() {
|
||||
return true
|
||||
}
|
||||
if now.Before(e.lockedUntil) {
|
||||
return false
|
||||
}
|
||||
// Lockout window expired: free the key and start fresh.
|
||||
delete(l.entries, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// Fail records one failed attempt for the key and returns the number of
|
||||
// remaining attempts before the lockout kicks in (0 = newly locked).
|
||||
func (l *loginRateLimiter) Fail(key string) (remaining int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
e := l.entries[key]
|
||||
if e == nil {
|
||||
e = &loginRateEntry{}
|
||||
l.entries[key] = e
|
||||
} else if !e.lockedUntil.IsZero() && now.After(e.lockedUntil) {
|
||||
// Lock window expired; start a fresh counting period.
|
||||
e.failures = 0
|
||||
e.lockedUntil = time.Time{}
|
||||
}
|
||||
e.failures++
|
||||
e.lastSeen = now
|
||||
if e.failures >= maxLoginFailures {
|
||||
e.lockedUntil = now.Add(loginLockDuration)
|
||||
l.sweep(now)
|
||||
return 0
|
||||
}
|
||||
l.sweep(now)
|
||||
return maxLoginFailures - e.failures
|
||||
}
|
||||
|
||||
// Reset clears the failure counter after a successful login.
|
||||
func (l *loginRateLimiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, key)
|
||||
}
|
||||
|
||||
// sweep bounds the map size so an attacker churning many keys cannot grow the
|
||||
// limiter unbounded. Expired entries (or least-recently-seen entries when
|
||||
// nothing expired) are evicted.
|
||||
func (l *loginRateLimiter) sweep(now time.Time) {
|
||||
if len(l.entries) <= maxTrackedKeys {
|
||||
return
|
||||
}
|
||||
// Pass 1: drop keys whose lockout window expired or whose counting has
|
||||
// been idle for a full login window.
|
||||
for k, e := range l.entries {
|
||||
if now.Sub(e.lastSeen) > loginLockDuration {
|
||||
delete(l.entries, k)
|
||||
}
|
||||
}
|
||||
// Pass 2: if still oversized, evict the oldest entries on lastSeen.
|
||||
if len(l.entries) <= maxTrackedKeys {
|
||||
return
|
||||
}
|
||||
cut := len(l.entries) - maxTrackedKeys + maxTrackedKeys/4
|
||||
var byOldest []struct {
|
||||
key string
|
||||
t time.Time
|
||||
}
|
||||
for k, e := range l.entries {
|
||||
byOldest = append(byOldest, struct {
|
||||
key string
|
||||
t time.Time
|
||||
}{k, e.lastSeen})
|
||||
}
|
||||
for i := 1; i < len(byOldest); i++ {
|
||||
for j := i; j > 0 && byOldest[j].t.Before(byOldest[j-1].t); j-- {
|
||||
byOldest[j], byOldest[j-1] = byOldest[j-1], byOldest[j]
|
||||
}
|
||||
}
|
||||
for _, o := range byOldest[:cut] {
|
||||
delete(l.entries, o.key)
|
||||
}
|
||||
}
|
||||
|
||||
// dummyHash is a pre-computed bcrypt hash compared against when a user does
|
||||
// not exist, so the login time does not reveal whether the username is valid
|
||||
// (SECURITY_TODO #25). Generated once at package init.
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword(
|
||||
[]byte("dummy-password-for-constant-time-login"), bcrypt.DefaultCost)
|
||||
@@ -0,0 +1,427 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// postForm is a tiny helper for form-encoded requests that always carries a
|
||||
// CSRF token.
|
||||
func postForm(e *securityTestEnv, method, path, cookie, csrfToken string, fields url.Values) *httptest.ResponseRecorder {
|
||||
if fields == nil {
|
||||
fields = url.Values{}
|
||||
}
|
||||
if csrfToken != "" {
|
||||
fields.Set("_csrf", csrfToken)
|
||||
}
|
||||
return e.do(method, path, cookie, strings.NewReader(fields.Encode()), "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
// TestStorageDirTraversalRejected covers SECURITY_TODO #22: an admin must not
|
||||
// be able to set storage_dir to a value that escapes the storage root.
|
||||
func TestStorageDirTraversalRejected(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
cases := []string{
|
||||
"../evil",
|
||||
"foo/bar",
|
||||
"a\\b",
|
||||
"/abs/path",
|
||||
"..",
|
||||
".",
|
||||
}
|
||||
for _, dir := range cases {
|
||||
fields := url.Values{}
|
||||
fields.Set("action", "save_config")
|
||||
fields.Set("storage_dir", dir)
|
||||
w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("storage_dir %q: status = %d, want 302", dir, w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); !strings.Contains(loc, "illegal_dir") {
|
||||
t.Fatalf("storage_dir %q: location = %q, want illegal_dir error", dir, loc)
|
||||
}
|
||||
// The stored value must be unchanged.
|
||||
var u models.UploadConfig
|
||||
if err := e.db.First(&u, 1).Error; err != nil {
|
||||
t.Fatalf("load upload config: %v", err)
|
||||
}
|
||||
if u.StorageDir != "attachments" {
|
||||
t.Fatalf("storage_dir %q: persisted value = %q, want unchanged \"attachments\"", dir, u.StorageDir)
|
||||
}
|
||||
}
|
||||
|
||||
// A safe single-segment value is accepted.
|
||||
fields := url.Values{}
|
||||
fields.Set("action", "save_config")
|
||||
fields.Set("storage_dir", "my_attach-2")
|
||||
w := postForm(e, http.MethodPost, "/admin/settings/upload", admin, token, fields)
|
||||
if w.Code != http.StatusFound || strings.Contains(w.Header().Get("Location"), "illegal_dir") {
|
||||
t.Fatalf("safe storage_dir: status = %d, location = %q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
var u models.UploadConfig
|
||||
if err := e.db.First(&u, 1).Error; err != nil {
|
||||
t.Fatalf("load upload config: %v", err)
|
||||
}
|
||||
if u.StorageDir != "my_attach-2" {
|
||||
t.Fatalf("persisted storage_dir = %q, want my_attach-2", u.StorageDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfilePasswordMinLength covers SECURITY_TODO #23 on the profile
|
||||
// password-change path.
|
||||
func TestProfilePasswordMinLength(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
// A 1-char password must be rejected and the old hash preserved.
|
||||
fields := url.Values{}
|
||||
fields.Set("current_password", "pw-alice")
|
||||
fields.Set("new_password", "a")
|
||||
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("short password: status = %d, want 302", w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/profile?error=pw_short" {
|
||||
t.Fatalf("short password: location = %q, want /profile?error=pw_short", loc)
|
||||
}
|
||||
var u models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
if !u.CheckPassword("pw-alice") {
|
||||
t.Fatal("old password no longer verifies after rejected change")
|
||||
}
|
||||
|
||||
// A 6-char password is accepted.
|
||||
fields = url.Values{}
|
||||
fields.Set("current_password", "pw-alice")
|
||||
fields.Set("new_password", "newpass6")
|
||||
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("valid password change: status = %d", w.Code)
|
||||
}
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("reload alice: %v", err)
|
||||
}
|
||||
if !u.CheckPassword("newpass6") || u.CheckPassword("pw-alice") {
|
||||
t.Fatal("password change did not take effect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileEmailValidation covers SECURITY_TODO #24 on the profile path.
|
||||
func TestProfileEmailValidation(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
fields := url.Values{}
|
||||
fields.Set("email", "not-an-email")
|
||||
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("invalid email: status = %d, want 302", w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/profile?error=email" {
|
||||
t.Fatalf("invalid email: location = %q, want /profile?error=email", loc)
|
||||
}
|
||||
var u models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
if u.Email != "" {
|
||||
t.Fatalf("invalid email was persisted: %q", u.Email)
|
||||
}
|
||||
|
||||
fields = url.Values{}
|
||||
fields.Set("email", "alice@example.com")
|
||||
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("valid email: status = %d, want 302", w.Code)
|
||||
}
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("reload alice: %v", err)
|
||||
}
|
||||
if u.Email != "alice@example.com" {
|
||||
t.Fatalf("valid email not persisted: %q", u.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminUserPasswordAndEmailEnforcement covers SECURITY_TODO #23/#24 on
|
||||
// the admin user-create/update paths.
|
||||
func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
// Create: short password rejected (no row created).
|
||||
fields := url.Values{}
|
||||
fields.Set("username", "charlie")
|
||||
fields.Set("password", "ab")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w := postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create with short password: status = %d, want 200 (re-render)", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "6 characters") {
|
||||
t.Fatal("short-password error message not rendered")
|
||||
}
|
||||
|
||||
// Create: invalid email rejected.
|
||||
fields = url.Values{}
|
||||
fields.Set("username", "charlie")
|
||||
fields.Set("password", "longenough")
|
||||
fields.Set("email", "abc")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create with invalid email: status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("charlie was created despite invalid input")
|
||||
}
|
||||
|
||||
// Create: valid row succeeds.
|
||||
fields = url.Values{}
|
||||
fields.Set("username", "charlie")
|
||||
fields.Set("password", "longenough")
|
||||
fields.Set("email", "charlie@example.com")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("create valid user: status = %d", w.Code)
|
||||
}
|
||||
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("charlie was not created")
|
||||
}
|
||||
|
||||
// Update (password reset path): short password rejected, hash unchanged.
|
||||
aliceID := userIDByUsername(t, e.db, "alice")
|
||||
fields = url.Values{}
|
||||
fields.Set("password", "x")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update with short password: status = %d, want 200", w.Code)
|
||||
}
|
||||
var u models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
if !u.CheckPassword("pw-alice") {
|
||||
t.Fatal("alice password changed by a rejected reset")
|
||||
}
|
||||
|
||||
// Update: invalid email rejected, old value preserved.
|
||||
fields = url.Values{}
|
||||
fields.Set("email", "bad")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update with invalid email: status = %d, want 200", w.Code)
|
||||
}
|
||||
e.db.Where("username = ?", "alice").First(&u)
|
||||
if u.Email != "" {
|
||||
t.Fatalf("invalid admin-set email persisted: %q", u.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRejectsInvalidEmail covers SECURITY_TODO #24 on registration.
|
||||
func TestRegisterRejectsInvalidEmail(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil {
|
||||
t.Fatalf("enable registration: %v", err)
|
||||
}
|
||||
|
||||
// Fetch the registration form for an anonymous CSRF token + session.
|
||||
req := httptest.NewRequest(http.MethodGet, "/register", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /register: status = %d", w.Code)
|
||||
}
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("register page did not render a CSRF token")
|
||||
}
|
||||
anonCookie := e.sessionCookie(w)
|
||||
|
||||
fields := url.Values{}
|
||||
fields.Set("username", "carol")
|
||||
fields.Set("password", "secret1")
|
||||
fields.Set("confirm_password", "secret1")
|
||||
fields.Set("email", "abc")
|
||||
w2 := postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("register invalid email: status = %d, want 302", w2.Code)
|
||||
}
|
||||
if loc := w2.Header().Get("Location"); loc != "/register?error=register_email_invalid" {
|
||||
t.Fatalf("register invalid email: location = %q", loc)
|
||||
}
|
||||
var count int64
|
||||
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("carol created with invalid email")
|
||||
}
|
||||
|
||||
fields.Set("email", "carol@example.com")
|
||||
w2 = postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
|
||||
if w2.Code != http.StatusFound {
|
||||
t.Fatalf("register valid email: status = %d, want 302", w2.Code)
|
||||
}
|
||||
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("carol not created")
|
||||
}
|
||||
}
|
||||
|
||||
// limiterEntryKey returns the rate-limiter key for a username by scanning the
|
||||
// tracked entries (the prepended client IP depends on the test transport).
|
||||
func limiterEntryKey(e *securityTestEnv, username string) string {
|
||||
for k := range e.limiter.entries {
|
||||
if strings.HasSuffix(k, "\x00"+username) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestLoginRateLimited covers SECURITY_TODO #10: repeated failures lock the
|
||||
// IP+username key, and a successful login resets it.
|
||||
func TestLoginRateLimited(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
loginAttempt := func(form url.Values) (*httptest.ResponseRecorder, string) {
|
||||
// Fresh anonymous session (and CSRF token) for each attempt.
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
anonCookie := e.sessionCookie(w)
|
||||
form.Set("_csrf", m[1])
|
||||
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], form)
|
||||
return w2, anonCookie
|
||||
}
|
||||
|
||||
bad := url.Values{"username": {"alice"}, "password": {"wrong-password"}}
|
||||
for i := 0; i < maxLoginFailures; i++ {
|
||||
w, _ := loginAttempt(bad)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("attempt %d: status = %d, want 302", i+1, w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/login?error=1" {
|
||||
t.Fatalf("attempt %d: location = %q, want /login?error=1", i+1, loc)
|
||||
}
|
||||
}
|
||||
|
||||
// The next attempt (even with the correct password) is locked.
|
||||
good := url.Values{"username": {"alice"}, "password": {"pw-alice"}}
|
||||
w, _ := loginAttempt(good)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("locked attempt: status = %d, want 302", w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/login?error=locked" {
|
||||
t.Fatalf("locked attempt: location = %q, want /login?error=locked", loc)
|
||||
}
|
||||
|
||||
// A different key (username) is unaffected.
|
||||
w, _ = loginAttempt(url.Values{"username": {"bob"}, "password": {"pw-bob"}})
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
|
||||
t.Fatalf("different user login during lock: status = %d, location = %q",
|
||||
w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// After reset the locked key works again.
|
||||
aliceKey := limiterEntryKey(e, "alice")
|
||||
if aliceKey == "" {
|
||||
t.Fatal("alice rate-limit entry not found")
|
||||
}
|
||||
e.limiter.Reset(aliceKey)
|
||||
w, _ = loginAttempt(good)
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
|
||||
t.Fatalf("login after reset: status = %d, location = %q",
|
||||
w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginTimingDoesNotRevealUser asserts the structural property of
|
||||
// SECURITY_TODO #25: an unknown username still incurs a bcrypt comparison
|
||||
// (dummy hash) and one failure is recorded, so the two branches are
|
||||
// indistinguishable in cost by design.
|
||||
func TestLoginTimingDoesNotRevealUser(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
anonCookie := e.sessionCookie(w)
|
||||
|
||||
fields := url.Values{"username": {"does-not-exist-31415"}, "password": {"anything"}}
|
||||
fields.Set("_csrf", m[1])
|
||||
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], fields)
|
||||
if w2.Code != http.StatusFound || w2.Header().Get("Location") != "/login?error=1" {
|
||||
t.Fatalf("unknown user: status = %d, location = %q", w2.Code, w2.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// The unknown user's key must be fail-counted (if the limiter is
|
||||
// shared), proving the branch went through Fail + dummy bcrypt path.
|
||||
if key := limiterEntryKey(e, "does-not-exist-31415"); key == "" {
|
||||
t.Fatal("unknown-user branch did not record a failure")
|
||||
} else if e.limiter.entries[key].failures != 1 {
|
||||
t.Fatalf("unknown-user failure count = %d, want 1", e.limiter.entries[key].failures)
|
||||
}
|
||||
}
|
||||
func TestSafeStorageDirNameAndValidators(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
dir string
|
||||
ok bool
|
||||
}{
|
||||
{"attachments", true},
|
||||
{"my_attach-2", true},
|
||||
{"A1-_", true},
|
||||
{"", false},
|
||||
{"../evil", false},
|
||||
{"foo/bar", false},
|
||||
{"a\\b", false},
|
||||
{"/abs", false},
|
||||
{"..", false},
|
||||
{"a b", false},
|
||||
{".hidden", false},
|
||||
} {
|
||||
if got := safeStorageDirName(tc.dir); got != tc.ok {
|
||||
t.Errorf("safeStorageDirName(%q) = %v, want %v", tc.dir, got, tc.ok)
|
||||
}
|
||||
}
|
||||
|
||||
if validatePassword("12345") {
|
||||
t.Error("validatePassword accepted 5 chars")
|
||||
}
|
||||
if !validatePassword("123456") {
|
||||
t.Error("validatePassword rejected 6 chars")
|
||||
}
|
||||
if !validateEmail("") || !validateEmail("user@example.com") {
|
||||
t.Error("validateEmail rejected empty or valid address")
|
||||
}
|
||||
if validateEmail("abc") || validateEmail("a@b@c") {
|
||||
t.Error("validateEmail accepted malformed address")
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc {
|
||||
data["Error"] = tr["profile_upload_disabled"]
|
||||
case "size":
|
||||
data["Error"] = fmt.Sprintf(tr["profile_upload_too_large"], c.Query("max"))
|
||||
case "pw_short":
|
||||
data["Error"] = tr["profile_password_short"]
|
||||
case "email":
|
||||
data["Error"] = tr["profile_email_invalid"]
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "profile", data)
|
||||
@@ -84,7 +88,14 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
if v := c.PostForm("gender"); v != "" {
|
||||
user.Gender = v
|
||||
}
|
||||
// SECURITY (#24): validate the email format before persisting
|
||||
// (dirty values would pollute Gravatar lookups). Empty is allowed.
|
||||
if v := c.PostForm("email"); v != "" {
|
||||
if !validateEmail(v) {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile?error=email")
|
||||
return
|
||||
}
|
||||
user.Email = v
|
||||
}
|
||||
if v := c.PostForm("birthday"); v != "" {
|
||||
@@ -172,6 +183,13 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
c.Redirect(http.StatusFound, "/profile?error=pw")
|
||||
return
|
||||
}
|
||||
// SECURITY (#23): enforce the same minimum length as registration;
|
||||
// resetting to a 1-char password would be trivially guessable.
|
||||
if !validatePassword(newPass) {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile?error=pw_short")
|
||||
return
|
||||
}
|
||||
if err := user.SetPassword(newPass); err != nil {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
|
||||
@@ -29,6 +29,7 @@ type securityTestEnv struct {
|
||||
router *gin.Engine
|
||||
db *gorm.DB
|
||||
storageDir string
|
||||
limiter *loginRateLimiter
|
||||
}
|
||||
|
||||
var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
|
||||
@@ -73,14 +74,17 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
}
|
||||
r.LoadHTMLGlob("../templates/**/*.html")
|
||||
store := cookie.NewStore([]byte("test-secret"))
|
||||
limiter := NewLoginLimiter()
|
||||
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("/login", Login(db, limiter))
|
||||
r.POST("/logout", Logout())
|
||||
r.POST("/article/:slug/comments", PostComment(db))
|
||||
r.GET("/register", RegisterPage(db))
|
||||
r.POST("/register", Register(db))
|
||||
|
||||
protected := r.Group("/my", middleware.AuthRequired(db))
|
||||
{
|
||||
@@ -109,12 +113,13 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
// Admin user-management routes (SQL-injection regression coverage, #19).
|
||||
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.POST("/users/new", UserCreate(db))
|
||||
admin.GET("/users/:id/edit", UserEditPage(db))
|
||||
admin.POST("/users/:id/edit", UserUpdate(db))
|
||||
admin.POST("/users/:id/delete", UserDelete(db))
|
||||
}
|
||||
|
||||
return &securityTestEnv{router: r, db: db, storageDir: storageDir}
|
||||
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter}
|
||||
}
|
||||
|
||||
func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User {
|
||||
|
||||
+34
-2
@@ -228,6 +228,9 @@ func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
if msg := c.Query("error"); msg == "dangerous_ext" {
|
||||
data["Error"] = tr["settings_upload_dangerous_ext"]
|
||||
}
|
||||
if c.Query("error") == "illegal_dir" {
|
||||
data["Error"] = tr["settings_upload_illegal_dir"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_upload", data)
|
||||
}
|
||||
}
|
||||
@@ -238,7 +241,11 @@ func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
redirect := "/admin/settings/upload?saved=1"
|
||||
switch c.PostForm("action") {
|
||||
case "save_config":
|
||||
saveUploadConfig(db, c)
|
||||
if !saveUploadConfig(db, c) {
|
||||
// SECURITY (#22): an illegal storage_dir was rejected;
|
||||
// report and keep the previous value.
|
||||
redirect = "/admin/settings/upload?error=illegal_dir"
|
||||
}
|
||||
case "add_type":
|
||||
if addUploadFileType(db, c) {
|
||||
redirect = "/admin/settings/upload?error=dangerous_ext"
|
||||
@@ -255,7 +262,28 @@ func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func saveUploadConfig(db *gorm.DB, c *gin.Context) {
|
||||
// safeStorageDirName reports whether s is a single safe path segment: no
|
||||
// separators, no traversal, no absolute paths. storage_dir must stay inside
|
||||
// the storage root (SECURITY_TODO #22).
|
||||
func safeStorageDirName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// saveUploadConfig persists the upload policy. It returns false and leaves
|
||||
// the stored value untouched when the submitted storage_dir is unsafe
|
||||
// (SECURITY_TODO #22), so a misconfigured admin cannot redirect attachment
|
||||
// writes outside the storage root.
|
||||
func saveUploadConfig(db *gorm.DB, c *gin.Context) bool {
|
||||
var u models.UploadConfig
|
||||
if err := db.First(&u, 1).Error; err != nil {
|
||||
u = models.UploadConfig{ID: 1}
|
||||
@@ -266,10 +294,14 @@ func saveUploadConfig(db *gorm.DB, c *gin.Context) {
|
||||
u.DefaultMaxSize = models.DefaultUploadMaxSize
|
||||
}
|
||||
if dir := strings.TrimSpace(c.PostForm("storage_dir")); dir != "" {
|
||||
if !safeStorageDirName(dir) {
|
||||
return false
|
||||
}
|
||||
u.StorageDir = dir
|
||||
}
|
||||
u.UpdatedBy = userIDFromSession(c)
|
||||
db.Save(&u)
|
||||
return true
|
||||
}
|
||||
|
||||
// dangerousUploadExtensions are never accepted as upload file types: files of
|
||||
|
||||
@@ -45,6 +45,7 @@ var translations = map[Lang]map[string]string{
|
||||
"login_ph_pass": "Enter your password",
|
||||
"login_submit": "Sign In",
|
||||
"login_error": "Invalid username or password.",
|
||||
"login_locked": "Too many failed attempts. Please try again in 15 minutes.",
|
||||
"login_required": "Please fill in all fields.",
|
||||
"login_no_account": "Don't have an account?",
|
||||
"login_register_link": "Register",
|
||||
@@ -73,6 +74,7 @@ var translations = map[Lang]map[string]string{
|
||||
"register_username_length": "Username must be 3-32 characters.",
|
||||
"register_password_length": "Password must be at least 6 characters.",
|
||||
"register_password_mismatch": "Passwords do not match.",
|
||||
"register_email_invalid": "Please enter a valid email address.",
|
||||
"register_error": "Registration failed. Please try again.",
|
||||
|
||||
// Settings
|
||||
@@ -118,6 +120,8 @@ var translations = map[Lang]map[string]string{
|
||||
"profile_save": "Save Changes",
|
||||
"profile_saved": "Profile updated.",
|
||||
"profile_wrong_password": "Current password is incorrect.",
|
||||
"profile_password_short": "New password must be at least 6 characters.",
|
||||
"profile_email_invalid": "Please enter a valid email address.",
|
||||
"profile_upload_invalid": "File type not allowed.",
|
||||
"profile_upload_disabled": "Uploads are currently disabled.",
|
||||
"profile_upload_too_large": "File is too large. Limit: %s",
|
||||
@@ -255,6 +259,7 @@ var translations = map[Lang]map[string]string{
|
||||
"settings_upload_title": "Upload Settings",
|
||||
"settings_upload_desc": "Attachment upload policy and permitted file types.",
|
||||
"settings_upload_dangerous_ext": "This extension is not allowed: files of this type can execute active content in the site's origin.",
|
||||
"settings_upload_illegal_dir": "Storage sub-directory is not allowed: use a single segment of letters, digits, '_' or '-' only.",
|
||||
"settings_uploads_enabled":"Enable attachments",
|
||||
"settings_default_size": "Default max size (MB)",
|
||||
"settings_storage_dir": "Storage sub-directory",
|
||||
@@ -391,6 +396,8 @@ var translations = map[Lang]map[string]string{
|
||||
"user_empty": "No users.",
|
||||
"user_username_required": "Username is required.",
|
||||
"user_password_required": "Password is required.",
|
||||
"user_password_short": "Password must be at least 6 characters.",
|
||||
"user_email_invalid": "Please enter a valid email address.",
|
||||
"user_username_exists": "Username already exists.",
|
||||
"user_cannot_delete_self": "You cannot delete your own account.",
|
||||
"user_cannot_disable_self": "You cannot disable or lock your own account.",
|
||||
@@ -466,6 +473,7 @@ var translations = map[Lang]map[string]string{
|
||||
"login_ph_pass": "请输入密码",
|
||||
"login_submit": "登录",
|
||||
"login_error": "用户名或密码错误。",
|
||||
"login_locked": "尝试次数过多,请15分钟后再试。",
|
||||
"login_required": "请填写所有字段。",
|
||||
"login_no_account": "还没有账号?",
|
||||
"login_register_link": "注册",
|
||||
@@ -494,6 +502,7 @@ var translations = map[Lang]map[string]string{
|
||||
"register_username_length": "用户名必须是3-32个字符。",
|
||||
"register_password_length": "密码至少需要6个字符。",
|
||||
"register_password_mismatch": "两次输入的密码不一致。",
|
||||
"register_email_invalid": "请输入有效的邮箱地址。",
|
||||
"register_error": "注册失败,请重试。",
|
||||
|
||||
// 平台设置
|
||||
@@ -537,6 +546,8 @@ var translations = map[Lang]map[string]string{
|
||||
"profile_save": "保存修改",
|
||||
"profile_saved": "个人信息已更新。",
|
||||
"profile_wrong_password": "当前密码错误。",
|
||||
"profile_password_short": "新密码至少需要6个字符。",
|
||||
"profile_email_invalid": "请输入有效的邮箱地址。",
|
||||
"profile_upload_invalid": "不允许的文件类型。",
|
||||
"profile_upload_disabled": "上传功能已关闭。",
|
||||
"profile_upload_too_large": "文件过大。限制:%s",
|
||||
@@ -674,6 +685,7 @@ var translations = map[Lang]map[string]string{
|
||||
"settings_upload_title": "上传设置",
|
||||
"settings_upload_desc": "附件上传策略与允许的文件类型。",
|
||||
"settings_upload_dangerous_ext": "不允许该扩展名:此类文件可在站点同源执行活动内容。",
|
||||
"settings_upload_illegal_dir": "存储子目录不合法:只能使用字母、数字、'_' 或 '-' 的单个路径段。",
|
||||
"settings_uploads_enabled":"启用附件上传",
|
||||
"settings_default_size": "默认最大大小(MB)",
|
||||
"settings_storage_dir": "存储子目录",
|
||||
@@ -810,6 +822,8 @@ var translations = map[Lang]map[string]string{
|
||||
"user_empty": "暂无用户。",
|
||||
"user_username_required": "请填写用户名。",
|
||||
"user_password_required": "请填写密码。",
|
||||
"user_password_short": "密码至少需要6个字符。",
|
||||
"user_email_invalid": "请输入有效的邮箱地址。",
|
||||
"user_username_exists": "用户名已存在。",
|
||||
"user_cannot_delete_self": "不能删除自己的账号。",
|
||||
"user_cannot_disable_self": "不能禁用或锁定自己的账号。",
|
||||
|
||||
+7
-1
@@ -77,7 +77,7 @@ User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/${BINARY_NAME} -config ${CONFIG_DIR}/config.yaml
|
||||
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chmod 666 ${SOCKET_DIR}/web.sock'
|
||||
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chown ${SERVICE_USER}:${SERVICE_USER} ${SOCKET_DIR}/web.sock; chmod 660 ${SOCKET_DIR}/web.sock'
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
NoNewPrivileges=true
|
||||
@@ -97,3 +97,9 @@ systemctl restart "${SERVICE_NAME}"
|
||||
|
||||
echo "部署完成,服务状态:"
|
||||
systemctl --no-pager --full status "${SERVICE_NAME}"
|
||||
|
||||
echo ""
|
||||
echo "重要提示:unix socket 权限现为 660(组: ${SERVICE_USER}),"
|
||||
echo "请将反向代理(caddy/nginx)运行用户加入 ${SERVICE_USER} 组:"
|
||||
echo " sudo usermod -aG ${SERVICE_USER} <proxy_user>"
|
||||
echo "然后重启代理服务,否则代理无法读取 socket(本机其他用户也无法再直连)。"
|
||||
@@ -46,6 +46,9 @@ func main() {
|
||||
|
||||
// 3. Create session store (cookie-based).
|
||||
store := cookie.NewStore([]byte(cfg.Secret))
|
||||
// Login rate limiter (SECURITY_TODO #10): per IP+username failures, a
|
||||
// libcurl/wordlist attacker cannot hammer the login endpoint.
|
||||
loginLimiter := handlers.NewLoginLimiter()
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400, // 24 hours
|
||||
@@ -117,7 +120,7 @@ func main() {
|
||||
router.GET("/rss", handlers.RSSFeed(db))
|
||||
router.GET("/feed", handlers.RSSFeed(db))
|
||||
router.GET("/login", handlers.LoginPage())
|
||||
router.POST("/login", handlers.Login(db))
|
||||
router.POST("/login", handlers.Login(db, loginLimiter))
|
||||
router.GET("/register", handlers.RegisterPage(db))
|
||||
router.POST("/register", handlers.Register(db))
|
||||
router.POST("/logout", handlers.Logout())
|
||||
|
||||
@@ -5,14 +5,12 @@ 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).
|
||||
// <style> blocks (Go html/template is the XSS defense for those). All
|
||||
// third-party assets are vendored locally (SECURITY_TODO #9), so the policy
|
||||
// allows no other origins for scripts or styles.
|
||||
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; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https: http:; " +
|
||||
"font-src 'self' data: https:; " +
|
||||
"connect-src 'self'; " +
|
||||
|
||||
+25
-3
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -17,6 +18,24 @@ import (
|
||||
// DB is the global database connection, initialized by InitDB.
|
||||
var DB *gorm.DB
|
||||
|
||||
// adminPasswordAlphabet avoids visually ambiguous characters (no l, I, O, 0,
|
||||
// 1) and is used to generate the first-run admin password.
|
||||
const adminPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" + "^$*+?%"
|
||||
|
||||
// randomAdminPassword returns a crypto-random 16-character first-run admin
|
||||
// password (SECURITY_TODO #12: no more hardcoded admin/admin).
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Fatalf("Failed to generate admin password: %v", err)
|
||||
}
|
||||
out := make([]byte, len(b))
|
||||
for i, v := range b {
|
||||
out[i] = adminPasswordAlphabet[int(v)%len(adminPasswordAlphabet)]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// InitDB opens the database connection, runs migrations, and seeds the admin user.
|
||||
func InitDB(cfg *config.Config) *gorm.DB {
|
||||
// Ensure the storage path exists.
|
||||
@@ -66,17 +85,20 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
Status: StatusNormal,
|
||||
Role: RoleAdmin,
|
||||
}
|
||||
if err := admin.SetPassword("admin"); err != nil {
|
||||
adminPassword := randomAdminPassword()
|
||||
if err := admin.SetPassword(adminPassword); err != nil {
|
||||
log.Fatalf("Failed to hash admin password: %v", err)
|
||||
}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
log.Fatalf("Failed to create admin user: %v", err)
|
||||
}
|
||||
// SECURITY_TODO #12: the first-run password is crypto-random and
|
||||
// printed exactly once — copy it now; it cannot be recovered later.
|
||||
log.Println("==============================================")
|
||||
log.Println(" First run: created default admin user.")
|
||||
log.Println(" Username: admin")
|
||||
log.Println(" Password: admin")
|
||||
log.Println(" Please change this password immediately!")
|
||||
log.Println(" Password: " + adminPassword)
|
||||
log.Println(" This password is shown ONCE. Change it after login!")
|
||||
log.Println("==============================================")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRandomAdminPassword covers SECURITY_TODO #12: the first-run admin
|
||||
// password comes from the ambiguous-safe alphabet, has fixed length, and
|
||||
// differs between generations.
|
||||
func TestRandomAdminPassword(t *testing.T) {
|
||||
pw := randomAdminPassword()
|
||||
if len(pw) != 16 {
|
||||
t.Fatalf("password length = %d, want 16", len(pw))
|
||||
}
|
||||
for _, c := range pw {
|
||||
if !strings.ContainsRune(adminPasswordAlphabet, c) {
|
||||
t.Fatalf("password contains rune %q outside alphabet", c)
|
||||
}
|
||||
}
|
||||
if pw == randomAdminPassword() {
|
||||
t.Fatal("two generated passwords are identical")
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Tailwind CSS from the HTML templates into static/css/app.css.
|
||||
#
|
||||
# The production build does NOT use cdn.tailwindcss.com (the dev runtime):
|
||||
# it is a JS script executed in the browser, which browsers cannot pin with
|
||||
# SRI and which would keep a third-party origin in our CSP
|
||||
# (SECURITY_TODO #9). This script produces a static stylesheet instead.
|
||||
#
|
||||
# Running it requires Node >= 18 with npx available. The generated
|
||||
# static/css/app.css MUST be committed so deployments need no toolchain
|
||||
# (static assets are go:embed'd into the binary).
|
||||
#
|
||||
# Usage: ./scripts/build_tailwind.sh [version]
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-3.4.17}"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(dirname "${HERE}")"
|
||||
|
||||
echo "==> Building Tailwind CSS ${VERSION} (offline static build)"
|
||||
cd "${ROOT}"
|
||||
# Content sources: templates for static markup, Go sources for class strings
|
||||
# assembled in handlers (e.g. status/role badges), plus the embedding main.
|
||||
npx --yes "tailwindcss@${VERSION}" \
|
||||
-i ./static/css/input.css \
|
||||
-o ./static/css/app.css \
|
||||
--content "./templates/**/*.html" "./handlers/**/*.go" "./middleware/**/*.go" "./main.go"
|
||||
echo "==> Done: static/css/app.css"
|
||||
echo " (commit this file; it is embedded into the binary via go:embed)"
|
||||
+1915
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/*!
|
||||
* Cropper.js v1.6.2
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2024-04-21T07:43:02.731Z
|
||||
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||
Vendored
+10
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||
Vendored
+1262
File diff suppressed because one or more lines are too long
Vendored
+69
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
@@ -14,11 +14,13 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<!-- All third-party assets are vendored locally (static/vendor, embedded
|
||||
via go:embed) — no external CDN origins, see SECURITY_TODO #9. -->
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=1">
|
||||
<link rel="stylesheet" href="/static/vendor/cropper.min.css?v=1">
|
||||
<script src="/static/vendor/cropper.min.js?v=1"></script>
|
||||
<link rel="stylesheet" href="/static/vendor/easymde.min.css?v=1">
|
||||
<script src="/static/vendor/easymde.min.js?v=1"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<!-- Navigation -->
|
||||
@@ -117,11 +119,11 @@
|
||||
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/vendor/github.min.css?v=1">
|
||||
<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/vendor/marked.min.js?v=1"></script>
|
||||
<script src="/static/vendor/purify.min.js?v=1"></script>
|
||||
<script src="/static/vendor/highlight.min.js?v=1"></script>
|
||||
<script src="/static/js/markdown.js?v=2"></script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user