mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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) {
|
} else if (runtimeModel.getTopP() != null) {
|
||||||
builder.topP(runtimeModel.getTopP());
|
builder.topP(runtimeModel.getTopP());
|
||||||
}
|
}
|
||||||
if (runtimeModel.getMaxTokens() != null) {
|
// RFC-025 Change 5: 非正值 maxTokens 会被 Anthropic API 直接拒绝;本地提前拦截
|
||||||
builder.maxTokens(runtimeModel.getMaxTokens());
|
// 并 fallback 到 4096,避免错误信息在运行时才暴露、也防止坏配置透传
|
||||||
|
Integer configuredMax = runtimeModel.getMaxTokens();
|
||||||
|
if (configuredMax != null && configuredMax > 0) {
|
||||||
|
builder.maxTokens(configuredMax);
|
||||||
} else {
|
} else {
|
||||||
|
if (configuredMax != null) {
|
||||||
|
log.warn("Ignoring non-positive Anthropic maxTokens={} for model {}; falling back to 4096",
|
||||||
|
configuredMax, runtimeModel.getModelName());
|
||||||
|
}
|
||||||
builder.maxTokens(4096);
|
builder.maxTokens(4096);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,6 +141,16 @@ public class ConversationWindowManager {
|
|||||||
|
|
||||||
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
|
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
|
||||||
int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05);
|
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;
|
int historyBudget = effectiveMax - reservedTokens;
|
||||||
|
|
||||||
// 尾部保护 token 预算:阈值的 20%(与 Hermes 一致)
|
// 尾部保护 token 预算:阈值的 20%(与 Hermes 一致)
|
||||||
|
|||||||
@ -110,7 +110,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
if ("websocket".equals(connectionMode)) {
|
if ("websocket".equals(connectionMode)) {
|
||||||
startWebSocket(appId, appSecret);
|
startWebSocket(appId, appSecret);
|
||||||
} else {
|
} 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={}",
|
log.info("[feishu] Feishu channel initialized: appId={}, mode={}, domain={}",
|
||||||
|
|||||||
@ -393,7 +393,10 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
|
|||||||
// 构建 contentParts
|
// 构建 contentParts
|
||||||
List<MessageContentPart> contentParts = new ArrayList<>();
|
List<MessageContentPart> contentParts = new ArrayList<>();
|
||||||
String textContent = (String) message.get("text");
|
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;
|
boolean hasVoice = message.get("voice") != null;
|
||||||
|
|
||||||
if (textContent != null && !textContent.isBlank()) {
|
if (textContent != null && !textContent.isBlank()) {
|
||||||
@ -716,4 +719,26 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
|
|||||||
public String getChannelType() {
|
public String getChannelType() {
|
||||||
return CHANNEL_TYPE;
|
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.ExponentialBackoff;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
import vip.mate.channel.weixin.error.TokenExpiredException;
|
import vip.mate.channel.weixin.error.TokenExpiredException;
|
||||||
|
import vip.mate.common.security.SecretEquals;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -563,7 +564,8 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
if (!fromUserId.isBlank() && !contextToken.isBlank()) {
|
if (!fromUserId.isBlank() && !contextToken.isBlank()) {
|
||||||
String prev = userContextTokens.put(fromUserId, contextToken);
|
String prev = userContextTokens.put(fromUserId, contextToken);
|
||||||
// token 变更时才持久化(减少 I/O)
|
// token 变更时才持久化(减少 I/O)
|
||||||
if (!contextToken.equals(prev)) {
|
// RFC-025 Change 2: 常数时间比较,作为秘钥类字符串比较的模板统一
|
||||||
|
if (!SecretEquals.equals(contextToken, prev)) {
|
||||||
saveContextTokens();
|
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