mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat: 5 defensive hardenings
- 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.
This commit is contained in:
parent
52f5a38654
commit
7d8d16e458
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 一致)
|
||||
|
||||
@ -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={}",
|
||||
|
||||
@ -393,7 +393,10 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
|
||||
// 构建 contentParts
|
||||
List<MessageContentPart> 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 可选复用):
|
||||
* <ul>
|
||||
* <li>剥掉控制字符与非可打印字节,保留常见空白、中日韩文字</li>
|
||||
* <li>硬封顶 {@value #INBOUND_TEXT_MAX} 字符,超出追加 truncation 标记</li>
|
||||
* </ul>
|
||||
* <p>RFC-025 Change 4:应对 .epub / .mobi 等附件把二进制元数据塞到 caption 的场景。</p>
|
||||
*/
|
||||
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]";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package vip.mate.common.security;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* 常数时间字符串比较工具(RFC-025 Change 2)。
|
||||
*
|
||||
* <p>用于比较秘钥 / 签名 / 会话 token 等敏感字符串,避免 {@link String#equals} 的字符级短路
|
||||
* 特征被 timing 攻击利用推断前缀。</p>
|
||||
*
|
||||
* <p>实现层统一 UTF-8 编码到 {@code byte[]} 后调用 {@link MessageDigest#isEqual(byte[], byte[])}
|
||||
* ——该方法在 JDK 6+ 即保证线性时间不短路(即使长度不等也是返回 false 而非抛异常)。
|
||||
* null 被视为空数组,与非 null / 非空比较结果为 false。</p>
|
||||
*
|
||||
* <p>使用场景:
|
||||
* <ul>
|
||||
* <li>webhook 签名比对</li>
|
||||
* <li>MCP / HTTP bearer 校验</li>
|
||||
* <li>会话 token / context_token 比较</li>
|
||||
* <li>后续 RFC(sanitizer / skill hub 签名)中复用</li>
|
||||
* </ul></p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user