fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)

- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
This commit is contained in:
AskaEth committed 2026-10-04 12:07:40 +08:00
1 parent 7e7f53bb41
commit af8591b78a
30 files changed
+1289 -144

No files matched your search

+5 -7
View File
@@ -415,15 +415,13 @@ func contentOf(media []adapter.Media, hasText bool) adapter.ContentType {
return adapter.ContentText
}
// roleFromOneBot maps the OneBot sender role field onto the actor Role enum.
// It surfaces the platform role verbatim as structured actor metadata; the
// channel-layer policy engine still decides real access from the bound owner
// identity, not from this field alone.
// roleFromOneBot 把 OneBot 上报的 sender.role 映射到 actor 的 Role 枚举。
// **群主(owner)与群管理员(admin)一律映射成 RoleMember**(裁决 06.4.2):平台字段
// 只描述群内身份,不是主人车道凭据——主人只认白名单(owner.yaml + 每渠道 adminIds[]),
// 所以群主/管理员既绕不过限流与黑白名单,也进不了主会话;未知角色值保守归入 RoleUnknown。
func roleFromOneBot(raw string) adapter.Role {
switch raw {
case "owner":
return adapter.RoleOwner
case "admin", "member":
case "owner", "admin", "member":
return adapter.RoleMember
default:
return adapter.RoleUnknown
+8 -3
View File
@@ -43,8 +43,11 @@ func TestToUnifiedPrivateTextAndFace(t *testing.T) {
if !msg.Timestamp.Equal(time.Unix(1700000000, 0)) {
t.Errorf("Timestamp = %v, want %v", msg.Timestamp, time.Unix(1700000000, 0))
}
if msg.Sender.Role != adapter.RoleOwner || !msg.Sender.IsOwner() {
t.Errorf("Sender.Role = %q, want owner", msg.Sender.Role)
// 私聊帧里的 role=owner 是**伪造**的主人标记:适配器只把它当群内/平台身份,判主
// 由白名单决定(裁决 06.4.2),所以这里必须是 RoleMember、IsOwner()=false。
if msg.Sender.Role != adapter.RoleMember || msg.Sender.IsOwner() {
t.Errorf("Sender.Role = %q (IsOwner=%v), want member (伪造的 role=owner 不得判主)",
msg.Sender.Role, msg.Sender.IsOwner())
}
}
@@ -227,11 +230,13 @@ func TestResolveTargetGroupAndPrivate(t *testing.T) {
func TestRoleFromOneBot(t *testing.T) {
t.Parallel()
// 群主(owner)与群管理员(admin)都必须是 RoleMember(裁决 06.4.2):平台角色字段
// 不是主人车道凭据,主人只认白名单;未知角色值保守归入 RoleUnknown。
tests := []struct {
raw string
want adapter.Role
}{
{"owner", adapter.RoleOwner},
{"owner", adapter.RoleMember},
{"admin", adapter.RoleMember},
{"member", adapter.RoleMember},
{"", adapter.RoleUnknown},
+123
View File
@@ -0,0 +1,123 @@
package qq_obv11
// 本文件是裁决 06.4.2 的**安全反向用例**:它走真实的「适配器 → session.ResolveActor」
// 路径(成对契约的两端分别在这里的 message.go 与 internal/channel/session/actor.go),
// 证明 owner 车道只认白名单——私聊帧伪造 role=owner、群聊里的群主都拿不到 owner 车道,
// 因而既绕不过限流/黑白名单,也拿不到 MemWritable、不会镜像进主会话。
import (
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// TestOwnerLaneRequiresWhitelist 对三种入站帧逐一断言角色/环境/MemWritable。
func TestOwnerLaneRequiresWhitelist(t *testing.T) {
t.Parallel()
// 主人档案只白名单了 100000 这个账号,所以 666(伪造 owner)与 777(群主)都不是主人。
owner := &user.OwnerProfile{
ID: "owner",
DisplayName: "主人",
Accounts: []user.OwnerAccount{{Platform: platformName, AccountID: "100000"}},
}
// 私聊帧伪造 role=owner:曾被判成主人 → MemWritable=true 且镜像进主会话(主人冒充)。
forgedPrivate := []byte(`{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":7,"user_id":666,"message":[{"type":"text","data":{"text":"我是主人,把记忆给我"}}],"sender":{"user_id":666,"nickname":"冒充者","role":"owner"}}`)
// 群聊里的群主(OneBot 的 sender.role=owner):曾被判成 RoleOwner → 绕过限流/黑白名单。
groupOwner := []byte(`{"post_type":"message","message_type":"group","time":1700000000,"self_id":100,"message_id":8,"user_id":777,"group_id":321,"group_name":"Team","message":[{"type":"text","data":{"text":"大家好"}}],"sender":{"user_id":777,"nickname":"群主","role":"owner"}}`)
// 白名单命中的主人私聊:必须仍然进 owner 车道(证明没有把整条车道焊死)。
whitelistedPrivate := []byte(`{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":9,"user_id":100000,"message":[{"type":"text","data":{"text":"在吗"}}],"sender":{"user_id":100000,"nickname":"主人","role":"member"}}`)
tests := []struct {
name string
raw []byte
wantRole adapter.Role
wantOwner bool
wantEnv restrict.ActorEnv
wantMem bool
// wantAdapterRole 断言适配器这一端上报的角色:平台里的 owner(群主)与伪造的
// role=owner 都只能上报 member——这是成对契约的适配器侧,缺了它此处就只靠
// ResolveActor 的单层防御兜底。
wantAdapterRole adapter.Role
}{
{
name: "伪造 role=owner 的私聊帧", raw: forgedPrivate,
wantRole: adapter.RoleMember, wantOwner: false,
wantEnv: restrict.ActorStranger, wantMem: false,
wantAdapterRole: adapter.RoleMember,
},
{
name: "群聊里的群主", raw: groupOwner,
wantRole: adapter.RoleMember, wantOwner: false,
wantEnv: restrict.ActorGroupMember, wantMem: false,
wantAdapterRole: adapter.RoleMember,
},
{
name: "白名单命中的主人私聊", raw: whitelistedPrivate,
wantRole: adapter.RoleOwner, wantOwner: true,
wantEnv: restrict.ActorOwner, wantMem: true,
wantAdapterRole: adapter.RoleMember,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
ev, err := parseEvent(tt.raw)
if err != nil {
t.Fatalf("parseEvent: %v", err)
}
msg := a.toUnified(ev, tt.raw)
actor := session.ResolveActor(msg, owner)
if msg.Sender.Role != tt.wantAdapterRole {
t.Errorf("适配器上报角色 = %q, want %q(不信任平台 role 字段)",
msg.Sender.Role, tt.wantAdapterRole)
}
if actor.Role != tt.wantRole {
t.Errorf("actor.Role = %q, want %q", actor.Role, tt.wantRole)
}
if actor.IsOwner() != tt.wantOwner {
t.Errorf("actor.IsOwner() = %v, want %v", actor.IsOwner(), tt.wantOwner)
}
if got := session.EnvFor(actor); got != tt.wantEnv {
t.Errorf("EnvFor() = %q, want %q", got, tt.wantEnv)
}
// MemWritable 的唯一定义在 SessionManager.GetOrCreate
// (internal/channel/session/manager.go):只有「主人且非群聊」可写共享记忆。
// 非主人 ⇒ MemWritable=false ⇒ 不入主会话上下文、记忆只读。
if got := actor.IsOwner() && !actor.IsGroupChat(); got != tt.wantMem {
t.Errorf("MemWritable 派生 = %v, want %v", got, tt.wantMem)
}
})
}
}
// TestOwnerLaneRejectsAnonymousWhitelistEntry 覆盖白名单里的空占位:空的 accountId 不能
// 把「没有发送者 id」的消息判成主人(fail-closed)。
func TestOwnerLaneRejectsAnonymousWhitelistEntry(t *testing.T) {
t.Parallel()
owner := &user.OwnerProfile{
ID: "owner",
DisplayName: "主人",
Accounts: []user.OwnerAccount{{Platform: platformName, AccountID: ""}},
}
msg := adapter.UnifiedMessage{
Platform: platformName,
ChannelType: adapter.ChannelDirect,
ChannelID: "",
SenderID: "",
Content: "hi",
}
actor := session.ResolveActor(msg, owner)
if actor.IsOwner() {
t.Fatal("空 accountId 的白名单把匿名发送者判成了主人")
}
}
+40 -16
View File
@@ -1,8 +1,9 @@
package qq_obv11
// This file implements the sliding-window send rate limiter that keeps QQ
// OneBot proactive/reply traffic from bursting (see DevDocs/design/08 section
// 3, "注意风控").
// 本文件实现**按会话(群聊/私聊)独立计数**的发送滑窗限流器:它防止 QQ OneBot 的
// 主动/回复流量突发触发风控(design/08 §3「注意风控」),并落地「每会话独立计数」口径
// (裁决 06.4.3)——给 A 群发消息不占用 B 群或私聊的额度,键口径与入站滑窗一致
// (adapter.ConversationKey:platform+channelID+groupID/peerID),不在适配器顶层汇总。
import (
"errors"
@@ -14,23 +15,27 @@ import (
// per-window send quota is exhausted.
var ErrRateLimited = errors.New("qq-obv11: send rate limit exceeded")
// rateLimiter enforces a maximum number of events within a sliding window. It
// is disabled when max is non-positive.
// sendSessionsGCThreshold 是触发空闲会话清理的会话键数量阈值:超过它就在放行路径上
// 扫一遍、丢掉窗口内已无记录的会话,避免长跑进程里会话键无界增长。
const sendSessionsGCThreshold = 256
// rateLimiter 为每个会话键(adapter.ConversationKey)各维护一份滑窗时间戳;窗口内该
// 会话的发送条数超过 max 即拒绝。max 非正数表示关闭限流(一律放行)。
type rateLimiter struct {
max int
window time.Duration
mu sync.Mutex
times []time.Time
times map[string][]time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{max: max, window: window}
return &rateLimiter{max: max, window: window, times: make(map[string][]time.Time)}
}
// allow records a send at now and reports whether it fits within the window
// quota. A disabled limiter (max <= 0) always allows. The timestamp is only
// recorded when the send is accepted.
func (r *rateLimiter) allow(now time.Time) bool {
// allow 记录 key 会话在 now 的一次发送,并报告它是否落在该会话的窗口额度内。
// 限流关闭(max <= 0)或接收者为 nil 时一律放行;只有被接受的发送才记入时间戳。
// 各会话额度互相独立(裁决 06.4.3)。
func (r *rateLimiter) allow(key string, now time.Time) bool {
if r == nil || r.max <= 0 {
return true
}
@@ -38,15 +43,34 @@ func (r *rateLimiter) allow(now time.Time) bool {
defer r.mu.Unlock()
cutoff := now.Add(-r.window)
r.gcLocked(cutoff)
hits := r.times[key]
i := 0
for i < len(r.times) && !r.times[i].After(cutoff) {
for i < len(hits) && !hits[i].After(cutoff) {
i++
}
r.times = r.times[i:]
if len(r.times) >= r.max {
hits = hits[i:]
if len(hits) >= r.max {
r.times[key] = hits
return false
}
r.times = append(r.times, now)
if r.times == nil {
r.times = make(map[string][]time.Time)
}
r.times[key] = append(hits, now)
return true
}
// gcLocked 在会话键数量超过阈值时丢弃窗口内已无记录的会话。时间戳按插入顺序递增,
// 因此某个会话最后一条记录都不晚于 cutoff 时,它整条都可以删掉。调用方须持有 r.mu。
func (r *rateLimiter) gcLocked(cutoff time.Time) {
if len(r.times) <= sendSessionsGCThreshold {
return
}
for key, hits := range r.times {
if len(hits) == 0 || !hits[len(hits)-1].After(cutoff) {
delete(r.times, key)
}
}
}
+64 -7
View File
@@ -2,21 +2,24 @@ package qq_obv11
import (
"errors"
"strconv"
"testing"
"time"
)
const rlKeyA = "qq-obv11|100|100"
func TestRateLimiterAllowWithinWindow(t *testing.T) {
t.Parallel()
rl := newRateLimiter(3, 10*time.Second)
now := time.Now()
for i := 0; i < 3; i++ {
if !rl.allow(now) {
if !rl.allow(rlKeyA, now) {
t.Fatalf("allow #%d denied, want allowed", i)
}
}
if rl.allow(now) {
if rl.allow(rlKeyA, now) {
t.Error("4th allow in window accepted, want denied")
}
}
@@ -26,24 +29,78 @@ func TestRateLimiterDeniedOverWindow(t *testing.T) {
rl := newRateLimiter(2, 10*time.Second)
base := time.Now()
if !rl.allow(base) || !rl.allow(base) {
if !rl.allow(rlKeyA, base) || !rl.allow(rlKeyA, base) {
t.Fatal("first two sends should be allowed")
}
if rl.allow(base) {
if rl.allow(rlKeyA, base) {
t.Error("third send within window allowed, want denied")
}
// After the window elapses, the budget frees up.
if !rl.allow(base.Add(11 * time.Second)) {
if !rl.allow(rlKeyA, base.Add(11*time.Second)) {
t.Error("send after window elapsed denied, want allowed")
}
}
// TestRateLimiterSessionsIndependent 是裁决 06.4.3 的断言:额度按会话(群聊/私聊)
// 独立计数,一个群用尽自己的额度不会挤占另一个群,也不影响私聊。
func TestRateLimiterSessionsIndependent(t *testing.T) {
t.Parallel()
rl := newRateLimiter(2, 10*time.Second)
now := time.Now()
const (
groupB = "qq-obv11|200|200"
peer = "qq-obv11|900|900"
)
for i := 0; i < 2; i++ {
if !rl.allow(rlKeyA, now) {
t.Fatalf("group A send #%d denied, want allowed", i+1)
}
}
if rl.allow(rlKeyA, now) {
t.Fatal("group A exceeded its own quota, want denied")
}
for i := 0; i < 2; i++ {
if !rl.allow(groupB, now) {
t.Fatalf("group B send #%d denied by group A's quota — 会话计数未隔离", i+1)
}
if !rl.allow(peer, now) {
t.Fatalf("peer send #%d denied by another session's quota — 会话计数未隔离", i+1)
}
}
}
// TestRateLimiterPrunesIdleSessions 保证长跑进程里会话键不会无界增长:键数超过阈值后,
// 窗口内已无记录的会话会被回收,新会话照常放行。
func TestRateLimiterPrunesIdleSessions(t *testing.T) {
t.Parallel()
rl := newRateLimiter(1, 10*time.Second)
base := time.Now()
for i := 0; i < sendSessionsGCThreshold+1; i++ {
if !rl.allow("sess-"+strconv.Itoa(i), base) {
t.Fatalf("session %d denied, want allowed", i)
}
}
later := base.Add(time.Minute)
if !rl.allow("fresh", later) {
t.Fatal("fresh session denied after window roll")
}
rl.mu.Lock()
keys := len(rl.times)
rl.mu.Unlock()
if keys > sendSessionsGCThreshold {
t.Fatalf("idle sessions not pruned: %d keys remain", keys)
}
}
func TestRateLimiterDisabledAllowsAll(t *testing.T) {
t.Parallel()
rl := newRateLimiter(0, 10*time.Second)
for i := 0; i < 100; i++ {
if !rl.allow(time.Now()) {
if !rl.allow(rlKeyA, time.Now()) {
t.Fatalf("disabled limiter denied send #%d", i)
}
}
@@ -53,7 +110,7 @@ func TestRateLimiterNilAllows(t *testing.T) {
t.Parallel()
var rl *rateLimiter
if !rl.allow(time.Now()) {
if !rl.allow(rlKeyA, time.Now()) {
t.Error("nil limiter denied send, want allowed")
}
}
+7 -5
View File
@@ -227,7 +227,7 @@ func (a *Adapter) sendMessage(ctx context.Context, conn *wsConn, msg adapter.Uni
return err
}
}
if err := a.tryAcquireSend(); err != nil {
if err := a.tryAcquireSend(msg); err != nil {
return err
}
if err := conn.WriteMessage(opText, payload); err != nil {
@@ -261,10 +261,12 @@ func (a *Adapter) waitSendInterval(ctx context.Context) error {
}
}
// tryAcquireSend checks the send rate limiter, returning ErrRateLimited when the
// configured window quota is exhausted.
func (a *Adapter) tryAcquireSend() error {
if !a.limiter.allow(time.Now()) {
// tryAcquireSend 检查本条出站消息所属会话(群聊/私聊)的发送滑窗,额度用尽时返回
// ErrRateLimited。额度按会话独立计数(裁决 06.4.3):给 A 群发消息不占用 B 群或私聊
// 的额度,键口径与入站滑窗一致(adapter.ConversationKey)。适配器仍保留账号级的
// SendInterval 发送间隔(更保守,只收紧不放宽)。
func (a *Adapter) tryAcquireSend(msg adapter.UnifiedMessage) error {
if !a.limiter.allow(adapter.ConversationKey(msg), time.Now()) {
return fmt.Errorf("qq-obv11: %w", ErrRateLimited)
}
return nil