chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
[app]
|
||||
production = true
|
||||
title = "NekoBox"
|
||||
icp = ""
|
||||
default_lang = "" ; 站点默认语言:zh-CN / zh-TW / en / ja,留空默认简体中文(访客未设置语言时的兜底)
|
||||
uptrace_dsn = ""
|
||||
qiniu_access_key = ""
|
||||
qiniu_access_secret = ""
|
||||
aliyun_access_key = ""
|
||||
aliyun_access_key_secret = ""
|
||||
share_background = "https://your-default-background.jpg" ; 默认分享图背景图链接(1280x720)
|
||||
font_path = "static/fonts/NotoSansSC-Regular.otf" ; 中文字体文件路径(相对项目根目录)
|
||||
|
||||
[security]
|
||||
enable_text_censor = true
|
||||
ip_blacklist = ; 逗号分隔的IP黑名单,支持精确IP(如1.2.3.4)和CIDR(如1.2.3.0/24),留空不启用
|
||||
ip_blacklist_redirect_url = ; 黑名单IP访问时强制跳转到该URL
|
||||
trust_proxy = false ; 是否信任反向代理设置的 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 头。
|
||||
; 仅当站点部署在可信反向代理(如 nginx/Cloudflare)之后且代理已覆盖这些头时设为 true;
|
||||
; 否则保持 false,防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
; ⚠️ 若站点在 Cloudflare 后且需使用下面的自动封禁,必须设为 true,
|
||||
; 否则解析到的将是 Cloudflare 边缘 IP,自动封禁会误伤所有访客。
|
||||
; ⚠️ 开启 trust_proxy 时,必须配合「Cloudflare Tunnel / Authenticated Origin Pull /
|
||||
; 防火墙仅放行 Cloudflare / EdgeOne 回源网段」锁定源站,否则攻击者可直连源站并伪造
|
||||
; CF-Connecting-IP / EO-Client-IP 头。无需维护 CDN 庞大 CIDR 清单。
|
||||
;
|
||||
; —— 双 CDN(境内腾讯 EdgeOne + 境外 Cloudflare)场景 ——
|
||||
; EdgeOne 回源会用 EO-Client-IP 携带客户端真实 IP(等效 CF-Connecting-IP),
|
||||
; 本程序在 trust_proxy=true 时已同时信任这两个权威头。
|
||||
; 两家 CDN 并存时必须:① 防火墙仅放行两家回源网段;② 在各家 CDN 的规则引擎中
|
||||
; 删除回源请求里「对方 CDN」的权威头(经 EdgeOne 回源删 CF-Connecting-IP、
|
||||
; 经 Cloudflare 回源删 EO-Client-IP),否则攻击者可借道另一家伪造客户端身份,
|
||||
; 绕过限流 / 自动封禁 / 单 IP 投票限制。详细步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||||
|
||||
trusted_proxies =
|
||||
; 逗号分隔的「你自己的」反向代理/负载均衡 IP 或 CIDR(如 127.0.0.1、
|
||||
; 10.0.0.0/8)。仅当 TCP 对端落在该清单内时才解析 X-Forwarded-For / X-Real-IP。
|
||||
; 留空(默认)则这两个头永不被信任,仅采用 CDN 权威头 CF-Connecting-IP /
|
||||
; EO-Client-IP(需源站已锁定)。
|
||||
; 注意:此清单用于你自己的基础设施,不是 CDN 的全量 CIDR 清单。
|
||||
enable_auto_ban = true ; 自动封禁(fail2ban 风格):检测到批量扫描/探测后临时封禁来源 IP
|
||||
ban_track_notfound = false ; 是否把「未注册路径的 404」计入封禁统计(默认 false)。正常业务
|
||||
; (域名/题目不存在等均走 302 业务跳转,不产生 404 计数)一律放行,
|
||||
; 仅命中后台扫描特征路径(/admin、/.env 等)才参与统计封禁,避免
|
||||
; 共享出口(CDN 边缘 / CGNAT)下正常用户被 404 探测连坐误伤。
|
||||
; 如需恢复对纯 404 字典扫描的封禁再置 true。
|
||||
ban_threshold_sensitive = 10 ; 统计窗口内命中敏感路径(/admin、/.env、/wp-admin 等)的次数阈值
|
||||
ban_threshold_404 = 20 ; 统计窗口内返回 404 的次数阈值(仅 ban_track_notfound = true 时生效)
|
||||
ban_window_seconds = 60 ; 统计窗口时长(秒)
|
||||
ban_duration_minutes = 15 ; 临时封禁时长(分钟),到期自动解除,重启即清零
|
||||
security_alert_email = ; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送
|
||||
|
||||
[server]
|
||||
port = 80
|
||||
; 绑定监听地址(含端口)。Cloudflare Tunnel 部署时强烈建议设为 127.0.0.1:<port>,
|
||||
; 使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连伪造 CF-Connecting-IP。
|
||||
; 留空(默认)则监听所有网卡 ":port"。
|
||||
listen =
|
||||
salt = ""
|
||||
# ⚠️ 必填:密码哈希盐,留空会导致启动失败(已在 conf.Init 强制校验)
|
||||
xsrf_key = ""
|
||||
# ⚠️ 必填:CSRF token 密钥,留空会导致启动失败(已强制校验)
|
||||
xsrf_expire = 3600
|
||||
|
||||
[database]
|
||||
type = "postgres"
|
||||
user = "nekobox"
|
||||
password = ""
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
name = "nekobox"
|
||||
schema = "public"
|
||||
# 仅在已确认需要自动清理重复 email/domain 时开启;默认关闭,避免启动时改写账号数据。
|
||||
dedupe_unique_fields = false
|
||||
|
||||
[redis]
|
||||
addr = "127.0.0.1:6379"
|
||||
password = ""
|
||||
|
||||
[recaptcha]
|
||||
captcha_type = recaptcha ; recaptcha(默认), turnstile, math(本地算数验证码,国内访问友好)
|
||||
domain = "https://www.recaptcha.net"
|
||||
site_key = ""
|
||||
server_key = ""
|
||||
turnstile_style = false
|
||||
|
||||
[upload]
|
||||
default_avatar = ""
|
||||
default_background = ""
|
||||
|
||||
# User avatar and background.
|
||||
aliyun_endpoint = ""
|
||||
aliyun_access_id = ""
|
||||
aliyun_access_secret = ""
|
||||
aliyun_bucket = ""
|
||||
aliyun_bucket_cdn_host = ""
|
||||
image_endpoint = ""
|
||||
image_access_id = ""
|
||||
image_access_secret =""
|
||||
image_bucket = ""
|
||||
image_bucket_cdn_host = ""
|
||||
|
||||
# User uploaded images.
|
||||
image_endpoint = ""
|
||||
image_access_id = ""
|
||||
image_access_secret = ""
|
||||
image_bucket = ""
|
||||
image_bucket_cdn_host = ""
|
||||
|
||||
[mail]
|
||||
account = ""
|
||||
password = ""
|
||||
port = 465
|
||||
smtp = ""
|
||||
|
||||
[limits]
|
||||
; ── 全站内容 / 数量上限(运行时配置,修改后重启生效)──────────────────────
|
||||
; 缺失本段或个别键时按内置默认值运行。长度统一按字符(rune)计数。
|
||||
; 键留空或填 0/负数时自动回退内置默认值,不会导致启动失败,但也不会按你的意图生效,
|
||||
; 请按需填写正整数。本文件启用 IgnoreInlineComment,行内分号注释会被并入配置值,
|
||||
; 因此下面注释一律独占整行、勿写行内注释。
|
||||
|
||||
; ── 标签与屏蔽词(账号设置页)──────────────────────────────────────────────
|
||||
; 最多标签个数
|
||||
max_tags = 5
|
||||
; 单个标签最大字符数
|
||||
max_tag_len = 5
|
||||
; 最多屏蔽词个数
|
||||
max_block_words = 10
|
||||
; 单个屏蔽词最大字符数
|
||||
max_block_word_len = 10
|
||||
|
||||
; ── 投票 ────────────────────────────────────────────────────────────────────
|
||||
; 参与者填写的「自定义补充选项」(其他/自定义)最大字符数
|
||||
custom_answer_len = 200
|
||||
; 站长创建投票时单个「选项」文本最大字符数(预设选项)
|
||||
vote_option_len = 300
|
||||
|
||||
; ── 提问 / 回答 / 追问 ─────────────────────────────────────────────────────
|
||||
; 提问正文最大字符数(访客提交的问题内容)
|
||||
question_content_len = 1000
|
||||
; 回答正文最大字符数(箱主对提问的回答,及对追问的回复,共用)
|
||||
answer_content_len = 2000
|
||||
; 追问正文最大字符数(提问者被回答后继续追问的内容)
|
||||
followup_content_len = 2000
|
||||
|
||||
; ── 私聊 ────────────────────────────────────────────────────────────────────
|
||||
; 单条消息(发送/回复)最大字符数
|
||||
chat_message_len = 2000
|
||||
; 每个私聊会话的消息条数上限(系统硬顶)。用户只能在 1..该值 之间设置自己的
|
||||
; 会话条数上限;新注册用户的默认值即该硬顶。
|
||||
chat_max_messages = 200
|
||||
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
|
||||
daily_message_limit_max = 999
|
||||
@@ -0,0 +1,266 @@
|
||||
// Package autoban 提供 fail2ban 风格的自动临时封禁:检测批量扫描/探测特征后
|
||||
// 临时封禁来源 IP,并可回调发送告警。纯标准库实现,便于独立测试与复用。
|
||||
package autoban
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 探测事件类型
|
||||
const (
|
||||
KindNone = 0 // 无探测特征
|
||||
KindSensitive = 1 // 命中敏感路径
|
||||
KindNotFound = 2 // 404 响应(未命中敏感路径)
|
||||
)
|
||||
|
||||
// 默认阈值/窗口(Options 未配置或为 0 时兜底)。
|
||||
const (
|
||||
DefaultWindowSeconds = 60
|
||||
DefaultDurationMinutes = 15
|
||||
DefaultThresholdSensitive = 10
|
||||
DefaultThreshold404 = 20
|
||||
pathSampleMax = 6
|
||||
)
|
||||
|
||||
// Options 自动封禁配置。
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
ThresholdSensitive int // 窗口内敏感路径命中次数
|
||||
Threshold404 int // 窗口内 404 次数
|
||||
Window time.Duration // 统计窗口
|
||||
Duration time.Duration // 封禁时长
|
||||
AlertEmail string // 告警收件人(留空不发送)
|
||||
SendAlert func(ip string, paths []string, durationMinutes int) error // 告警发送函数(nil 则不发送)
|
||||
}
|
||||
|
||||
// sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,前缀匹配)。
|
||||
var sensitivePaths = []string{
|
||||
"/admin", "/wp-admin", "/wp-login.php", "/wp-content", "/wordpress",
|
||||
"/administrator", "/manager", "/cpanel", "/plesk",
|
||||
"/.env", "/.git", "/.aws", "/.ssh", "/.svn", "/.hg", "/.htaccess",
|
||||
"/config", "/configuration", "/backup", "/database", "/db",
|
||||
"/actuator", "/swagger", "/api-docs", "/openapi", "/v2/api-docs",
|
||||
"/phpmyadmin", "/pma", "/mysql", "/server-status", "/server-info",
|
||||
"/shell", "/webshell", "/cmd", "/eval", "/hack", "/shell.php",
|
||||
"/login.php", "/admin.php", "/index.php", "/console", "/vendor",
|
||||
}
|
||||
|
||||
type probeEvent struct {
|
||||
at time.Time
|
||||
kind int
|
||||
}
|
||||
|
||||
type probeStat struct {
|
||||
events []probeEvent
|
||||
sensitive int
|
||||
notFound int
|
||||
paths []string // 命中路径样本(告警用)
|
||||
}
|
||||
|
||||
// Banner 自动封禁器。方法并发安全。
|
||||
type Banner struct {
|
||||
mu sync.Mutex
|
||||
|
||||
probes map[string]*probeStat
|
||||
banned map[string]time.Time
|
||||
alerted map[string]bool
|
||||
|
||||
thresholdSensitive int
|
||||
threshold404 int
|
||||
window time.Duration
|
||||
duration time.Duration
|
||||
alertEmail string
|
||||
sendAlert func(ip string, paths []string, durationMinutes int) error
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// New 按配置创建封禁器并注册全局清理。
|
||||
func New(opts Options) *Banner {
|
||||
window := opts.Window
|
||||
if window <= 0 {
|
||||
window = DefaultWindowSeconds * time.Second
|
||||
}
|
||||
duration := opts.Duration
|
||||
if duration <= 0 {
|
||||
duration = DefaultDurationMinutes * time.Minute
|
||||
}
|
||||
sensitive := opts.ThresholdSensitive
|
||||
if sensitive <= 0 {
|
||||
sensitive = DefaultThresholdSensitive
|
||||
}
|
||||
notFound := opts.Threshold404
|
||||
if notFound <= 0 {
|
||||
notFound = DefaultThreshold404
|
||||
}
|
||||
b := &Banner{
|
||||
probes: make(map[string]*probeStat),
|
||||
banned: make(map[string]time.Time),
|
||||
alerted: make(map[string]bool),
|
||||
thresholdSensitive: sensitive,
|
||||
threshold404: notFound,
|
||||
window: window,
|
||||
duration: duration,
|
||||
alertEmail: strings.TrimSpace(opts.AlertEmail),
|
||||
sendAlert: opts.SendAlert,
|
||||
enabled: opts.Enabled,
|
||||
}
|
||||
register(b)
|
||||
return b
|
||||
}
|
||||
|
||||
// Record 记录一次探测事件,达到阈值时封禁 IP 并触发一次告警。
|
||||
func (b *Banner) Record(ip, path string, kind int) {
|
||||
if !b.enabled || ip == "" || kind == KindNone {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-b.window)
|
||||
|
||||
st := b.probes[ip]
|
||||
if st == nil {
|
||||
st = &probeStat{}
|
||||
b.probes[ip] = st
|
||||
}
|
||||
|
||||
// 滚动清理过期事件并重算计数
|
||||
valid := st.events[:0]
|
||||
sensitive, notFound := 0, 0
|
||||
for _, e := range st.events {
|
||||
if e.at.After(cutoff) {
|
||||
valid = append(valid, e)
|
||||
if e.kind == KindSensitive {
|
||||
sensitive++
|
||||
} else if e.kind == KindNotFound {
|
||||
notFound++
|
||||
}
|
||||
}
|
||||
}
|
||||
valid = append(valid, probeEvent{at: now, kind: kind})
|
||||
if kind == KindSensitive {
|
||||
sensitive++
|
||||
} else {
|
||||
notFound++
|
||||
}
|
||||
st.events = valid
|
||||
st.sensitive = sensitive
|
||||
st.notFound = notFound
|
||||
if len(st.paths) < pathSampleMax && path != "" {
|
||||
st.paths = append(st.paths, path)
|
||||
}
|
||||
|
||||
if sensitive < b.thresholdSensitive && notFound < b.threshold404 {
|
||||
return
|
||||
}
|
||||
|
||||
// 已达阈值:封禁(已在封禁中则跳过)
|
||||
if until, ok := b.banned[ip]; ok && until.After(now) {
|
||||
return
|
||||
}
|
||||
until := now.Add(b.duration)
|
||||
b.banned[ip] = until
|
||||
|
||||
// 告警(每 IP 仅一次)
|
||||
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
|
||||
b.alerted[ip] = true
|
||||
sample := append([]string{}, st.paths...)
|
||||
dur := int(b.duration.Minutes())
|
||||
go func() {
|
||||
_ = b.sendAlert(ip, sample, dur)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// IsBanned 判断 IP 是否处于封禁期;到期自动解除。
|
||||
func (b *Banner) IsBanned(ip string) bool {
|
||||
if !b.enabled || ip == "" {
|
||||
return false
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if until, ok := b.banned[ip]; ok {
|
||||
if until.After(time.Now()) {
|
||||
return true
|
||||
}
|
||||
delete(b.banned, ip)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSensitivePath 判断路径是否命中敏感探测特征(忽略大小写,完整段前缀匹配)。
|
||||
func IsSensitivePath(p string) bool {
|
||||
lower := strings.ToLower(p)
|
||||
for _, s := range sensitivePaths {
|
||||
if lower == s || strings.HasPrefix(lower, s+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- 全局注册与清理 ----------
|
||||
|
||||
var (
|
||||
bannersMu sync.Mutex
|
||||
banners = make(map[*Banner]struct{})
|
||||
bannersOnce sync.Once
|
||||
)
|
||||
|
||||
func register(b *Banner) {
|
||||
bannersMu.Lock()
|
||||
banners[b] = struct{}{}
|
||||
bannersMu.Unlock()
|
||||
|
||||
bannersOnce.Do(func() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(2 * time.Minute)
|
||||
bannersMu.Lock()
|
||||
for b := range banners {
|
||||
b.cleanup()
|
||||
}
|
||||
bannersMu.Unlock()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// cleanup 定期清理过期探测记录与已到期封禁(防止内存膨胀)。
|
||||
func (b *Banner) cleanup() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-b.window)
|
||||
|
||||
for ip, st := range b.probes {
|
||||
if len(st.events) == 0 || st.events[len(st.events)-1].at.Before(cutoff) {
|
||||
delete(b.probes, ip)
|
||||
continue
|
||||
}
|
||||
valid := st.events[:0]
|
||||
sensitive, notFound := 0, 0
|
||||
for _, e := range st.events {
|
||||
if e.at.After(cutoff) {
|
||||
valid = append(valid, e)
|
||||
if e.kind == KindSensitive {
|
||||
sensitive++
|
||||
} else if e.kind == KindNotFound {
|
||||
notFound++
|
||||
}
|
||||
}
|
||||
}
|
||||
st.events = valid
|
||||
st.sensitive = sensitive
|
||||
st.notFound = notFound
|
||||
}
|
||||
for ip, until := range b.banned {
|
||||
if !until.After(now) {
|
||||
delete(b.banned, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package autoban
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestBanner(thresholdSensitive, threshold404 int, window, duration time.Duration, alertEmail string, alertCalls *int64) *Banner {
|
||||
return New(Options{
|
||||
Enabled: true,
|
||||
ThresholdSensitive: thresholdSensitive,
|
||||
Threshold404: threshold404,
|
||||
Window: window,
|
||||
Duration: duration,
|
||||
AlertEmail: alertEmail,
|
||||
SendAlert: func(ip string, paths []string, dur int) error {
|
||||
if alertCalls != nil {
|
||||
atomic.AddInt64(alertCalls, 1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSensitiveThreshold(t *testing.T) {
|
||||
b := newTestBanner(2, 5, time.Minute, time.Minute, "", nil)
|
||||
b.Record("1.2.3.4", "/admin", KindSensitive)
|
||||
if b.IsBanned("1.2.3.4") {
|
||||
t.Fatal("should not be banned before reaching threshold")
|
||||
}
|
||||
b.Record("1.2.3.4", "/.env", KindSensitive)
|
||||
if !b.IsBanned("1.2.3.4") {
|
||||
t.Fatal("expected ban after reaching sensitive threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundThreshold(t *testing.T) {
|
||||
b := newTestBanner(10, 3, time.Minute, time.Minute, "", nil)
|
||||
for i := 0; i < 3; i++ {
|
||||
b.Record("5.6.7.8", "/random-"+string(rune('a'+i)), KindNotFound)
|
||||
}
|
||||
if !b.IsBanned("5.6.7.8") {
|
||||
t.Fatal("expected ban after reaching 404 threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowExpiry(t *testing.T) {
|
||||
b := newTestBanner(2, 5, 50*time.Millisecond, time.Minute, "", nil)
|
||||
b.Record("1.1.1.1", "/admin", KindSensitive)
|
||||
time.Sleep(80 * time.Millisecond) // 事件滑出窗口
|
||||
b.Record("1.1.1.1", "/.env", KindSensitive)
|
||||
if b.IsBanned("1.1.1.1") {
|
||||
t.Fatal("events outside the window must not accumulate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationExpiry(t *testing.T) {
|
||||
b := newTestBanner(1, 5, time.Minute, 60*time.Millisecond, "", nil)
|
||||
b.Record("2.2.2.2", "/admin", KindSensitive)
|
||||
if !b.IsBanned("2.2.2.2") {
|
||||
t.Fatal("expected ban")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // 封禁到期
|
||||
if b.IsBanned("2.2.2.2") {
|
||||
t.Fatal("ban should expire after duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertOncePerIP(t *testing.T) {
|
||||
var calls int64
|
||||
b := newTestBanner(1, 5, time.Minute, time.Minute, "[email protected]", &calls)
|
||||
b.Record("9.9.9.9", "/wp-admin", KindSensitive)
|
||||
b.Record("9.9.9.9", "/.git", KindSensitive) // 已在封禁中,不应重复告警
|
||||
b.Record("9.9.9.9", "/config", KindSensitive)
|
||||
|
||||
// sendAlert 异步调用,轮询等待最多 500ms
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for atomic.LoadInt64(&calls) == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt64(&calls); got != 1 {
|
||||
t.Fatalf("expected exactly 1 alert, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabled(t *testing.T) {
|
||||
b := New(Options{Enabled: false})
|
||||
b.Record("3.3.3.3", "/admin", KindSensitive)
|
||||
if b.IsBanned("3.3.3.3") {
|
||||
t.Fatal("disabled auto-ban must not ban")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
b := New(Options{Enabled: true}) // 全部走默认值
|
||||
if b.thresholdSensitive != DefaultThresholdSensitive || b.threshold404 != DefaultThreshold404 {
|
||||
t.Fatalf("default thresholds wrong: %d/%d", b.thresholdSensitive, b.threshold404)
|
||||
}
|
||||
if b.window != DefaultWindowSeconds*time.Second || b.duration != DefaultDurationMinutes*time.Minute {
|
||||
t.Fatalf("default window/duration wrong: %v/%v", b.window, b.duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSensitivePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/admin", true},
|
||||
{"/admin/panel", true},
|
||||
{"/WP-ADMIN", true},
|
||||
{"/.env", true},
|
||||
{"/user/questions", false},
|
||||
{"/vote/abc123", false},
|
||||
{"/administratorx", false}, // 前缀必须完整段匹配,避免误伤
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsSensitivePath(c.path); got != c.want {
|
||||
t.Errorf("IsSensitivePath(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flamego/cache"
|
||||
cacheRedis "github.com/flamego/cache/redis"
|
||||
"github.com/flamego/csrf"
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/flamego/recaptcha"
|
||||
"github.com/flamego/session"
|
||||
"github.com/flamego/session/mysql"
|
||||
sessionRedis "github.com/flamego/session/redis"
|
||||
"github.com/flamego/template"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
"github.com/tamakyi/TamaBox/route"
|
||||
"github.com/tamakyi/TamaBox/route/auth"
|
||||
"github.com/tamakyi/TamaBox/route/pixel"
|
||||
"github.com/tamakyi/TamaBox/route/question"
|
||||
"github.com/tamakyi/TamaBox/route/service"
|
||||
"github.com/tamakyi/TamaBox/route/user"
|
||||
"github.com/tamakyi/TamaBox/route/vote"
|
||||
"github.com/tamakyi/TamaBox/static"
|
||||
"github.com/tamakyi/TamaBox/templates"
|
||||
)
|
||||
|
||||
// CSRFUserSessionKey 是 CSRF token 绑定的会话键,必须与登录时写入的会话键一致
|
||||
// (见 route/auth/login.go 中 ctx.Session.Set("uid", ...))。flamego/csrf 默认使用
|
||||
// "userID",若不与之对齐,token 的 id 恒为 "0",任意匿名访客从公开页面取到的 token
|
||||
// 对全部已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
//
|
||||
// 本常量同时被 csrf_binding_test.go 引用,作为单一事实来源:一旦被误改,回归测试会在
|
||||
// CI 失败,避免修复悄悄倒退。
|
||||
const CSRFUserSessionKey = "uid"
|
||||
|
||||
// securityHeaders adds security-related HTTP headers to every response.
|
||||
func securityHeaders() flamego.Handler {
|
||||
return func(ctx flamego.Context) {
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
// HSTS:仅当站点以 https 对外提供服务时下发,避免 http 部署下把用户锁死在 https。
|
||||
if strings.HasPrefix(conf.App.ExternalURL, "https") {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func New() *flamego.Flame {
|
||||
f := flamego.Classic()
|
||||
if conf.App.Production {
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出、投票不存在等)误判为路径扫描。
|
||||
ab := autoban.New(autoban.Options{
|
||||
Enabled: conf.Security.EnableAutoBan,
|
||||
ThresholdSensitive: conf.Security.BanThresholdSensitive,
|
||||
Threshold404: conf.Security.BanThreshold404,
|
||||
Window: time.Duration(conf.Security.BanWindowSeconds) * time.Second,
|
||||
Duration: time.Duration(conf.Security.BanDurationMinutes) * time.Minute,
|
||||
AlertEmail: conf.Security.SecurityAlertEmail,
|
||||
SendAlert: mail.SendSecurityAlertMail,
|
||||
})
|
||||
// 需要输出详细日志时配置如下
|
||||
// if conf.App.Production {
|
||||
// flamego.SetEnv(flamego.EnvTypeProd)
|
||||
// }
|
||||
|
||||
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
|
||||
if err != nil {
|
||||
logrus.WithError(err).Fatal("Failed to embed templates file system")
|
||||
}
|
||||
|
||||
gob.Register(time.Time{})
|
||||
gob.Register(context.Flash{})
|
||||
gob.Register(captcha.StoredAnswer{})
|
||||
var sessionStorage interface{}
|
||||
initer := session.MemoryIniter()
|
||||
if conf.Database.DSN != "" {
|
||||
initer = mysql.Initer()
|
||||
sessionStorage = mysql.Config{
|
||||
DSN: conf.Database.DSN,
|
||||
Lifetime: 7 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
if conf.Redis.Addr != "" {
|
||||
initer = sessionRedis.Initer()
|
||||
sessionStorage = sessionRedis.Config{
|
||||
Options: &sessionRedis.Options{
|
||||
Addr: conf.Redis.Addr,
|
||||
Password: conf.Redis.Password,
|
||||
DB: 1,
|
||||
},
|
||||
Lifetime: 7 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
sessioner := session.Sessioner(session.Options{
|
||||
Initer: initer,
|
||||
Config: sessionStorage,
|
||||
// 显式声明 cookie 安全属性,不依赖库默认值:
|
||||
// - HTTPOnly:禁止 JS 读取,降低 XSS 窃取会话的风险
|
||||
// - SameSite=Lax:阻断经典跨站表单 POST(CSRF 的主要防线之一)
|
||||
// - Secure:仅在站点以 https 对外服务时下发,避免 http 本地开发下 cookie 不生效。
|
||||
// 缺失 Secure 时,HSTS 尚未落地的首次访问可被主动 MITM 诱导明文请求窃取会话。
|
||||
Cookie: session.CookieOptions{
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: strings.HasPrefix(conf.App.ExternalURL, "https"),
|
||||
},
|
||||
})
|
||||
|
||||
f.Use(flamego.Static(flamego.StaticOptions{
|
||||
FileSystem: http.FS(static.FS),
|
||||
Prefix: "/static",
|
||||
// 静态资源随二进制发布,内容变更需重新部署;1 天缓存是安全折中
|
||||
CacheControl: func() string {
|
||||
return "public, max-age=86400"
|
||||
},
|
||||
}))
|
||||
|
||||
f.Use(securityHeaders())
|
||||
f.Use(ipBlacklistMiddleware())
|
||||
f.Use(autobanMiddleware(ab))
|
||||
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
|
||||
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
|
||||
f.Use(requestBodyLimitMiddleware())
|
||||
|
||||
reqUserSignOut := context.Toggle(&context.ToggleOptions{UserSignOutRequired: true})
|
||||
reqUserSignIn := context.Toggle(&context.ToggleOptions{UserSignInRequired: true})
|
||||
|
||||
f.Group("", func() {
|
||||
f.Get("/", route.Home)
|
||||
f.Get("/pixel", reqUserSignIn, pixel.Index)
|
||||
f.Get("/sponsor", route.Sponsor)
|
||||
f.Get("/change-logs", route.ChangeLogs)
|
||||
f.Get("/about-me", route.AboutMe)
|
||||
f.Get("/robots.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("robots.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
f.Get("/favicon.ico", func(c context.Context) {
|
||||
fs, _ := static.FS.Open("favicon.ico")
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "image/x-icon")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/sitemap.xml", func(c context.Context) {
|
||||
fs, err := static.FS.Open("sitemap.xml")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "application/xml")
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/llms.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("llms.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Get("/llms-full.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("llms-full.txt")
|
||||
if err != nil {
|
||||
c.ResponseWriter().WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer func() { _ = fs.Close() }()
|
||||
c.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.ResponseWriter().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = io.Copy(c.ResponseWriter(), fs)
|
||||
})
|
||||
|
||||
f.Group("", func() {
|
||||
f.Combo("/register").Get(auth.Register).Post(form.Bind(form.Register{}), rateLimitMiddleware(10, 1*time.Minute), auth.RegisterAction)
|
||||
f.Combo("/login").Get(auth.Login).Post(form.Bind(form.Login{}), rateLimitMiddleware(20, 1*time.Minute), auth.LoginAction)
|
||||
f.Combo("/forgot-password").Get(auth.ForgotPassword).Post(form.Bind(form.ForgotPassword{}), rateLimitMiddleware(5, 5*time.Minute), auth.ForgotPasswordAction)
|
||||
f.Combo("/recover-password").Get(auth.RecoverPassword).Post(form.Bind(form.RecoverPassword{}), auth.RecoverPasswordAction)
|
||||
}, reqUserSignOut)
|
||||
|
||||
// 页面密码验证路由(独立于密码保护中间件);限流防爆破
|
||||
f.Combo("/_/{domain}/password").Get(question.PasswordPage).Post(rateLimitMiddleware(10, 1*time.Minute), question.VerifyPagePassword)
|
||||
|
||||
// 独立的分享图路由(不经过 Pager 中间件,直接返回图片)
|
||||
// 注意:仍挂载 question.Pager,页面设置了密码时必须先通过密码验证,
|
||||
// 否则分享图会绕过页面密码保护(与单题分享图行为一致)。
|
||||
f.Group("/_/{domain}", func() {
|
||||
f.Get("/share-card", question.ShareCard)
|
||||
}, question.Pager)
|
||||
|
||||
f.Group("/_/{domain}", func() {
|
||||
f.Combo("").Get(question.List).Post(form.Bind(form.NewQuestion{}), rateLimitMiddleware(30, 1*time.Minute), question.New)
|
||||
f.Group("/{questionID}", func() {
|
||||
f.Get("", question.Item)
|
||||
f.Get("/share-card", question.QuestionShareCard)
|
||||
f.Post("/delete", question.Delete)
|
||||
f.Post("/set-private", question.SetPrivate)
|
||||
f.Post("/set-public", question.SetPublic)
|
||||
f.Post("/pin", question.TogglePin)
|
||||
f.Post("/read", question.SetRead)
|
||||
f.Post("/unread", question.SetUnread)
|
||||
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
|
||||
f.Post("/follow-up", form.Bind(form.PublishFollowUpQuestion{}), question.PublishFollowUp)
|
||||
f.Post("/follow-up/{followUpID}/answer", reqUserSignIn, form.Bind(form.AnswerFollowUpQuestion{}), question.AnswerFollowUp)
|
||||
}, question.Questioner)
|
||||
|
||||
// 新增:私聊路由(需要登录,使用 Token 标识对话)
|
||||
f.Group("/chat/{token}", func() {
|
||||
f.Get("", chat.ConversationPage)
|
||||
f.Post("/send", rateLimitMiddleware(20, 1*time.Minute), chat.SendMessage)
|
||||
f.Post("/delete", chat.DeleteConversation)
|
||||
f.Post("/block", chat.ToggleBlockUser)
|
||||
}, reqUserSignIn)
|
||||
}, question.Pager) // Pager 中间件已内置页面密码检查
|
||||
|
||||
f.Get("/explore", user.Explore)
|
||||
|
||||
// 匿名投票参与端(公开):token 链接入口
|
||||
f.Group("/vote/{token}", func() {
|
||||
f.Get("", vote.Page)
|
||||
// 提交限流优先按登录用户/签名 vote_fp 计数,IP 仅作高阈值兜底,避免共享出口误伤。
|
||||
f.Post("/submit", voteSubmitRateLimitMiddleware(20, 1*time.Minute), vote.Submit)
|
||||
})
|
||||
|
||||
// 公开页语言切换:把语言偏好写入 session(由 session 中间件随其 cookie 一并持久化)
|
||||
// 后跳回原页面。此前写独立 lang cookie 会被 session 中间件的 Set-Cookie 整体覆盖而失效,
|
||||
// 故改为托管在 session 中。仅允许站内相对路径,防开放重定向。
|
||||
f.Get("/i18n/{lang}", func(c context.Context) {
|
||||
lang := i18n.Resolve(c.Param("lang"))
|
||||
// 写入 session,避免被 session 中间件的 Set-Cookie 覆盖(匿名用户也走 session 中间件)。
|
||||
c.Session.Set("lang", lang)
|
||||
to := c.Query("to")
|
||||
// 仅允许站内相对路径,防开放重定向:
|
||||
// 拒绝空、非 "/" 开头、以 "//" 开头的协议相对路径,以及含反斜杠/控制字符的变体。
|
||||
if to == "" || !strings.HasPrefix(to, "/") ||
|
||||
strings.HasPrefix(to, "//") || strings.ContainsAny(to, "\\\x00\r\n\t") {
|
||||
to = "/"
|
||||
}
|
||||
c.Redirect(to)
|
||||
})
|
||||
|
||||
// 算数验证码 API(仅在 captcha_type = "math" 时生效,但路由本身不依赖中间件)
|
||||
// 加限流:防止匿名脚本无限生成/轮询爆破(答案本身对客户端可见,限流是主要节流手段)。
|
||||
f.Get("/api/captcha/generate", rateLimitMiddleware(20, 1*time.Minute), captcha.GenerateHandler)
|
||||
f.Post("/api/captcha/verify", rateLimitMiddleware(20, 1*time.Minute), captcha.VerifyHandler)
|
||||
|
||||
f.Group("/user", func() {
|
||||
f.Get("/questions", user.QuestionList)
|
||||
|
||||
// 匿名投票管理(箱主侧)
|
||||
f.Group("/votes", func() {
|
||||
f.Get("", user.VoteList)
|
||||
f.Get("/new", user.VoteNew)
|
||||
f.Post("", rateLimitMiddleware(10, 1*time.Minute), user.VoteCreate)
|
||||
f.Get("/{voteID}/edit", user.VoteEdit)
|
||||
f.Post("/{voteID}", rateLimitMiddleware(10, 1*time.Minute), user.VoteUpdate)
|
||||
f.Post("/{voteID}/publish", user.VotePublish)
|
||||
f.Post("/{voteID}/close", user.VoteClose)
|
||||
f.Post("/{voteID}/delete", user.VoteDelete)
|
||||
f.Get("/{voteID}/results", user.VoteResults)
|
||||
f.Get("/{voteID}/preview", user.VotePreview)
|
||||
// 导出为只读操作(幂等、无副作用),用 GET 链接下载,避免表单提交
|
||||
f.Get("/{voteID}/export", user.VoteExportCSV)
|
||||
})
|
||||
|
||||
f.Group("/profile", func() {
|
||||
f.Get("", user.Profile)
|
||||
f.Post("/update", form.Bind(form.UpdateProfile{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateProfile)
|
||||
f.Post("/export", user.ExportProfile)
|
||||
f.Post("/export-json", user.ExportProfileJSON)
|
||||
f.Combo("/deactivate").Get(user.DeactivateProfile).Post(user.DeactivateProfileAction)
|
||||
|
||||
f.Post("/update-page-password", form.Bind(form.UpdatePagePassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdatePagePassword)
|
||||
f.Post("/update-ask-password", form.Bind(form.UpdateAskPassword{}), rateLimitMiddleware(10, 1*time.Minute), user.UpdateAskPassword)
|
||||
|
||||
f.Post("/update-chat-settings", user.UpdateChatSettings)
|
||||
|
||||
// 功能管理(提问 / 私聊 / 投票 的开关、定时开关与定量收集):
|
||||
// 不单独开页面,作为账号设置页的三个面板,保存后回跳到对应面板。
|
||||
f.Group("/features", func() {
|
||||
f.Post("/update", rateLimitMiddleware(20, 1*time.Minute), user.FeaturesUpdate)
|
||||
f.Post("/reset-quota", rateLimitMiddleware(20, 1*time.Minute), user.FeatureResetQuota)
|
||||
})
|
||||
})
|
||||
// 防骚扰设置:注册限制(harassment/update)与全局屏蔽词(blockwords/update)
|
||||
// 拆为两个独立入口。屏蔽词在提问/私聊/投票三个设置面板共用一份,保存后经
|
||||
// ?tab= 参数回跳到来源面板。
|
||||
f.Post("/harassment/update", form.Bind(form.UpdateHarassment{}), user.UpdateHarassment)
|
||||
f.Post("/blockwords/update", form.Bind(form.UpdateBlockWords{}), user.UpdateBlockWords)
|
||||
f.Post("/tags/update", form.Bind(form.UpdateTags{}), user.UpdateTags)
|
||||
// Logout 必须用 POST:GET 会留下 CSRF 风险(第三方图片/链接可触发退出登录)
|
||||
f.Post("/logout", auth.Logout)
|
||||
}, reqUserSignIn)
|
||||
|
||||
f.Group("/api/v1", func() {
|
||||
f.Group("/user", func() {
|
||||
f.Get("", reqUserSignIn, user.ProfileAPI)
|
||||
f.Group("/{domain}", func() {
|
||||
f.Group("/questions", func() {
|
||||
f.Get("", rateLimitMiddleware(60, 1*time.Minute), question.ListAPI)
|
||||
})
|
||||
})
|
||||
})
|
||||
// 像素追踪代理:仅暴露只读 GET(f.Any 会放行写方法,曾有 CSRF 豁免 + 写方法放行的风险),
|
||||
// 并加限流防止被用作高频打点通道。
|
||||
f.Get("/pixel/{**}", reqUserSignIn, rateLimitMiddleware(30, 1*time.Minute), pixel.Proxy)
|
||||
// 服务反代仅暴露只读 GET:本网关是把已登录用户的请求转发到配置好的内部服务,
|
||||
// 不应允许任意写方法,避免被用作对内网后端的越权操作入口。
|
||||
f.Get("/service/{**}", reqUserSignIn, service.Proxy)
|
||||
f.Get("/explore/random", rateLimitMiddleware(60, 1*time.Minute), user.RandomRecommend)
|
||||
// 首页"提问箱信息统计"公开接口(服务端 30 分钟缓存),供前端定时刷新
|
||||
f.Get("/site-stats", rateLimitMiddleware(60, 1*time.Minute), route.SiteStatsAPI)
|
||||
}, context.APIEndpoint)
|
||||
},
|
||||
cache.Cacher(cache.Options{
|
||||
Initer: cacheRedis.Initer(),
|
||||
Config: cacheRedis.Config{
|
||||
Options: &cacheRedis.Options{
|
||||
Addr: conf.Redis.Addr,
|
||||
Password: conf.Redis.Password,
|
||||
DB: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
recaptcha.V3(
|
||||
recaptcha.Options{
|
||||
Secret: conf.Recaptcha.ServerKey,
|
||||
VerifyURL: func() recaptcha.VerifyURL {
|
||||
if conf.Recaptcha.TurnstileStyle {
|
||||
return "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
}
|
||||
return recaptcha.VerifyURLGlobal
|
||||
}(),
|
||||
},
|
||||
),
|
||||
sessioner,
|
||||
csrf.Csrfer(csrf.Options{
|
||||
Secret: conf.Server.XSRFKey,
|
||||
Header: "X-CSRF-Token",
|
||||
// ⚠️ 必须与 route/auth/login.go 中 ctx.Session.Set("uid", ...) 的 key 一致。
|
||||
// flamego/csrf 默认 SessionKey 为 "userID",若不显式指定,s.Get("userID")
|
||||
// 恒为 nil,token 内绑定的 id 恒为 "0" —— 由于校验是无状态 HMAC 重算
|
||||
// (不与 session 中存储的 token 比对),这会导致任意匿名访客从公开的
|
||||
// /login 页面取到的 token 对所有已登录用户均有效,CSRF 退化为全站公开常量。
|
||||
// 回归测试见 route/csrf_binding_test.go(引用 CSRFUserSessionKey)。
|
||||
SessionKey: CSRFUserSessionKey,
|
||||
}),
|
||||
template.Templater(template.Options{
|
||||
FileSystem: templateFS,
|
||||
FuncMaps: templatepkg.FuncMap(),
|
||||
}),
|
||||
context.Contexter(),
|
||||
)
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
// 真正的 404 探测在此计数(仅此处,不把业务 302→/ 误判为扫描)。
|
||||
// 注意:flamego 的 NotFound 会先写入 404 状态再 Redirect 到 "/",
|
||||
// 因此这里在 Redirect 之前记录,确保状态为 404。
|
||||
// 键必须与 autobanMiddleware 中的 IsBanned/Record 保持一致(同样经 LimitKey 聚合),
|
||||
// 否则 404 计数会记在另一个键上,导致封禁永不生效。
|
||||
if ip := shared.LimitKey(shared.ResolveClientIP(ctx.Request().Request)); ip != "" {
|
||||
ab.Record(ip, ctx.Request().URL.Path, autoban.KindNotFound)
|
||||
}
|
||||
ctx.Redirect("/")
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package conf
|
||||
|
||||
// Build time and commit information.
|
||||
//
|
||||
// ⚠️ WARNING: should only be set by "-ldflags".
|
||||
var (
|
||||
BuildTime string
|
||||
BuildCommit = "dev"
|
||||
)
|
||||
|
||||
var (
|
||||
App struct {
|
||||
Production bool `ini:"production"`
|
||||
ExternalURL string `ini:"external_url"`
|
||||
ICP string `ini:"icp"`
|
||||
DefaultLang string `ini:"default_lang"` // 站点默认语言(zh-CN/zh-TW/en/ja),留空回退简体中文
|
||||
UptraceDSN string `ini:"uptrace_dsn"`
|
||||
QiniuAccessKey string `ini:"qiniu_access_key"`
|
||||
QiniuAccessSecret string `ini:"qiniu_access_secret"`
|
||||
AliyunAccessKey string `ini:"aliyun_access_key"`
|
||||
AliyunAccessKeySecret string `ini:"aliyun_access_key_secret"`
|
||||
SentryDSN string `ini:"sentry_dsn"`
|
||||
MaintenanceMode bool `ini:"maintenance_mode"`
|
||||
ShareBackground string `ini:"share_background"`
|
||||
FontPath string `ini:"font_path"`
|
||||
}
|
||||
|
||||
Security struct {
|
||||
EnableTextCensor bool `ini:"enable_text_censor"`
|
||||
IPBlacklist string `ini:"ip_blacklist"`
|
||||
IPBlacklistRedirectURL string `ini:"ip_blacklist_redirect_url"`
|
||||
// TrustProxy 为 true 时信任 CDN 权威客户端头 CF-Connecting-IP / EO-Client-IP
|
||||
// (以及受信代理设置的 X-Real-IP / X-Forwarded-For);否则一律使用 RemoteAddr,
|
||||
// 防止客户端伪造 header 绕过速率限制与 IP 黑名单。
|
||||
TrustProxy bool `ini:"trust_proxy"`
|
||||
// TrustedProxies 逗号分隔的 IP / CIDR 列表,仅限“你自己的”反向代理/负载均衡基础设施
|
||||
// (例如本机 nginx 的 127.0.0.1、私有网络 LB)。仅当 TCP 对端落在该清单内时才会解析
|
||||
// X-Forwarded-For / X-Real-IP。留空则这两个头永不被信任(CDN 权威头 CF-Connecting-IP /
|
||||
// EO-Client-IP 仍会被采用,前提是源站已被对应 CDN 锁定)。
|
||||
// 目的:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单——源站应配合
|
||||
// Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源网段锁定,
|
||||
// 使 CF-Connecting-IP / EO-Client-IP 成为权威客户端 IP。
|
||||
TrustedProxies string `ini:"trusted_proxies"`
|
||||
// 自动封禁(fail2ban 风格):检测批量扫描/探测特征后临时封禁来源 IP。
|
||||
EnableAutoBan bool `ini:"enable_auto_ban"`
|
||||
BanThresholdSensitive int `ini:"ban_threshold_sensitive"` // 窗口内敏感路径命中次数
|
||||
BanThreshold404 int `ini:"ban_threshold_404"` // 窗口内 404 次数
|
||||
BanWindowSeconds int `ini:"ban_window_seconds"` // 统计窗口(秒)
|
||||
BanDurationMinutes int `ini:"ban_duration_minutes"` // 封禁时长(分钟)
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
}
|
||||
|
||||
Server struct {
|
||||
Port int `ini:"port"`
|
||||
Listen string `ini:"listen"` // 绑定监听地址(含端口)。留空则默认 ":port"(监听所有网卡)。
|
||||
Salt string `ini:"salt"`
|
||||
XSRFKey string `ini:"xsrf_key"`
|
||||
}
|
||||
|
||||
Database struct {
|
||||
DSN string
|
||||
|
||||
Type string `ini:"type"`
|
||||
User string `ini:"user"`
|
||||
Password string `ini:"password"`
|
||||
Host string `ini:"host"`
|
||||
Port uint `ini:"port"`
|
||||
Name string `ini:"name"`
|
||||
Schema string `ini:"schema"`
|
||||
DedupeUniqueFields bool `ini:"dedupe_unique_fields"`
|
||||
}
|
||||
|
||||
Redis struct {
|
||||
Addr string `ini:"addr"`
|
||||
Password string `ini:"password"`
|
||||
}
|
||||
|
||||
Recaptcha struct {
|
||||
CaptchaType string `ini:"captcha_type"`
|
||||
Domain string `ini:"domain"`
|
||||
SiteKey string `ini:"site_key"`
|
||||
ServerKey string `ini:"server_key"`
|
||||
TurnstileStyle bool `ini:"turnstile_style"`
|
||||
}
|
||||
|
||||
Pixel struct {
|
||||
Host string `ini:"host"`
|
||||
}
|
||||
|
||||
Upload struct {
|
||||
DefaultAvatarURL string `ini:"default_avatar"`
|
||||
DefaultBackground string `ini:"default_background"`
|
||||
DefaultBackgroundImageURL string `ini:"default_qrbackgroundimage"`
|
||||
AliyunEndpoint string `ini:"aliyun_endpoint"`
|
||||
AliyunAccessID string `ini:"aliyun_access_id"`
|
||||
AliyunAccessSecret string `ini:"aliyun_access_secret"`
|
||||
AliyunBucket string `ini:"aliyun_bucket"`
|
||||
AliyunBucketCDNHost string `ini:"aliyun_bucket_cdn_host"`
|
||||
ImageEndpoint string `ini:"image_endpoint"`
|
||||
ImageAccessID string `ini:"image_access_id"`
|
||||
ImageAccessSecret string `ini:"image_access_secret"`
|
||||
ImageBucket string `ini:"image_bucket"`
|
||||
ImageBucketCDNHost string `ini:"image_bucket_cdn_host"`
|
||||
}
|
||||
|
||||
Mail struct {
|
||||
Account string `ini:"account"`
|
||||
Password string `ini:"password"`
|
||||
Port int `ini:"port"`
|
||||
SMTP string `ini:"smtp"`
|
||||
// SkipTLSVerify 跳过 SMTP TLS 证书校验(仅自签名/内网 SMTP 需要,默认 false 校验证书)
|
||||
SkipTLSVerify bool `ini:"skip_tls_verify"`
|
||||
}
|
||||
|
||||
Service struct {
|
||||
Backends []struct {
|
||||
Prefix string `ini:"prefix"`
|
||||
ForwardURL string `ini:"forward_url"`
|
||||
}
|
||||
// TLSInsecureSkipVerify 跳过反向代理到后端服务的 TLS 证书校验
|
||||
// (仅内网后端使用自签名证书时需要,默认 false 校验证书)
|
||||
TLSInsecureSkipVerify bool `ini:"tls_insecure_skip_verify"`
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user