仰望星辰工作室

fix(security): 修复A+B组11项高危问题 + 友校大使馆插件源码

F fqh 提交于 2026-10-02 15:31 · 51ffd76 ·父提交 e5b8a3d
fix(security): 修复A+B组11项高危问题 + 友校大使馆插件源码
13 个文件变更 +1001 -42 fangqihang1717@163.com
•internal/database/database.go +12 -1
•internal/handlers/admin.go +19 -6
•internal/handlers/auth.go +96 -2
•internal/handlers/front.go +8 -4
•internal/middleware/middleware.go +3 -3
•internal/plugin/api_db.go +22 -9
•internal/plugin/api_net.go +55 -1
•internal/plugin/embassy_app_test.go +220 -0
•internal/plugin/plugin.go +21 -6
•internal/util/util.go +49 -10
•plugins/embassy/admin.html +154 -0
•plugins/embassy/main.js +318 -0
•plugins/embassy/plugin.json +24 -0
变更内容
diff --git a/internal/database/database.go b/internal/database/database.go
index 81c74b0..cc891cb 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS admins (
 );
 CREATE TABLE IF NOT EXISTS settings (k VARCHAR(64) PRIMARY KEY, v {LT});
 CREATE TABLE IF NOT EXISTS notices (id {PK}, title VARCHAR(128) DEFAULT '', content {LT}, created_at VARCHAR(40));
-CREATE TABLE IF NOT EXISTS apikeys (id {PK}, name VARCHAR(64) DEFAULT '', key VARCHAR(64) NOT NULL UNIQUE, status {I} DEFAULT 1, last_used VARCHAR(40) DEFAULT '', created_at VARCHAR(40));
+CREATE TABLE IF NOT EXISTS apikeys (id {PK}, name VARCHAR(64) DEFAULT '', api_key VARCHAR(64) NOT NULL UNIQUE, status {I} DEFAULT 1, last_used VARCHAR(40) DEFAULT '', created_at VARCHAR(40));
 CREATE TABLE IF NOT EXISTS ai_logs (id {PK}, post_id {I} DEFAULT 0, action VARCHAR(16) DEFAULT '', reason {LT}, created_at VARCHAR(40));
 CREATE TABLE IF NOT EXISTS bans (id {PK}, btype VARCHAR(16) DEFAULT '', bvalue VARCHAR(128) DEFAULT '', created_at VARCHAR(40));
 CREATE TABLE IF NOT EXISTS verifications (id {PK}, email VARCHAR(128) NOT NULL, code VARCHAR(10) NOT NULL, expires VARCHAR(40));
@@ -134,6 +134,16 @@ func Migrate() error {
 	}
 	_, _ = DB.Exec(alter)
 	_, _ = DB.Exec("ALTER TABLE posts ADD COLUMN badges VARCHAR(255) DEFAULT ''")
