fix(channel): bound webchat conversationId/username to prevent VARCHAR(64) overflow

The webchat conversationId (webchat:<key8>:<visitorId>[:<sessionId>]) and the
derived username (webchat:<visitorId>) are written to VARCHAR(64) columns, but
visitorId had no validation and sessionId allows 64 chars — so a long visitorId,
or a legitimate 64-char sessionId, overflows the column and the getOrCreateConversation
INSERT throws (500 on /stream). Validate visitorId (charset + blank->UUID) and
fold the variable part into a stable hash when the derived id/username would
exceed 64 chars, keeping short ids byte-identical (backward compatible). Also
make listSessions filter on exact owner username, not just the conversationId
prefix, so system-owned rows can never leak via a crafted visitorId. Adds
boundary regression tests.
This commit is contained in:
matevip 2026-06-09 11:36:19 +08:00
parent 77b6baeccc
commit 846c1c31ca
2 changed files with 83 additions and 8 deletions

View File

@ -115,13 +115,13 @@ public class WebChatController {
}
final Long resolvedAgentId = agentId;
String visitorId = request.getVisitorId() != null ? request.getVisitorId() : UUID.randomUUID().toString();
// Optional sessionId lets one visitor hold multiple isolated threads. It is only ever
// composed into the server-derived conversationId (kept under the key+visitor namespace),
// never accepted as a raw conversationId so a caller can't reach another tenant's history.
final String visitorId;
final String effectiveSessionId;
try {
visitorId = normalizeVisitorId(request.getVisitorId());
effectiveSessionId = normalizeSessionId(request.getSessionId());
} catch (IllegalArgumentException ex) {
sendErrorAndComplete(emitter, ex.getMessage());
@ -156,7 +156,7 @@ public class WebChatController {
// 创建或获取会话workspace agent 获取
var webAgent = agentService.getAgent(resolvedAgentId);
Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L;
var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, "webchat:" + visitorId, webWsId);
var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId);
// 保存用户消息
conversationService.saveMessage(conversationId, "user", message, List.of());
@ -296,8 +296,10 @@ public class WebChatController {
}
String base = deriveConversationId(apiKey, visitorId, null);
String prefix = base + ":";
List<WebChatSessionView> sessions = conversationService.listConversations("webchat:" + visitorId).stream()
String owner = webchatUsername(visitorId);
List<WebChatSessionView> sessions = conversationService.listConversations(owner).stream()
.filter(c -> c.getConversationId() != null
&& owner.equals(c.getUsername())
&& (c.getConversationId().equals(base) || c.getConversationId().startsWith(prefix)))
.map(c -> {
String cid = c.getConversationId();
@ -391,13 +393,61 @@ public class WebChatController {
return s;
}
private static final Pattern VISITOR_ID_PATTERN = Pattern.compile("[A-Za-z0-9_.:\\-]{1,128}");
/**
* 归一化调用方传入的 visitorId空白 UUID非空必须满足白名单字符集否则抛出
* 限制字符集既防注入/控制字符也为派生的 conversationId / username 提供可预期的边界
*/
private String normalizeVisitorId(String raw) {
if (raw == null || raw.trim().isEmpty()) {
return UUID.randomUUID().toString();
}
String s = raw.trim();
if (!VISITOR_ID_PATTERN.matcher(s).matches()) {
throw new IllegalArgumentException(
"Invalid visitorId (allowed: letters, digits, '-', '_', '.', ':', length 1-128)");
}
return s;
}
/**
* 由服务端拼装 conversationId始终钳在 key + visitor 命名空间内
* 绝不接受调用方传入的裸 conversationId
* <p>conversation_id 列为 VARCHAR(64) visitorId + sessionId 过长导致超出列宽时
* 把可变部分折叠为稳定哈希保证 id 唯一且有界否则 INSERT 会在 /stream 500
*/
private String deriveConversationId(String apiKey, String visitorId, String sessionId) {
String base = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":" + visitorId;
return sessionId != null ? base + ":" + sessionId : base;
static String deriveConversationId(String apiKey, String visitorId, String sessionId) {
String key8 = apiKey.substring(0, Math.min(8, apiKey.length()));
String full = "webchat:" + key8 + ":" + visitorId + (sessionId != null ? ":" + sessionId : "");
if (full.length() <= 64) {
return full;
}
return "webchat:" + key8 + ":#"
+ sha256Hex(visitorId + "" + (sessionId == null ? "" : sessionId)).substring(0, 40);
}
/**
* visitorId 派生 usernamemate_conversation.usernameVARCHAR(64)
* 同样在超长时折叠为哈希避免 username 溢出列宽
*/
static String webchatUsername(String visitorId) {
String u = "webchat:" + visitorId;
return u.length() <= 64 ? u : "webchat:#" + sha256Hex(visitorId).substring(0, 40);
}
private static String sha256Hex(String s) {
try {
byte[] d = java.security.MessageDigest.getInstance("SHA-256")
.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(d.length * 2);
for (byte b : d) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/**
@ -407,7 +457,7 @@ public class WebChatController {
*/
private boolean ownsConversation(String conversationId, String visitorId) {
ConversationEntity conv = conversationService.findByConversationId(conversationId);
return conv != null && ("webchat:" + visitorId).equals(conv.getUsername());
return conv != null && webchatUsername(visitorId).equals(conv.getUsername());
}
/**

View File

@ -87,4 +87,29 @@ class WebChatVisitorTokenTest {
assertFalse(WebChatController.verifyVisitorToken(SECRET, null, VISITOR, token));
assertFalse(WebChatController.verifyVisitorToken(SECRET, CHANNEL, null, token));
}
// ============ conversationId / username 边界避免溢出 VARCHAR(64) /stream 500============
@Test
void deriveConversationId_staysWithin64_forLongInputs() {
String id = WebChatController.deriveConversationId("apikey1234567890", "v".repeat(120), "s".repeat(64));
assertTrue(id.length() <= 64, "conversationId must fit VARCHAR(64), got " + id.length());
// a legitimate 64-char sessionId alone already overflows the old scheme
String id2 = WebChatController.deriveConversationId("apikey1234567890", "alice", "s".repeat(64));
assertTrue(id2.length() <= 64, "conversationId must fit VARCHAR(64), got " + id2.length());
}
@Test
void deriveConversationId_unchanged_forShortInputs() {
assertEquals("webchat:apikey12:alice:s1",
WebChatController.deriveConversationId("apikey1234567890", "alice", "s1"));
assertEquals("webchat:apikey12:alice",
WebChatController.deriveConversationId("apikey1234567890", "alice", null));
}
@Test
void webchatUsername_staysWithin64_forLongVisitor() {
assertTrue(WebChatController.webchatUsername("v".repeat(120)).length() <= 64);
assertEquals("webchat:alice", WebChatController.webchatUsername("alice"));
}
}