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:
matevip 2026-04-15 22:14:55 +08:00
parent 52f5a38654
commit 7d8d16e458
8 changed files with 217 additions and 5 deletions

View File

@ -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);
}
}

View File

@ -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本地 8KsystemTokens + 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 一致

View File

@ -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={}",

View File

@ -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]";
}
}

View File

@ -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();
}
}

View File

@ -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>后续 RFCsanitizer / 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);
}
}

View File

@ -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"));
}
}

View File

@ -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));
}
}