+	// 兼容旧库:apikeys.key 是 MySQL 保留字,统一改名为 api_key(列不存在/已改名时报错,忽略即可)
+	rename := `ALTER TABLE apikeys RENAME COLUMN "key" TO "api_key"`
+	if Driver == "mysql" {
+		rename = "ALTER TABLE apikeys RENAME COLUMN `key` TO `api_key`"
+	}
+	_, _ = DB.Exec(rename)
+
+	// 兼容旧库:likes 去重后再建唯一索引,防止并发点赞产生重复行
+	_, _ = DB.Exec(`DELETE FROM likes WHERE id NOT IN (
+		SELECT min_id FROM (SELECT MIN(id) AS min_id FROM likes GROUP BY post_id, fingerprint) t)`)
 
 	// 常用索引(忽略已存在错误)
 	indexes := []string{
@@ -142,6 +152,7 @@ func Migrate() error {
 		"CREATE INDEX idx_media_post ON medias (post_id)",
 		"CREATE INDEX idx_comments_post ON comments (post_id)",
 		"CREATE INDEX idx_likes_post ON likes (post_id)",
+		"CREATE UNIQUE INDEX idx_likes_uniq ON likes (post_id, fingerprint)",
 	}
 	for _, ix := range indexes {
 		_, _ = DB.Exec(ix)
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index 02f5e40..01eba17 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -40,15 +40,23 @@ func AdminLoginPage(w http.ResponseWriter, r *http.Request) {
 
 // AdminLoginSubmit 管理员登录(12 小时会话)
 func AdminLoginSubmit(w http.ResponseWriter, r *http.Request) {
+	ip := util.ClientIP(r)
+	// 与发帖守卫共用失败锁定:同一 IP 连续失败 5 次锁 10 分钟,防止后台账号被在线爆破
+	if adminTryBlocked("login:" + ip) {
+		Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": "失败次数过多,请 10 分钟后再试"})
+		return
+	}
 	username := strings.TrimSpace(r.FormValue("username"))
 	password := r.FormValue("password")
 	var id int64
 	var hash string
 	err := database.DB.QueryRow("SELECT id,password FROM admins WHERE username=?", username).Scan(&id, &hash)
 	if err != nil || !util.CheckPassword(hash, password) {
+		adminTryFail("login:" + ip)
 		Render(w, r, "admin_layout.html", "pg_admin_login", map[string]any{"err": "用户名或密码错误"})
 		return
 	}
+	adminTryReset("login:" + ip)
 	http.SetCookie(w, &http.Cookie{
 		Name: "clv_admin", Value: util.SignSession(id, 12*time.Hour), Path: "/",
 		MaxAge: 12 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
@@ -352,10 +360,15 @@ func AdminThemeDelete(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	name := filepath.Base(r.FormValue("name"))
-	if name != "default" {
-		_ = os.RemoveAll(filepath.Join(config.Cfg.DataDir, "themes", name))
-		if models.GetSetting("theme") == name {
-			_ = models.SetSetting("theme", "default")
+	// 防路径穿越:拒绝 . / .. 等非法目录名,并确保解析后仍在 themes 目录内
+	if name != "default" && name != "." && name != ".." && !strings.HasPrefix(name, ".") {
+		themesDir := filepath.Clean(filepath.Join(config.Cfg.DataDir, "themes"))
+		target := filepath.Clean(filepath.Join(themesDir, name))
+		if target != themesDir && strings.HasPrefix(target, themesDir+string(os.PathSeparator)) {
+			_ = os.RemoveAll(target)
+			if models.GetSetting("theme") == name {
+				_ = models.SetSetting("theme", "default")
+			}
 		}
 	}
 	http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
@@ -1007,7 +1020,7 @@ func AdminAPIKeys(w http.ResponseWriter, r *http.Request) {
 	if a := requireAdmin(w, r, models.PermAPI); a == nil {
 		return
 	}
-	rows, err := database.DB.Query("SELECT id,name,key,status,IFNULL(last_used,''),created_at FROM apikeys ORDER BY id DESC")
+	rows, err := database.DB.Query("SELECT id,name,api_key,status,IFNULL(last_used,''),created_at FROM apikeys ORDER BY id DESC")
 	if err != nil {
 		http.Error(w, "查询失败", http.StatusInternalServerError)
 		return
@@ -1039,7 +1052,7 @@ func AdminAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
 	if name == "" {
 		name = "未命名"
 	}
-	_, _ = database.DB.Exec("INSERT INTO apikeys(name,key,created_at) VALUES(?,?,?)",
+	_, _ = database.DB.Exec("INSERT INTO apikeys(name,api_key,created_at) VALUES(?,?,?)",
 		name, util.RandomHex(24), models.Now())
 	http.Redirect(w, r, "/admin/apikeys", http.StatusSeeOther)
 }
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index 52a340d..ffee5a3 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -8,6 +8,7 @@ import (
 	"net/mail"
 	"regexp"
 	"strings"
+	"sync"
 	"time"
 
 	"clearlove/internal/database"
@@ -20,6 +21,70 @@ import (
 // Go 正则不支持 \u 转义,使用 \x{...} 表示中文区间
 var usernameRe = regexp.MustCompile(`^[\w\x{4e00}-\x{9fa5}]{3,20}$`)
 
+// ---------- 通用失败计数(防爆破/轰炸,进程内内存态) ----------
+
+type tryEntry struct {
+	Count int
+	Until time.Time
+}
+
+var (
+	codeTryMu     sync.Mutex
+	codeTries     = map[string]tryEntry{}
+	codeTriesSeen = time.Now()
+)
+
+// codeTryBlocked 是否被锁定(连续失败 5 次锁 10 分钟)
+func codeTryBlocked(key string) bool {
+	codeTryMu.Lock()
+	defer codeTryMu.Unlock()
+	t, ok := codeTries[key]
+	if !ok {
+		return false
+	}
+	if time.Now().After(t.Until) {
+		delete(codeTries, key)
+		return false
+	}
+	return t.Count >= 5
+}
+
+func codeTryFail(key string) {
+	codeTryMu.Lock()
+	defer codeTryMu.Unlock()
+	// 定期整体回收过期条目,防止伪造 key 导致 map 无界增长
+	if time.Since(codeTriesSeen) > time.Hour {
+		codeTriesSeen = time.Now()
+		now := time.Now()
+		for k, v := range codeTries {
+			if now.After(v.Until) {
+				delete(codeTries, k)
+			}
+		}
+	}
+	t := codeTries[key]
+	t.Count++
+	t.Until = time.Now().Add(10 * time.Minute)
+	codeTries[key] = t
+}
+
+func codeTryReset(key string) {
+	codeTryMu.Lock()
+	defer codeTryMu.Unlock()
+	delete(codeTries, key)
+}
+
+// codeTryCount 当前失败次数(未锁定也返回计数,用于发码限流判断)
+func codeTryCount(key string) int {
+	codeTryMu.Lock()
+	defer codeTryMu.Unlock()
+	t, ok := codeTries[key]
+	if !ok || time.Now().After(t.Until) {
+		return 0
+	}
+	return t.Count
+}
+
 // LoginPage 登录页
 func LoginPage(w http.ResponseWriter, r *http.Request) {
 	Render(w, r, "layout.html", "pg_login", map[string]any{
@@ -114,14 +179,29 @@ func RegisterSubmit(w http.ResponseWriter, r *http.Request) {
 	}
 	// SMTP 已配置则强制校验验证码
 	if models.GetSetting("smtp_host") != "" {
+		key := "vcode:" + email
+		var vid int64
 		var dbCode string
 		var exp string
 		err := database.DB.QueryRow(
-			"SELECT code,expires FROM verifications WHERE email=? ORDER BY id DESC LIMIT 1", email).Scan(&dbCode, &exp)
-		if err != nil || dbCode != code || !codeValid(exp) {
+			"SELECT id,code,expires FROM verifications WHERE email=? ORDER BY id DESC LIMIT 1", email).Scan(&vid, &dbCode, &exp)
+		if err != nil || !codeValid(exp) {
 			q("邮箱验证码错误或已过期")
 			return
 		}
+		if dbCode != code {
+			// 防爆破:同一邮箱连续错误 5 次即作废当前验证码,必须重新获取
+			if codeTryBlocked(key) {
+				_, _ = database.DB.Exec("DELETE FROM verifications WHERE id=?", vid)
+				codeTryReset(key)
+				q("验证码错误次数过多,请重新获取验证码")
+				return
+			}
+			codeTryFail(key)
+			q("邮箱验证码错误或已过期")
+			return
+		}
+		codeTryReset(key)
 	}
 	res, err := database.DB.Exec(
 		"INSERT INTO users(username,email,password,status,created_at) VALUES(?,?,?,1,?)",
@@ -158,11 +238,23 @@ func RegisterCode(w http.ResponseWriter, r *http.Request) {
 		fail(w, 400, "邮箱格式不正确")
 		return
 	}
+	// 发码限流:同一邮箱 10 分钟内最多 3 次,同一 IP 10 分钟内最多 10 次,防 SMTP 轰炸
+	ip := util.ClientIP(r)
+	if codeTryCount("send:"+email) >= 3 {
+		fail(w, 429, "发送过于频繁,请稍后再试")
+		return
+	}
+	if codeTryCount("sendip:"+ip) >= 10 {
+		fail(w, 429, "发送过于频繁,请稍后再试")
+		return
+	}
 	if models.QueryInt("SELECT COUNT(1) FROM users WHERE email=?", email) > 0 {
 		fail(w, 400, "该邮箱已注册")
 		return
 	}
 	code := fmt.Sprintf("%06d", randomInt(1000000))
+	// 同邮箱旧验证码作废,避免多条并存增加爆破面
+	_, _ = database.DB.Exec("DELETE FROM verifications WHERE email=?", email)
 	_, err := database.DB.Exec(
 		"INSERT INTO verifications(email,code,expires) VALUES(?,?,?)",
 		email, code, time.Now().UTC().Add(10*time.Minute).Format(time.RFC3339))
@@ -170,6 +262,8 @@ func RegisterCode(w http.ResponseWriter, r *http.Request) {
 		fail(w, 500, "验证码生成失败")
 		return
 	}
+	codeTryFail("send:" + email)
+	codeTryFail("sendip:" + ip)
 	err = mailer.SendCode(host, models.GetSetting("smtp_port"),
 		models.GetSetting("smtp_user"), models.GetSetting("smtp_pass"),
 		models.GetSetting("smtp_from"), email, code)
diff --git a/internal/handlers/front.go b/internal/handlers/front.go
index 3182bdf..03cb169 100644
--- a/internal/handlers/front.go
+++ b/internal/handlers/front.go
@@ -570,16 +570,20 @@ func toggleLike(postID int64, fp string) (int64, bool) {
 		return 0, false
 	}
 	exists := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=? AND fingerprint=?", postID, fp)
+	liked := exists == 0
 	if exists > 0 {
 		_, _ = database.DB.Exec("DELETE FROM likes WHERE post_id=? AND fingerprint=?", postID, fp)
 	} else {
+		// 唯一索引 idx_likes_uniq 拦截并发重复点赞,冲突报错直接忽略(等价于已被并发请求点亮)
 		_, _ = database.DB.Exec(
 			"INSERT INTO likes(post_id,fingerprint,created_at) VALUES(?,?,?)", postID, fp, models.Now())
 	}
-	n := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=?", postID)
-	_, _ = database.DB.Exec("UPDATE posts SET like_count=? WHERE id=?", n, postID)
-	plugin.Emit("like_toggled", map[string]any{"post_id": postID, "liked": exists == 0, "count": n})
-	return n, exists == 0
+	// 单语句内重算计数,避免"读-改-写"交错导致 like_count 与实际行数永久漂移
+	_, _ = database.DB.Exec(
+		"UPDATE posts SET like_count=(SELECT COUNT(1) FROM likes WHERE post_id=?) WHERE id=?", postID, postID)
+	n := models.QueryInt("SELECT like_count FROM posts WHERE id=?", postID)
+	plugin.Emit("like_toggled", map[string]any{"post_id": postID, "liked": liked, "count": n})
+	return n, liked
 }
 
 // addComment 发表评论
diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go
index b57d069..23914f2 100644
--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -175,7 +175,7 @@ func exemptCSRF(r *http.Request) bool {
 		return false
 	}
 	var n int64
-	err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE key=? AND status=1", k).Scan(&n)
+	err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE api_key=? AND status=1", k).Scan(&n)
 	return err == nil && n > 0
 }
 
@@ -214,9 +214,9 @@ func HasAPIKey(r *http.Request) bool {
 		return false
 	}
 	var n int64
-	if err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE key=? AND status=1", k).Scan(&n); err != nil || n == 0 {
+	if err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE api_key=? AND status=1", k).Scan(&n); err != nil || n == 0 {
 		return false
 	}
-	_, _ = database.DB.Exec("UPDATE apikeys SET last_used=? WHERE key=?", models.Now(), k)
+	_, _ = database.DB.Exec("UPDATE apikeys SET last_used=? WHERE api_key=?", models.Now(), k)
 	return true
 }
diff --git a/internal/plugin/api_db.go b/internal/plugin/api_db.go
index ea50822..260c11c 100644
--- a/internal/plugin/api_db.go
+++ b/internal/plugin/api_db.go
@@ -59,29 +59,42 @@ func (rt *jsRuntime) installDB(clv *goja.Object) error {
 // ---------- SQL 安全 ----------
 
 var (
-	reWriteKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|replace|alter|drop|create|truncate|rename)\b`)
 	reTableRef     = regexp.MustCompile("(?i)\\b(?:into|update|from|table|join)\\s+[`\"']?([a-zA-Z0-9_]+)")
+	reSQLComment   = regexp.MustCompile(`(?s)/\*.*?\*/|--[^\n]*`)
+	reDangerousSQL = regexp.MustCompile(`(?i)\b(pragma|attach|detach|vacuum)\b`)
 )
 
-// sqlAllowed 写操作必须落在本插件前缀的表上
+// sqlAllowed 插件 SQL 管控:
+//   - 声明 db.admin 权限的插件不受限
+//   - 拒绝多语句(分号拆分逐段校验)与 PRAGMA/ATTACH/DETACH/VACUUM 等危险语句
+//   - 读写一致:所有被引用的表必须落在本插件前缀(pl_<slug>_)内;
+//     读取内核表请走 clv.site(site.read 权限),防止仅声明 db 的插件拖库
 func (rt *jsRuntime) sqlAllowed(sqlText string) bool {
 	if rt.app != nil && rt.app.Plugin.HasPermission("db.admin") {
 		return true
 	}
-	if !reWriteKeyword.MatchString(sqlText) {
-		return true // 纯读取
-	}
-	prefix := strings.ToLower(rt.plugin.TablePrefix())
-	for _, m := range reTableRef.FindAllStringSubmatch(sqlText, -1) {
-		if !strings.HasPrefix(strings.ToLower(m[1]), prefix) {
+	// 去除注释再校验,防止 UPDATE/**/settings 这类拆词绕过
+	cleaned := reSQLComment.ReplaceAllString(sqlText, " ")
+	for _, seg := range strings.Split(cleaned, ";") {
+		seg = strings.TrimSpace(seg)
+		if seg == "" {
+			continue
+		}
+		if reDangerousSQL.MatchString(seg) {
 			return false
 		}
+		prefix := strings.ToLower(rt.plugin.TablePrefix())
+		for _, m := range reTableRef.FindAllStringSubmatch(seg, -1) {
+			if !strings.HasPrefix(strings.ToLower(m[1]), prefix) {
+				return false
+			}
+		}
 	}
 	return true
 }
 
 func (rt *jsRuntime) sqlDeniedErr(sqlText string) error {
-	return fmt.Errorf("插件 %s 只能写自己的数据表(%s*);如需访问其它表请声明 db.admin 权限。SQL: %.100s",
+	return fmt.Errorf("插件 %s 只能访问自己的数据表(%s*);读取内核数据请用 clv.site,如需访问其它表请声明 db.admin 权限。SQL: %.100s",
 		rt.plugin.SlugOf(), rt.plugin.TablePrefix(), strings.TrimSpace(sqlText))
 }
 
diff --git a/internal/plugin/api_net.go b/internal/plugin/api_net.go
index 60b54af..08ff90b 100644
--- a/internal/plugin/api_net.go
+++ b/internal/plugin/api_net.go
@@ -2,6 +2,7 @@
 package plugin
 
 import (
+	"context"
 	"crypto/hmac"
 	"crypto/rand"
 	"crypto/sha256"
@@ -163,7 +164,19 @@ func (rt *jsRuntime) httpRequest(call goja.FunctionCall) (any, error) {
 		req.Header.Set("User-Agent", "ClearLove-Plugin/1.0")
 	}
 
-	client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond}
+	// 拨号期校验实际解析到的 IP(防 DNS rebinding:校验与拨号共用同一次解析结果,
+	// 并直接连接已校验的 IP),重定向目标同样复查(防外站 302 跳内网绕过)。
+	transport := &http.Transport{DialContext: ssrfGuardedDial(allowLocal)}
+	client := &http.Client{
+		Timeout:   time.Duration(timeoutMS) * time.Millisecond,
+		Transport: transport,
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			if len(via) >= 10 {
+				return errors.New("重定向次数过多")
+			}
+			return guardSSRF(req.URL.String(), allowLocal)
+		},
+	}
 	resp, err := client.Do(req)
 	if err != nil {
 		return nil, err
@@ -224,6 +237,47 @@ func isPrivateIP(ip net.IP) bool {
 		ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
 }
 
+// ssrfGuardedDial 返回带内网校验的拨号函数:域名解析后逐 IP 校验,
+// 仅连接通过校验的 IP,消除"校验时公网、拨号时内网"的 DNS rebinding 窗口。
+func ssrfGuardedDial(allowLocal bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
+	dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
+	return func(ctx context.Context, network, addr string) (net.Conn, error) {
+		host, port, err := net.SplitHostPort(addr)
+		if err != nil {
+			return nil, err
+		}
+		if allowLocal {
+			return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
+		}
+		if ip := net.ParseIP(host); ip != nil {
+			if isPrivateIP(ip) {
+				return nil, fmt.Errorf("禁止连接内网地址 %s", ip)
+			}
+			return dialer.DialContext(ctx, network, net.JoinHostPort(host, port))
+		}
+		ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+		if err != nil {
+			return nil, err
+		}
+		var lastErr error
+		for _, ipa := range ips {
+			if isPrivateIP(ipa.IP) {
+				lastErr = fmt.Errorf("禁止连接内网地址(%s 解析到 %s)", host, ipa.IP)
+				continue
+			}
+			conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipa.IP.String(), port))
+			if err == nil {
+				return conn, nil
+			}
+			lastErr = err
+		}
+		if lastErr == nil {
+			lastErr = fmt.Errorf("无法解析主机 %s", host)
+		}
+		return nil, lastErr
+	}
+}
+
 func randomUUID() string {
 	b := make([]byte, 16)
 	_, _ = rand.Read(b)
diff --git a/internal/plugin/embassy_app_test.go b/internal/plugin/embassy_app_test.go
new file mode 100644
index 0000000..37769c8
--- /dev/null
+++ b/internal/plugin/embassy_app_test.go
@@ -0,0 +1,220 @@
+package plugin
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"clearlove/internal/database"
+	"clearlove/internal/models"
+)
+
+// TestEmbassyPlugin 加载仓库 plugins/embassy 真实插件,验证「友校大使馆」完整业务流:
+// 后台登记大使 → 昵称识别 → 验证账密 → post_created 落认证 → 卡片徽章。
+func TestEmbassyPlugin(t *testing.T) {
+	dataDir := setupAppEnv(t)
+
+	// 拷贝真实插件文件到临时插件目录
+	src, err := filepath.Abs(filepath.Join("..", "..", "plugins", "embassy"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	dst := filepath.Join(dataDir, "plugins", "embassy")
+	if err := os.MkdirAll(dst, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	for _, f := range []string{"plugin.json", "main.js"} {
+		b, err := os.ReadFile(filepath.Join(src, f))
+		if err != nil {
+			t.Fatalf("读取插件文件失败: %v", err)
+		}
+		if err := os.WriteFile(filepath.Join(dst, f), b, 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	SetEnabled("友校大使馆", true)
+	if err := LoadApp("embassy"); err != nil {
+		t.Fatalf("加载 embassy 插件失败: %v", err)
+	}
+	defer func() {
+		UnloadApp("embassy")
+		SetEnabled("友校大使馆", false)
+	}()
+
+	app := GetApp("embassy")
+	if app == nil {
+		t.Fatal("embassy 未登记到注册表")
+	}
+	if len(app.Routes) != 5 {
+		t.Fatalf("路由数 = %d,期望 5", len(app.Routes))
+	}
+	if len(app.Pages) != 1 || len(AdminMenus()) != 1 {
+		t.Fatalf("后台页/菜单注册异常")
+	}
+	if len(EventHooks("post_created")) != 1 {
+		t.Fatal("post_created 事件未注册")
+	}
+	if len(FilterHooks("filter.card")) != 1 {
+		t.Fatal("filter.card 未注册")
+	}
+
+	// 表已创建
+	if _, err := database.DB.Exec("SELECT COUNT(1) FROM pl_embassy_ambassador"); err != nil {
+		t.Fatalf("插件表未创建: %v", err)
+	}
+
+	// 简易 ctx 构造器(对齐 buildRequestCtx 的关键字段)
+	mkCtx := func(query, form map[string]string) map[string]any {
+		return map[string]any{
+			"req":     map[string]any{"query": query, "form": form, "json": nil, "ip": "1.2.3.4", "fingerprint": "fp-test"},
+			"params":  map[string]string{},
+			"userId":  int64(0), "adminId": int64(1), "isAdmin": true, "csrf": "test-csrf",
+		}
+	}
+	call := func(fn string, arg any) (any, error) {
+		return app.Worker.Do(context.Background(), func(rt *jsRuntime) (any, error) {
+			return rt.callFn(fn, arg)
+		})
+	}
+	// 路由函数返回 {status?, json: {...}},直接调用时需解包
+	jbody := func(v any) map[string]any {
+		m, _ := v.(map[string]any)
+		if m == nil {
+			return nil
+		}
+		j, _ := m["json"].(map[string]any)
+		return j
+	}
+
+	// 1) 未登记昵称 → 不受保护
+	v, err := call("apiLookup", mkCtx(map[string]string{"nickname": "小美"}, nil))
+	if err != nil {
+		t.Fatalf("apiLookup 失败: %v", err)
+	}
+	if m := jbody(v); m == nil || m["protected"] == true {
+		t.Fatalf("未登记昵称不应受保护: %v", v)
+	}
+
+	// 2) 后台登记大使(密码 bcrypt 落库)
+	v, err = call("adminAdd", mkCtx(nil, map[string]string{
+		"school": "邻校一中", "account": "amb01", "password": "1234", "nickname": "邻校大使小美"}))
+	if err != nil {
+		t.Fatalf("adminAdd 失败: %v", err)
+	}
+	if m, _ := v.(map[string]any); m == nil || m["redirect"] == "" {
+		t.Fatalf("adminAdd 应重定向回管理页: %v", v)
+	}
+	var ambID int64
+	var hash, school string
+	if err := database.DB.QueryRow(
+		"SELECT id, school, password FROM pl_embassy_ambassador WHERE nickname='邻校大使小美'").
+		Scan(&ambID, &school, &hash); err != nil {
+		t.Fatalf("大使未入库: %v", err)
+	}
+	if school != "邻校一中" {
+		t.Fatalf("school 异常: %q", school)
+	}
+	if strings.EqualFold(hash, "1234") || len(hash) < 20 {
+		t.Fatalf("密码应为 bcrypt 哈希,实际: %q", hash)
+	}
+
+	// 3) 已登记昵称 → 受保护
+	v, err = call("apiLookup", mkCtx(map[string]string{"nickname": "邻校大使小美"}, nil))
+	if err != nil {
+		t.Fatalf("apiLookup 失败: %v", err)
+	}
+	if m := jbody(v); m == nil || m["protected"] != true || m["school"] != "邻校一中" {
+		t.Fatalf("已登记昵称应受保护: %v", v)
+	}
+
+	// 4) 错误密码 → 验证失败
+	v, err = call("apiVerify", mkCtx(nil, map[string]string{
+		"nickname": "邻校大使小美", "account": "amb01", "password": "wrong"}))
+	if err != nil {
+		t.Fatalf("apiVerify 失败: %v", err)
+	}
+	if m := jbody(v); m == nil {
+		t.Fatalf("apiVerify 返回异常: %v", v)
+	} else if ok, _ := m["ok"].(bool); ok {
+		t.Fatalf("错误密码不应通过验证: %v", v)
+	}
+
+	// 5) 正确密码 → 验证通过,验证记录落库
+	v, err = call("apiVerify", mkCtx(nil, map[string]string{
+		"nickname": "邻校大使小美", "account": "amb01", "password": "1234"}))
+	if err != nil {
+		t.Fatalf("apiVerify 失败: %v", err)
+	}
+	if m := jbody(v); m == nil {
+		t.Fatalf("apiVerify 返回异常: %v", v)
+	} else if ok, _ := m["ok"].(bool); !ok {
+		t.Fatalf("正确密码应通过验证: %v", v)
+	}
+	if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_verify WHERE fingerprint='fp-test'") != 1 {
+		t.Fatal("验证记录未落库")
+	}
+
+	// 6) status:已验证指纹 → verified
+	v, err = call("apiStatus", mkCtx(map[string]string{"nickname": "邻校大使小美"}, nil))
+	if err != nil {
+		t.Fatalf("apiStatus 失败: %v", err)
+	}
+	if m := jbody(v); m == nil {
+		t.Fatalf("apiStatus 返回异常: %v", v)
+	} else if verified, _ := m["verified"].(bool); !verified {
+		t.Fatalf("status 应为已验证: %v", v)
+	}
+
+	// 7) post_created 处理(同步调用 handler)→ 认证落库
+	if _, err := call("onPostCreated", map[string]any{
+		"post_id": 101, "nickname": "邻校大使小美", "content": "你好"}); err != nil {
+		t.Fatalf("onPostCreated 失败: %v", err)
+	}
+	if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_cert WHERE post_id=101") != 1 {
+		t.Fatal("认证记录未落库")
+	}
+
+	// 8) filter.card → 徽章追加
+	card := map[string]any{"id": int64(101), "badges": []any{}}
+	out := ApplyFilterValue("filter.card", card, nil)
+	om, _ := out.(map[string]any)
+	if om == nil {
+		t.Fatalf("filter.card 无返回: %v", out)
+	}
+	badges, _ := om["badges"].([]any)
+	found := false
+	for _, b := range badges {
+		if s, _ := b.(string); s == "邻校一中大使官方认证" {
+			found = true
+		}
+	}
+	if !found {
+		t.Fatalf("徽章未追加: %v", om["badges"])
+	}
+
+	// 9) 无验证时同昵称发帖 → 不落认证
+	if _, err := database.DB.Exec("DELETE FROM pl_embassy_verify"); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := call("onPostCreated", map[string]any{
+		"post_id": 102, "nickname": "邻校大使小美", "content": "仿冒"}); err != nil {
+		t.Fatalf("onPostCreated 失败: %v", err)
+	}
+	if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_cert WHERE post_id=102") != 0 {
+		t.Fatal("无有效验证不应落认证")
+	}
+
+	// 10) 删除大使 → 连带清理验证记录
+	if _, err := call("adminDelete", mkCtx(nil, map[string]string{"id": "1"})); err != nil {
+		t.Fatalf("adminDelete 失败: %v", err)
+	}
+	if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_ambassador") != 0 {
+		t.Fatal("大使未删除")
+	}
+	if models.QueryInt("SELECT COUNT(1) FROM pl_embassy_verify") != 0 {
+		t.Fatal("验证记录未连带清理")
+	}
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index c843b58..0823e3d 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -18,6 +18,7 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"html"
 	"io"
 	"net/http"
 	"os"
@@ -307,13 +308,27 @@ func SaveConfig(p *Plugin, values map[string]string) error {
 // reConfigVar 配置占位符 {config:key}
 var reConfigVar = regexp.MustCompile(`\{config:[A-Za-z0-9_\-]+\}`)
 
-// expand 展开 {config:key} 占位符
+// expand 展开 {config:key} 占位符(用于昵称名单、标签、提示语等非 HTML 场景,原样替换)
 func expand(s string, cfg map[string]string) string {
+	return expandIn(s, cfg, false)
+}
+
+// expandHTML 展开 {config:key} 占位符,配置值做 HTML 转义,
+// 防止管理员配置值(如 {config:url})携带引号/尖括号闭合标签属性造成存储型 XSS
+func expandHTML(s string, cfg map[string]string) string {
+	return expandIn(s, cfg, true)
+}
+
+func expandIn(s string, cfg map[string]string, esc bool) string {
 	if s == "" || !strings.Contains(s, "{config:") {
 		return s
 	}
 	return reConfigVar.ReplaceAllStringFunc(s, func(m string) string {
-		return cfg[m[len("{config:"):len(m)-1]]
+		v := cfg[m[len("{config:"):len(m)-1]]
+		if esc {
+			return html.EscapeString(v)
+		}
+		return v
 	})
 }
 
@@ -573,7 +588,7 @@ func CallHTML(hook string) string {
 		cfg := GetConfig(p)
 		for _, h := range p.Hooks {
 			if h.Hook == hook && h.Type == "html" {
-				b.WriteString(expand(h.HTML, cfg))
+				b.WriteString(expandHTML(h.HTML, cfg))
 			}
 		}
 	}
@@ -596,9 +611,9 @@ func Notify(event string, payload map[string]any) {
 }
 
 func postWebhook(url, event string, payload map[string]any) {
-	payload["event"] = event
-	payload["time"] = models.Now()
-	body, _ := json.Marshal(payload)
+	// 拷贝后再补充元数据字段:payload 会被多个 webhook goroutine 与内核共享,
+	// 直接写入原 map 会触发并发写 panic
+	body, _ := json.Marshal(withMeta(event, payload))
 	client := &http.Client{Timeout: 10 * time.Second}
 	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
 	if err != nil {
diff --git a/internal/util/util.go b/internal/util/util.go
index 5ebf984..0fd8ecc 100644
--- a/internal/util/util.go
+++ b/internal/util/util.go
@@ -19,6 +19,7 @@ import (
 	"log"
 	"math"
 	"mime/multipart"
+	"net"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -109,21 +110,38 @@ type captchaEntry struct {
 
 var captchas sync.Map
 
+func init() {
+	// 定期回收过期验证码,防止匿名刷 /api/captcha 导致内存无限增长
+	go func() {
+		for {
+			time.Sleep(5 * time.Minute)
+			now := time.Now().Unix()
+			captchas.Range(func(k, v any) bool {
+				if e, ok := v.(captchaEntry); ok && e.Exp <= now {
+					captchas.Delete(k)
+				}
+				return true
+			})
+		}
+	}()
+}
+
 // NewCaptcha 生成验证码,返回 ID 与内联 SVG
 func NewCaptcha() (string, string) {
-	code := fmt.Sprintf("%04d", time.Now().UnixNano()%10000)
+	b := make([]byte, 2)
+	_, _ = rand.Read(b)
+	code := fmt.Sprintf("%04d", (int(b[0])<<8|int(b[1]))%10000)
 	id := RandomHex(8)
 	captchas.Store(id, captchaEntry{Code: code, Exp: time.Now().Add(5 * time.Minute).Unix()})
 	return id, captchaSVG(code)
 }
 
-// VerifyCaptcha 校验并消费验证码(一次性)
+// VerifyCaptcha 校验并消费验证码(一次性;原子取出+删除,防并发重放)
 func VerifyCaptcha(id, code string) bool {
-	v, ok := captchas.Load(id)
+	v, ok := captchas.LoadAndDelete(id)
 	if !ok {
 		return false
 	}
-	captchas.Delete(id) // 一次性使用
 	e := v.(captchaEntry)
 	return e.Exp > time.Now().Unix() && e.Code == strings.TrimSpace(code)
 }
@@ -333,21 +351,42 @@ func io_ReadAll(r interface{ Read([]byte) (int, error) }) ([]byte, error) {
 
 // ============ 请求辅助 ============
 
-// ClientIP 提取客户端真实 IP(支持反代 X-Forwarded-For / X-Real-IP)
+// ClientIP 提取客户端真实 IP(支持反代 X-Forwarded-For / X-Real-IP)。
+// 仅当直连来源为回环/内网地址(即确实经过本机反向代理转发)时才采信代理头,
+// 防止服务直接暴露时攻击者伪造 X-Forwarded-For 绕过 IP 封禁与失败锁定。
 func ClientIP(r *http.Request) string {
-	if v := r.Header.Get("X-Forwarded-For"); v != "" {
-		return strings.TrimSpace(strings.Split(v, ",")[0])
-	}
-	if v := r.Header.Get("X-Real-IP"); v != "" {
-		return v
+	remote := remoteIP(r)
+	if isTrustedProxy(remote) {
+		if v := r.Header.Get("X-Forwarded-For"); v != "" {
+			return strings.TrimSpace(strings.Split(v, ",")[0])
+		}
+		if v := r.Header.Get("X-Real-IP"); v != "" {
+			return strings.TrimSpace(v)
+		}
 	}
+	return remote
+}
+
+// remoteIP 取 TCP 连接的直连对端地址(去掉端口)
+func remoteIP(r *http.Request) string {
 	host := r.RemoteAddr
 	if i := strings.LastIndex(host, ":"); i > 0 {
 		host = host[:i]
 	}
+	// 去掉 IPv6  zone/方括号
+	host = strings.Trim(host, "[]")
 	return host
 }
 
+// isTrustedProxy 判断直连对端是否为可信代理(回环或 RFC 私有/链路本地地址)
+func isTrustedProxy(ip string) bool {
+	parsed := net.ParseIP(ip)
+	if parsed == nil {
+		return false
+	}
+	return parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast()
+}
+
 // FingerprintOf 获取请求携带的浏览器指纹(前端 JS 计算后随请求提交)
 func FingerprintOf(r *http.Request) string {
 	fp := r.Header.Get("X-Fingerprint")
diff --git a/plugins/embassy/admin.html b/plugins/embassy/admin.html
new file mode 100644
index 0000000..52e446d
--- /dev/null
+++ b/plugins/embassy/admin.html
@@ -0,0 +1,154 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>友校大使馆 · 后台管理</title>
+<style>
+  * { margin: 0; padding: 0; box-sizing: border-box; }
+  body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+    background: #f5f6fa; color: #23262f; font-size: 14px; line-height: 1.6;
+    padding: 28px 20px 60px;
+  }
+  .wrap { max-width: 980px; margin: 0 auto; }
+  .head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
+  .head .logo {
+    width: 40px; height: 40px; border-radius: 12px; flex: none;
+    background: linear-gradient(135deg, #ff7a9c, #ff5c8a);
+    display: flex; align-items: center; justify-content: center; color: #fff;
+  }
+  h1 { font-size: 20px; font-weight: 700; }
+  .sub { color: #8a8f9c; font-size: 13px; margin-bottom: 18px; }
+  .card {
+    background: #fff; border-radius: 14px; padding: 18px 20px; margin-bottom: 16px;
+    box-shadow: 0 1px 3px rgba(20, 24, 40, .06), 0 8px 24px -18px rgba(20, 24, 40, .12);
+  }
+  .card h3 { font-size: 15px; font-weight: 700; margin-bottom: 12px; }
+  .tip {
+    background: #fff7f9; border: 1px solid #ffe1e9; color: #8a5a68;
+    border-radius: 12px; padding: 12px 16px; font-size: 13px; line-height: 1.8; margin-bottom: 16px;
+  }
+  .tip b { color: #ff5c8a; }
+  form.add { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
+  form.add label { flex: 1 1 160px; min-width: 150px; font-size: 12.5px; color: #5a5f6d; font-weight: 600; }
+  form.add input {
+    width: 100%; margin-top: 5px; padding: 10px 12px; font-size: 14px; font-family: inherit;
+    border: 1px solid #e3e6ee; border-radius: 10px; background: #fbfbfd; color: inherit; outline: none;
+    transition: border-color .15s, box-shadow .15s;
+  }
+  form.add input:focus { border-color: #ff7a9c; box-shadow: 0 0 0 3px rgba(255, 122, 156, .15); background: #fff; }
+  .btn {
+    border: 0; cursor: pointer; font-family: inherit; font-weight: 600; font-size: 14px;
+    padding: 10px 22px; border-radius: 10px; color: #fff;
+    background: linear-gradient(135deg, #ff7a9c, #ff5c8a);
+    box-shadow: 0 4px 14px -4px rgba(255, 92, 138, .5);
+    transition: transform .1s, box-shadow .15s;
+  }
+  .btn:hover { transform: translateY(-1px); box-shadow: 0 6px 18px -4px rgba(255, 92, 138, .55); }
+  .btn:active { transform: translateY(0); }
+  .btn-del {
+    border: 0; background: none; cursor: pointer; font-family: inherit; font-size: 13px;
+    color: #e5484d; padding: 2px 6px; border-radius: 6px;
+  }
+  .btn-del:hover { background: #fdecec; }
+  .table-scroll { overflow-x: auto; }
+  table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
+  th {
+    text-align: left; font-size: 12px; color: #8a8f9c; font-weight: 600;
+    padding: 8px 10px; border-bottom: 1px solid #eef0f4; white-space: nowrap;
+  }
+  td { padding: 10px; border-bottom: 1px solid #f3f4f8; }
+  tr:last-child td { border-bottom: 0; }
+  tbody tr:hover { background: #fafafc; }
+  .nick { font-weight: 700; }
+  .muted { color: #9aa0ae; }
+  .empty { text-align: center; color: #9aa0ae; padding: 22px 10px !important; }
+  .badge {
+    display: inline-block; font-size: 12px; font-weight: 600; color: #ff5c8a;
+    background: #fff0f4; border: 1px solid #ffdbe5; border-radius: 999px; padding: 1px 10px;
+  }
+  a { color: #ff5c8a; text-decoration: none; }
+  a:hover { text-decoration: underline; }
+  .count { font-weight: 400; color: #9aa0ae; font-size: 13px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <div class="head">
+    <div class="logo">
+      <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 21v-6h6v6"/><path d="M12 3v4"/></svg>
+    </div>
+    <h1>友校大使馆</h1>
+  </div>
+  <p class="sub">登记各友校大使账号,发帖人使用大使专属昵称时将弹窗验证身份,通过后帖子自动携带官方认证标识。</p>
+
+  <div class="tip">
+    在此登记各友校大使的账号信息。发帖人使用「<b>大使专属昵称</b>」发布时,前台会弹窗要求验证身份;
+    验证通过后在有效期内(后台「插件设置」可调)以该昵称发帖,帖子将自动携带
+    「<b>xx学校大使官方认证</b>」标识。未通过验证的仿冒昵称不会获得任何标识。
+  </div>
+
+  <div class="card">
+    <h3>登记友校大使</h3>
+    <form class="add" method="post" action="/x/embassy/admin/add">
+      <input type="hidden" name="_csrf" value="{{.csrf}}">
+      <label>学校<input name="school" required placeholder="如:邻校一中"></label>
+      <label>账号<input name="account" required autocomplete="off" placeholder="大使登录账号"></label>
+      <label>密码<input name="password" required minlength="4" autocomplete="new-password" placeholder="至少 4 位"></label>
+      <label>大使专属昵称<input name="nickname" required placeholder="发帖时使用的昵称"></label>
+      <button class="btn" type="submit">添加</button>
+    </form>
+  </div>
+
+  <div class="card">
+    <h3>已登记大使 <span class="count">({{.ambs | len}})</span></h3>
+    <div class="table-scroll">
+      <table>
+        <thead><tr><th>学校</th><th>账号</th><th>大使专属昵称</th><th>登记时间</th><th>操作</th></tr></thead>
+        <tbody>
+        {{range .ambs}}
+          <tr>
+            <td>{{.school}}</td>
+            <td>{{.account}}</td>
+            <td class="nick">{{.nickname}}</td>
+            <td class="muted">{{.created_at}}</td>
+            <td>
+              <form method="post" action="/x/embassy/admin/delete" style="display:inline" onsubmit="return confirm('确定删除该大使?删除后该昵称不再需要验证。')">
+                <input type="hidden" name="_csrf" value="{{$.csrf}}">
+                <input type="hidden" name="id" value="{{.id}}">
+                <button class="btn-del" type="submit">删除</button>
+              </form>
+            </td>
+          </tr>
+        {{else}}
+          <tr><td colspan="5" class="empty">暂无登记,使用上方表单添加第一位友校大使</td></tr>
+        {{end}}
+        </tbody>
+      </table>
+    </div>
+  </div>
+
+  <div class="card">
+    <h3>最近认证的帖子 <span class="count">({{.certs | len}})</span></h3>
+    <div class="table-scroll">
+      <table>
+        <thead><tr><th>帖子</th><th>学校</th><th>大使昵称</th><th>认证时间</th></tr></thead>
+        <tbody>
+        {{range .certs}}
+          <tr>
+            <td><a href="/post/{{.post_id}}" target="_blank">#{{.post_id}}</a></td>
+            <td><span class="badge">{{.school}} · 大使官方认证</span></td>
+            <td>{{.nickname}}</td>
+            <td class="muted">{{.created_at}}</td>
+          </tr>
+        {{else}}
+          <tr><td colspan="4" class="empty">暂无认证记录</td></tr>
+        {{end}}
+        </tbody>
+      </table>
+    </div>
+  </div>
+</div>
+</body>
+</html>
diff --git a/plugins/embassy/main.js b/plugins/embassy/main.js
new file mode 100644
index 0000000..d3670fa
--- /dev/null
+++ b/plugins/embassy/main.js
@@ -0,0 +1,318 @@
+// 友校大使馆 —— 应用型插件(kind=app)
+//
+// 功能:
+//   1. 管理员在后台登记各友校大使(学校 / 账号 / 密码 / 大使专属昵称)
+//   2. 发帖人使用大使专属昵称时,前台弹窗要求验证身份(账号 + 密码)
+//   3. 验证通过后在有效期内以该昵称发帖,帖子自动带上「xx学校大使官方认证」标识
+//
+// 实现要点:
+//   - clv.table       三张表:ambassador(大使)、verify(验证记录)、cert(帖子认证)
+//   - clv.route       前台 lookup/status/verify API + 后台增删
+//   - clv.on          订阅 post_created 事件,验证有效则落认证记录
+//   - clv.filter      filter.card 给卡片追加认证徽章(列表 / 详情 / API 通用)
+//   - clv.slot        body.end 注入前台拦截与弹窗脚本
+//
+// 安全说明:未通过验证的仿冒昵称可以发帖,但不会有任何认证标识;
+// 认证标识仅在验证有效期内由 post_created 事件落库,伪造不了。
+
+var T_AMB = clv.db.table("ambassador"); // pl_embassy_ambassador
+var T_VERIFY = clv.db.table("verify");  // pl_embassy_verify
+var T_CERT = clv.db.table("cert");      // pl_embassy_cert
+
+function setup() {
+  // 1) 建表(幂等)
+  clv.table("ambassador", {
+    school: "string",
+    account: "string",
+    password: "string", // bcrypt 哈希
+    nickname: "string",
+    created_at: "time"
+  }, { unique: [["nickname"]], indexes: [["account"]] });
+
+  clv.table("verify", {
+    ambassador_id: "int",
+    fingerprint: "string",
+    ip: "string",
+    created_at: "time"
+  }, { indexes: [["fingerprint"], ["ambassador_id"]] });
+
+  clv.table("cert", {
+    post_id: "int",
+    ambassador_id: "int",
+    school: "string",
+    nickname: "string",
+    created_at: "time"
+  }, { unique: [["post_id"]], indexes: [["post_id"]] });
+
+  // 2) 前台 API(/x/embassy/api/...)
+  clv.route("GET", "/api/lookup", "apiLookup", { auth: "none", json: true });
+  clv.route("GET", "/api/status", "apiStatus", { auth: "none", json: true });
+  clv.route("POST", "/api/verify", "apiVerify", { auth: "none", json: true });
+
+  // 3) 后台管理(/admin/plugins/embassy/)
+  clv.adminPage("/", "adminMain", { perm: "plugins" });
+  clv.adminMenu({ label: "友校大使馆", path: "/", perm: "plugins", order: 55 });
+  clv.route("POST", "/admin/add", "adminAdd", { auth: "admin", json: true });
+  clv.route("POST", "/admin/delete", "adminDelete", { auth: "admin", json: true });
+
+  // 4) 前台:发帖拦截 + 身份验证弹窗
+  clv.slot("body.end", "clientScript");
+
+  // 5) 认证落库 + 徽章展示
+  clv.on("post_created", "onPostCreated");
+  clv.filter("filter.card", 50, "cardBadge");
+}
+
+/* ---------------- 工具 ---------------- */
+
+function validMS() {
+  var m = Number(clv.plugin.config("valid_minutes"));
+  if (!m || m <= 0) m = 10;
+  return m * 60000;
+}
+
+function nowISO() { return new Date().toISOString(); }
+
+function byNickname(nick) {
+  return clv.db.get("SELECT id, school, account, password, nickname FROM " + T_AMB + " WHERE nickname=?", nick);
+}
+
+// 某指纹对某大使是否存在有效期内的验证记录
+function hasValidVerify(ambID, fingerprint) {
+  if (!ambID) return false;
+  if (fingerprint) {
+    var r = clv.db.get(
+      "SELECT id FROM " + T_VERIFY + " WHERE ambassador_id=? AND fingerprint=? AND created_at>=?",
+      ambID, fingerprint, new Date(Date.now() - validMS()).toISOString());
+    if (r) return true;
+  }
+  return false;
+}
+
+/* ---------------- 前台 API ---------------- */
+
+// GET /api/lookup?nickname=xxx → 该昵称是否为大使专属昵称
+function apiLookup(ctx) {
+  var nick = String(ctx.req.query.nickname || "").trim();
+  if (!nick) return { json: { ok: true, protected: false } };
+  var amb = byNickname(nick);
+  return { json: { ok: true, protected: !!amb, school: amb ? amb.school : "" } };
+}
+
+// GET /api/status?nickname=xxx → 当前浏览器指纹是否已验证且仍在有效期
+function apiStatus(ctx) {
+  var nick = String(ctx.req.query.nickname || "").trim();
+  var amb = nick ? byNickname(nick) : null;
+  if (!amb) return { json: { ok: true, verified: false } };
+  return { json: { ok: true, verified: hasValidVerify(amb.id, ctx.req.fingerprint) } };
+}
+
+// POST /api/verify {nickname, account, password} → 校验大使账密,成功则记录验证
+function apiVerify(ctx) {
+  var body = ctx.req.json || {};
+  var nick = String(body.nickname || ctx.req.form.nickname || "").trim();
+  var account = String(body.account || ctx.req.form.account || "").trim();
+  var password = String(body.password || ctx.req.form.password || "");
+  if (!nick || !account || !password) {
+    return { status: 400, json: { ok: false, msg: "请填写账号和密码" } };
+  }
+  var amb = byNickname(nick);
+  if (!amb) return { status: 404, json: { ok: false, msg: "该昵称未登记大使身份" } };
+  if (account !== amb.account || !clv.crypto.verify(amb.password, password)) {
+    clv.log.info("大使验证失败:nickname=" + nick + " account=" + account + " ip=" + ctx.req.ip);
+    return { status: 403, json: { ok: false, msg: "账号或密码错误,验证失败" } };
+  }
+  var fp = ctx.req.fingerprint;
+  // 同一指纹重复验证时清理旧记录,避免堆积
+  clv.db.exec("DELETE FROM " + T_VERIFY + " WHERE ambassador_id=? AND fingerprint=?", amb.id, fp);
+  clv.db.exec("INSERT INTO " + T_VERIFY + "(ambassador_id,fingerprint,ip,created_at) VALUES(?,?,?,?)",
+    amb.id, fp, ctx.req.ip, nowISO());
+  clv.log.info("大使验证通过:" + amb.school + " / " + nick + " ip=" + ctx.req.ip);
+  return { json: { ok: true, school: amb.school } };
+}
+
+/* ---------------- 事件与过滤器 ---------------- */
+
+// post_created:昵称为大使昵称且验证仍有效 → 落认证记录(帖子获得徽章)
+function onPostCreated(payload) {
+  if (!payload) return;
+  var nick = String(payload.nickname || "").trim();
+  var postID = Number(payload.post_id || 0);
+  if (!nick || !postID) return;
+  var amb = byNickname(nick);
+  if (!amb) return;
+  // 验证与发帖之间没有指纹可比(事件不含指纹),以时间窗口为准
+  var v = clv.db.get("SELECT id FROM " + T_VERIFY + " WHERE ambassador_id=? AND created_at>=?",
+    amb.id, new Date(Date.now() - validMS()).toISOString());
+  if (!v) return;
+  if (clv.db.get("SELECT id FROM " + T_CERT + " WHERE post_id=?", postID)) return;
+  clv.db.exec("INSERT INTO " + T_CERT + "(post_id,ambassador_id,school,nickname,created_at) VALUES(?,?,?,?,?)",
+    postID, amb.id, amb.school, amb.nickname, nowISO());
+  clv.log.info("帖子 #" + postID + " 获得「" + amb.school + "大使官方认证」");
+}
+
+// filter.card:列表 / 详情 / API 卡片统一追加认证徽章
+function cardBadge(card) {
+  if (!card || !card.id) return card;
+  var cert = clv.db.get("SELECT school FROM " + T_CERT + " WHERE post_id=?", card.id);
+  if (!cert) return card;
+  var badges = card.badges;
+  if (!badges || typeof badges.length !== "number") badges = [];
+  var label = cert.school + "大使官方认证";
+  for (var i = 0; i < badges.length; i++) {
+    if (badges[i] === label) return card;
+  }
+  badges.push(label);
+  card.badges = badges;
+  return card;
+}
+
+/* ---------------- 后台管理 ---------------- */
+
+function adminMain(ctx) {
+  var ambs = clv.db.query(
+    "SELECT id, school, account, nickname, created_at FROM " + T_AMB + " ORDER BY id DESC LIMIT 200");
+  var certs = clv.db.query(
+    "SELECT post_id, school, nickname, created_at FROM " + T_CERT + " ORDER BY id DESC LIMIT 20");
+  return { template: "admin.html", data: { ambs: ambs, certs: certs, csrf: ctx.csrf } };
+}
+
+function adminAdd(ctx) {
+  var f = ctx.req.form;
+  var school = String(f.school || "").trim();
+  var account = String(f.account || "").trim();
+  var password = String(f.password || "");
+  var nickname = String(f.nickname || "").trim();
+  if (!school || !account || !password || !nickname) {
+    return { status: 400, body: "学校、账号、密码、昵称均不能为空" };
+  }
+  if (password.length < 4) return { status: 400, body: "密码至少 4 位" };
+  if (byNickname(nickname)) return { status: 400, body: "昵称「" + nickname + "」已被登记" };
+  var dup = clv.db.get("SELECT id FROM " + T_AMB + " WHERE account=?", account);
+  if (dup) return { status: 400, body: "账号「" + account + "」已存在" };
+  clv.db.exec("INSERT INTO " + T_AMB + "(school,account,password,nickname,created_at) VALUES(?,?,?,?,?)",
+    school, account, clv.crypto.bcrypt(password), nickname, nowISO());
+  clv.log.info("登记友校大使:" + school + " / " + nickname);
+  return { redirect: "/admin/plugins/embassy/" };
+}
+
+function adminDelete(ctx) {
+  var id = Number(ctx.req.form.id || 0);
+  if (id > 0) {
+    clv.db.exec("DELETE FROM " + T_AMB + " WHERE id=?", id);
+    clv.db.exec("DELETE FROM " + T_VERIFY + " WHERE ambassador_id=?", id);
+  }
+  return { redirect: "/admin/plugins/embassy/" };
+}
+
+/* ---------------- 前台脚本注入 ---------------- */
+
+function clientScript() {
+  return '<style>' +
+    '#embassy-mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(15,17,24,.55);z-index:99998;display:none;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}' +
+    '#embassy-box{width:100%;max-width:400px;background:var(--clv-card,#fff);color:var(--clv-text,#23262f);border-radius:var(--clv-radius,16px);box-shadow:0 18px 50px rgba(0,0,0,.28);padding:22px 20px;font-family:var(--clv-font,sans-serif);box-sizing:border-box}' +
+    '#embassy-box h3{margin:0 0 6px;font-size:17px;display:flex;align-items:center;gap:8px}' +
+    '#embassy-box .emb-sub{margin:0 0 14px;font-size:13px;line-height:1.6;color:var(--clv-muted,#8a8f9c)}' +
+    '#embassy-box .emb-school{color:var(--clv-accent,#ff7a9c);font-weight:600}' +
+    '#embassy-box input{width:100%;box-sizing:border-box;padding:11px 12px;margin-bottom:10px;border:1px solid var(--clv-border,#eceef4);border-radius:10px;font-size:14px;font-family:inherit;background:var(--clv-bg,#fff);color:inherit}' +
+    '#embassy-box input:focus{outline:none;border-color:var(--clv-accent,#ff7a9c)}' +
+    '#embassy-err{min-height:18px;font-size:12.5px;color:#e5484d;margin:0 0 8px}' +
+    '#embassy-box .emb-acts{display:flex;gap:10px}' +
+    '#embassy-box .emb-acts button{flex:1;padding:11px 14px;border-radius:10px;font-size:15px;font-family:inherit;border:1px solid var(--clv-border,#eceef4);background:transparent;color:inherit;cursor:pointer}' +
+    '#embassy-box #embassy-ok{flex:1.4;border:0;background:var(--clv-accent,#ff7a9c);color:#fff;font-weight:600}' +
+    '#embassy-box #embassy-ok:disabled{opacity:.5;cursor:not-allowed}' +
+    '</style>' +
+    '<div id="embassy-mask" role="dialog" aria-modal="true"><div id="embassy-box">' +
+    '<h3><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M9 12l2 2 4-4"/></svg>大使身份验证</h3>' +
+    '<p class="emb-sub">「<span id="embassy-nick"></span>」是 <span class="emb-school" id="embassy-school"></span> 大使专属昵称,请输入大使账号与密码完成验证。</p>' +
+    '<input id="embassy-account" type="text" autocomplete="off" placeholder="大使账号">' +
+    '<input id="embassy-password" type="password" placeholder="大使密码">' +
+    '<p id="embassy-err"></p>' +
+    '<div class="emb-acts"><button type="button" id="embassy-no">取消</button><button type="button" id="embassy-ok">验证并发布</button></div>' +
+    '</div></div>' +
+    '<scr' + 'ipt>(function () {' +
+    'var mask, nick, school, acc, pwd, err, okBtn, curForm = null;' +
+    'function q(id) { return document.getElementById(id); }' +
+    'function csrf() {' +
+    // clv_csrf Cookie 为 HttpOnly,JS 不可读;改从服务端渲染的表单隐藏字段取令牌
+    '  var f = document.getElementById("compose-form");' +
+    '  var i = f ? f.querySelector("[name=_csrf]") : null;' +
+    '  if (!i || !i.value) { i = document.querySelector("form input[name=_csrf]"); }' +
+    '  return i ? i.value : "";' +
+    '}' +
+    'function fp() {' +
+    // 服务端指纹取自 X-Fingerprint 头;优先复用 app.js 的 CLV.fingerprint(),不可用则本地缓存一个稳定值
+    '  try { if (window.CLV && typeof CLV.fingerprint === "function") { return String(CLV.fingerprint() || ""); } } catch (e) {}' +
+    '  try {' +
+    '    var v = localStorage.getItem("clv_embassy_fp");' +
+    '    if (!v) { v = "emb-" + Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem("clv_embassy_fp", v); }' +
+    '    return v;' +
+    '  } catch (e2) { return "emb-fallback"; }' +
+    '}' +
+    'function ajax(method, url, data, cb) {' +
+    '  var x = new XMLHttpRequest();' +
+    '  x.open(method, url, true);' +
+    '  x.setRequestHeader("X-Fingerprint", fp());' +
+    '  x.setRequestHeader("X-Requested-With", "XMLHttpRequest");' +
+    '  if (data) {' +
+    '    x.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");' +
+    '    x.setRequestHeader("X-CSRF-Token", csrf());' +
+    '  }' +
+    '  x.onload = function () { var d = null; try { d = JSON.parse(x.responseText); } catch (e) {} cb(d, x.status); };' +
+    '  x.onerror = function () { cb(null, 0); };' +
+    '  x.send(data || null);' +
+    '}' +
+    'function submitNow(f) { f.__embassyOk = 1; if (typeof f.requestSubmit === "function") { f.requestSubmit(); } else { f.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); } }' +
+    'function close() { if (mask) { mask.style.display = "none"; } document.body.style.overflow = ""; curForm = null; }' +
+    'function show(n, sch, f) {' +
+    '  curForm = f; q("embassy-nick").textContent = n; q("embassy-school").textContent = sch;' +
+    '  q("embassy-account").value = ""; q("embassy-password").value = "";' +
+    '  q("embassy-err").textContent = ""; q("embassy-ok").disabled = false;' +
+    '  mask.style.display = "flex"; document.body.style.overflow = "hidden";' +
+    '  setTimeout(function () { q("embassy-account").focus(); }, 60);' +
+    '}' +
+    'function verify() {' +
+    '  var a = q("embassy-account").value.trim(), p = q("embassy-password").value;' +
+    '  if (!a || !p) { q("embassy-err").textContent = "请填写账号和密码"; return; }' +
+    '  okBtn.disabled = true; q("embassy-err").textContent = "验证中…";' +
+    '  var body = "nickname=" + encodeURIComponent(q("embassy-nick").textContent) +' +
+    '    "&account=" + encodeURIComponent(a) + "&password=" + encodeURIComponent(p) +' +
+    '    "&_csrf=" + encodeURIComponent(csrf());' +
+    '  ajax("POST", "/x/embassy/api/verify", body, function (d, status) {' +
+    '    if (d && d.ok) { close(); if (curForm) { submitNow(curForm); } return; }' +
+    '    okBtn.disabled = false;' +
+    '    q("embassy-err").textContent = (d && d.msg) ? d.msg : "验证失败,请稍后再试";' +
+    '  });' +
+    '}' +
+    'function check(n, f) {' +
+    '  ajax("GET", "/x/embassy/api/lookup?nickname=" + encodeURIComponent(n), null, function (d) {' +
+    '    if (!d || !d.ok || !d.protected) { submitNow(f); return; }' +
+    '    ajax("GET", "/x/embassy/api/status?nickname=" + encodeURIComponent(n), null, function (s) {' +
+    '      if (s && s.ok && s.verified) { submitNow(f); return; }' +
+    '      show(n, d.school || "友校", f);' +
+    '    });' +
+    '  });' +
+    '}' +
+    'document.addEventListener("submit", function (ev) {' +
+    '  var f = ev.target;' +
+    '  if (!f || f.id !== "compose-form") { return; }' +
+    '  if (f.__embassyOk) { f.__embassyOk = 0; return; }' +
+    '  var input = f.querySelector("[name=nickname]");' +
+    '  var n = input ? String(input.value || "").trim() : "";' +
+    '  if (!n) { return; }' +
+    '  ev.preventDefault(); ev.stopImmediatePropagation();' +
+    '  check(n, f);' +
+    '}, true);' +
+    'document.addEventListener("DOMContentLoaded", function () {' +
+    '  mask = q("embassy-mask");' +
+    '  okBtn = q("embassy-ok");' +
+    '  q("embassy-no").addEventListener("click", close);' +
+    '  okBtn.addEventListener("click", verify);' +
+    '  q("embassy-password").addEventListener("keydown", function (e) { if (e.key === "Enter") { verify(); } });' +
+    '  mask.addEventListener("click", function (e) { if (e.target === mask) { close(); } });' +
+    '  document.addEventListener("keydown", function (e) {' +
+    '    if (e.key === "Escape" && mask && mask.style.display !== "none") { close(); }' +
+    '  });' +
+    '});' +
+    '})();</scr' + 'ipt>';
+}
diff --git a/plugins/embassy/plugin.json b/plugins/embassy/plugin.json
new file mode 100644
index 0000000..39dba4a
--- /dev/null
+++ b/plugins/embassy/plugin.json
@@ -0,0 +1,24 @@
+{
+  "kind": "app",
+  "name": "友校大使馆",
+  "slug": "embassy",
+  "version": "1.0.0",
+  "author": "ClearLove",
+  "description": "管理员在后台登记各友校大使账号;发帖人使用大使专属昵称时弹窗验证身份,验证通过后帖子自动带上「xx学校大使官方认证」标识",
+  "requires": "2.1.0",
+  "runtime": {
+    "engine": "js",
+    "entry": "main.js",
+    "timeout_ms": 3000
+  },
+  "permissions": ["db"],
+  "config": [
+    {
+      "key": "valid_minutes",
+      "label": "验证有效期(分钟)",
+      "type": "number",
+      "default": "10",
+      "help": "大使验证通过后,在该时长内以大使昵称发帖会自动携带官方认证标识"
+    }
+  ]
+}