From 7d8d16e4584cba43a036c6c0699b3660cbdbeefc Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 15 Apr 2026 22:14:55 +0800 Subject: [PATCH] feat: 5 defensive hardenings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConversationWindowManager: cap reserve token at 50% of effective max to prevent negative historyBudget on small-context models (8K/16K) - common.security.SecretEquals: new constant-time comparison utility (MessageDigest.isEqual wrapper) for secrets/tokens/signatures - WeixinChannelAdapter: migrate context_token comparison to SecretEquals - FeishuChannelAdapter: fail-fast on empty encrypt_key when connection_mode=webhook - TelegramChannelAdapter: sanitize attachment captions — strip control bytes (\p{Cc} except \t\r\n) + format chars (\p{Cf}) + 4096 char cap - AgentGraphBuilder: fallback Anthropic max_tokens to 4096 on null/0/negative Tests: SecretEqualsTest (5) + TelegramCaptionSanitizeTest (5) — all green. --- .../vip/mate/agent/AgentGraphBuilder.java | 11 +++- .../context/ConversationWindowManager.java | 10 +++ .../channel/feishu/FeishuChannelAdapter.java | 12 +++- .../telegram/TelegramChannelAdapter.java | 27 +++++++- .../channel/weixin/WeixinChannelAdapter.java | 4 +- .../mate/common/security/SecretEquals.java | 45 +++++++++++++ .../telegram/TelegramCaptionSanitizeTest.java | 63 +++++++++++++++++++ .../common/security/SecretEqualsTest.java | 50 +++++++++++++++ 8 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/common/security/SecretEquals.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/telegram/TelegramCaptionSanitizeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/common/security/SecretEqualsTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index ec040ddf..bf9733bf 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -991,9 +991,16 @@ public class AgentGraphBuilder { } else if (runtimeModel.getTopP() != null) { builder.topP(runtimeModel.getTopP()); } - if (runtimeModel.getMaxTokens() != null) { - builder.maxTokens(runtimeModel.getMaxTokens()); + // RFC-025 Change 5: 非正值 maxTokens 会被 Anthropic API 直接拒绝;本地提前拦截 + // 并 fallback 到 4096,避免错误信息在运行时才暴露、也防止坏配置透传 + Integer configuredMax = runtimeModel.getMaxTokens(); + if (configuredMax != null && configuredMax > 0) { + builder.maxTokens(configuredMax); } else { + if (configuredMax != null) { + log.warn("Ignoring non-positive Anthropic maxTokens={} for model {}; falling back to 4096", + configuredMax, runtimeModel.getModelName()); + } builder.maxTokens(4096); } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 46d7a84a..eeea1ee5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -141,6 +141,16 @@ public class ConversationWindowManager { // 可用于历史的 token 预算 = max - system - currentMsg - 安全余量 int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05); + // RFC-025 Change 1: reserve 硬封顶到 effectiveMax 的 50%。 + // 小上下文模型(Ollama 16K、本地 8K)下,systemTokens + currentMsgTokens 很容易 + // 接近或超过 effectiveMax,不封顶会让 historyBudget 变负数导致死循环压缩 + // (压缩目标比压缩前还大 → 压缩后又触发压缩)。 + int reservedCap = Math.max(1024, effectiveMax / 2); + if (reservedTokens > reservedCap) { + log.warn("[ConversationWindow] 预留 token {} 超过上下文窗口 50% {},封顶至 {}", + reservedTokens, effectiveMax, reservedCap); + reservedTokens = reservedCap; + } int historyBudget = effectiveMax - reservedTokens; // 尾部保护 token 预算:阈值的 20%(与 Hermes 一致) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index eb63fd73..7ce7f803 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -110,7 +110,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { if ("websocket".equals(connectionMode)) { startWebSocket(appId, appSecret); } else { - log.info("[feishu] Webhook mode, waiting for callbacks at /api/v1/channels/webhook/feishu"); + // RFC-025 Change 3: webhook 模式下 encrypt_key 必须配置,否则 fail-fast 拒绝启动; + // 没有加密密钥 + 无签名校验 = 任何人可伪造 webhook 请求触发 agent 消息 + String encryptKey = getConfigString("encrypt_key", null); + if (encryptKey == null || encryptKey.isBlank()) { + throw new IllegalStateException( + "Feishu channel in webhook mode requires encrypt_key in configJson " + + "(fail-closed to prevent unauthenticated webhook abuse). " + + "Configure encrypt_key on the Feishu Event Subscriptions page and mirror " + + "it in this channel's configJson, or switch connection_mode to websocket."); + } + log.info("[feishu] Webhook mode (encrypt_key configured), waiting for callbacks at /api/v1/channels/webhook/feishu"); } log.info("[feishu] Feishu channel initialized: appId={}, mode={}, domain={}", diff --git a/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java index 2932a3dd..50b1c90b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java @@ -393,7 +393,10 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter { // 构建 contentParts List contentParts = new ArrayList<>(); String textContent = (String) message.get("text"); - String caption = (String) message.get("caption"); + // RFC-025 Change 4: caption 净化 — .epub / .mobi 等附件的二进制元数据会被 + // 某些 Telegram Bot API 版本塞进 caption,非 UTF-8 字节序列直接进 LLM prompt + // 会让 token 数爆涨、成本不可控 + String caption = sanitizeInboundText((String) message.get("caption")); boolean hasVoice = message.get("voice") != null; if (textContent != null && !textContent.isBlank()) { @@ -716,4 +719,26 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter { public String getChannelType() { return CHANNEL_TYPE; } + + /** RFC-025 Change 4 入站文本净化上限(防止 caption 含超长二进制撑爆 prompt)。 */ + private static final int INBOUND_TEXT_MAX = 4096; + + /** + * 净化入站文本(caption / text 可选复用): + * + *

RFC-025 Change 4:应对 .epub / .mobi 等附件把二进制元数据塞到 caption 的场景。

+ */ + static String sanitizeInboundText(String raw) { + if (raw == null || raw.isEmpty()) return raw; + // 策略:剥掉控制字符(\p{Cc})但保留 \t \r \n;再剥零宽/BIDI 等格式字符(\p{Cf})。 + // 其它所有 Unicode 可见字符(含 CJK 全角标点、emoji、西欧字母、阿拉伯数字等)均保留。 + // 相比白名单法:对全角 / 异域文字 / 新 emoji 都零误杀;仅打掉真正会爆 token 的控制字节。 + String cleaned = raw.replaceAll("[\\p{Cc}&&[^\\t\\r\\n]]", "") + .replaceAll("\\p{Cf}", ""); + if (cleaned.length() <= INBOUND_TEXT_MAX) return cleaned; + return cleaned.substring(0, INBOUND_TEXT_MAX) + " ...[truncated]"; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java index c77ddbb6..d550302b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java @@ -8,6 +8,7 @@ import vip.mate.channel.ChannelMessageRouter; import vip.mate.channel.ExponentialBackoff; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.weixin.error.TokenExpiredException; +import vip.mate.common.security.SecretEquals; import vip.mate.workspace.conversation.model.MessageContentPart; import java.io.IOException; @@ -563,7 +564,8 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { if (!fromUserId.isBlank() && !contextToken.isBlank()) { String prev = userContextTokens.put(fromUserId, contextToken); // token 变更时才持久化(减少 I/O) - if (!contextToken.equals(prev)) { + // RFC-025 Change 2: 常数时间比较,作为秘钥类字符串比较的模板统一 + if (!SecretEquals.equals(contextToken, prev)) { saveContextTokens(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/common/security/SecretEquals.java b/mateclaw-server/src/main/java/vip/mate/common/security/SecretEquals.java new file mode 100644 index 00000000..ac775382 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/security/SecretEquals.java @@ -0,0 +1,45 @@ +package vip.mate.common.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * 常数时间字符串比较工具(RFC-025 Change 2)。 + * + *

用于比较秘钥 / 签名 / 会话 token 等敏感字符串,避免 {@link String#equals} 的字符级短路 + * 特征被 timing 攻击利用推断前缀。

+ * + *

实现层统一 UTF-8 编码到 {@code byte[]} 后调用 {@link MessageDigest#isEqual(byte[], byte[])} + * ——该方法在 JDK 6+ 即保证线性时间不短路(即使长度不等也是返回 false 而非抛异常)。 + * null 被视为空数组,与非 null / 非空比较结果为 false。

+ * + *

使用场景: + *

+ */ +public final class SecretEquals { + + private SecretEquals() {} + + /** + * 常数时间比较两个字符串(视为 UTF-8 字节数组)。 + * + * @return true 当且仅当两者非 null 且字节内容相等;任一为 null 且另一非空视为不等 + */ + public static boolean equals(String a, String b) { + byte[] ba = (a == null) ? new byte[0] : a.getBytes(StandardCharsets.UTF_8); + byte[] bb = (b == null) ? new byte[0] : b.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(ba, bb); + } + + /** 字节数组版本:调用方已自行编码时可直接使用。 */ + public static boolean equals(byte[] a, byte[] b) { + if (a == null) a = new byte[0]; + if (b == null) b = new byte[0]; + return MessageDigest.isEqual(a, b); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/telegram/TelegramCaptionSanitizeTest.java b/mateclaw-server/src/test/java/vip/mate/channel/telegram/TelegramCaptionSanitizeTest.java new file mode 100644 index 00000000..083eead5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/telegram/TelegramCaptionSanitizeTest.java @@ -0,0 +1,63 @@ +package vip.mate.channel.telegram; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** RFC-025 Change 4:入站 caption / text 净化单测。 */ +class TelegramCaptionSanitizeTest { + + @Test + @DisplayName("空 / null 直接原样返回") + void emptyInputs() { + assertNull(TelegramChannelAdapter.sanitizeInboundText(null)); + assertEquals("", TelegramChannelAdapter.sanitizeInboundText("")); + } + + @Test + @DisplayName("纯文本 / 中文 / emoji 不应被误杀") + void preservesNormalText() { + assertEquals("Hello world", TelegramChannelAdapter.sanitizeInboundText("Hello world")); + assertEquals("你好,世界", TelegramChannelAdapter.sanitizeInboundText("你好,世界")); + assertEquals("日本語テスト", TelegramChannelAdapter.sanitizeInboundText("日本語テスト")); + assertEquals("한국어", TelegramChannelAdapter.sanitizeInboundText("한국어")); + } + + @Test + @DisplayName("控制字符 / ZWSP / 非可打印字节被剥掉") + void stripsNonPrintable() { + // \u0000 NUL, \u0007 BEL, \u200B ZWSP + String dirty = "a\u0000b\u0007c\u200Bd"; + String clean = TelegramChannelAdapter.sanitizeInboundText(dirty); + assertEquals("abcd", clean, "got: " + clean); + } + + @Test + @DisplayName("超长字符串被截断到 4096 + truncation 标记") + void longInputTruncated() { + String huge = "x".repeat(5000); + String result = TelegramChannelAdapter.sanitizeInboundText(huge); + assertTrue(result.length() <= 4096 + 20, "expected ≤ 4116 chars, got " + result.length()); + assertTrue(result.endsWith("...[truncated]")); + } + + @Test + @DisplayName("模拟 .epub caption 泄露二进制 → 净化后可控大小") + void simulatedEpubBinaryCaption() { + // 构造类似 .epub 元数据:混合 UTF-8 文本 + 非可打印字节 + StringBuilder dirty = new StringBuilder(); + dirty.append("Book Title\u0000PK\u0003\u0004"); // ZIP 魔数 + 文件头 + for (int i = 0; i < 1000; i++) { + dirty.append((char) (i % 32)); // 大量控制字符 + } + dirty.append(" author:Foo"); + String clean = TelegramChannelAdapter.sanitizeInboundText(dirty.toString()); + // 净化结果不含任何控制字符 + assertFalse(clean.matches(".*[\\x00-\\x1F].*"), + "cleaned should have no control bytes: " + clean); + // 但保留了实际文本 + assertTrue(clean.contains("Book Title")); + assertTrue(clean.contains("author:Foo")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/common/security/SecretEqualsTest.java b/mateclaw-server/src/test/java/vip/mate/common/security/SecretEqualsTest.java new file mode 100644 index 00000000..66e6e507 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/security/SecretEqualsTest.java @@ -0,0 +1,50 @@ +package vip.mate.common.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** RFC-025 Change 2 常数时间秘钥比较的正确性测试(不测 timing,测正确性)。 */ +class SecretEqualsTest { + + @Test + @DisplayName("相等字符串返回 true") + void equalStringsMatch() { + assertTrue(SecretEquals.equals("abc123", "abc123")); + assertTrue(SecretEquals.equals("", "")); + } + + @Test + @DisplayName("不等字符串返回 false,含不同长度、前缀差异、后缀差异") + void unequalReturnsFalse() { + assertFalse(SecretEquals.equals("abc123", "abc124")); + assertFalse(SecretEquals.equals("abc", "abcd")); + assertFalse(SecretEquals.equals("xabc", "yabc")); + } + + @Test + @DisplayName("null 视为空数组,任一 null 与非空字符串比较返回 false") + void nullSafety() { + assertTrue(SecretEquals.equals((String) null, (String) null)); + assertTrue(SecretEquals.equals((String) null, "")); + assertFalse(SecretEquals.equals((String) null, "x")); + assertFalse(SecretEquals.equals("x", (String) null)); + } + + @Test + @DisplayName("UTF-8 编码一致性:中文等值字符串返回 true") + void utf8ConsistencyForCjk() { + assertTrue(SecretEquals.equals("秘钥", "秘钥")); + assertFalse(SecretEquals.equals("秘钥", "秘密")); + } + + @Test + @DisplayName("字节数组版本与字符串版本结果一致") + void byteArrayOverloadConsistent() { + assertTrue(SecretEquals.equals(new byte[]{1, 2, 3}, new byte[]{1, 2, 3})); + assertFalse(SecretEquals.equals(new byte[]{1, 2, 3}, new byte[]{1, 2, 4})); + assertFalse(SecretEquals.equals(new byte[]{1, 2, 3}, new byte[]{1, 2, 3, 4})); + assertTrue(SecretEquals.equals((byte[]) null, (byte[]) null)); + } +}