mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
fix(conversation): exclude malformed conversationIds from admin list/page
conversationId ending in ":" (e.g. webchat:<key8>: with empty visitorId,
from older webchat versions) leaks into the admin console via the
'webchat:%' username LIKE, then 500/403s on open because the trailing ":"
makes some reverse proxies strip the path tail — landing a GET on the
@DeleteMapping variant of /{conversationId} (issue #369).
Add applyMalformedIdGuard — a NOT LIKE '%:' clause — to both listConversations
(lenient + strict overloads) and pageConversations so these rows never
surface. isConversationOwner already rejects unknown ids with 403, so no
change is needed on the direct-access endpoints; once the rows are out of
the lists, admin can no longer reach them.
The two existing strict/non-admin assertions changed from "no LIKE keyword"
to "no webchat:% param value" — applyMalformedIdGuard emits a NOT LIKE
itself, so the LIKE keyword is now present in every query.
Tests cover the guard on lenient, page, and strict paths.
This commit is contained in:
parent
ffef9bab00
commit
f70e56cfc3
@ -150,6 +150,7 @@ public class ConversationService {
|
|||||||
boolean includeWebchat = includeChannelPrincipals && isGlobalAdmin(username);
|
boolean includeWebchat = includeChannelPrincipals && isGlobalAdmin(username);
|
||||||
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
||||||
.and(w -> applyOwnerScope(w, username, includeWebchat))
|
.and(w -> applyOwnerScope(w, username, includeWebchat))
|
||||||
|
.and(this::applyMalformedIdGuard)
|
||||||
.isNull(ConversationEntity::getParentConversationId)
|
.isNull(ConversationEntity::getParentConversationId)
|
||||||
.orderByDesc(ConversationEntity::getPinned)
|
.orderByDesc(ConversationEntity::getPinned)
|
||||||
.orderByDesc(ConversationEntity::getLastActiveTime);
|
.orderByDesc(ConversationEntity::getLastActiveTime);
|
||||||
@ -203,6 +204,17 @@ public class ConversationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclude rows whose conversationId ends in ":" — malformed (e.g.
|
||||||
|
* {@code webchat:<key8>:} with empty visitorId, from older versions).
|
||||||
|
* Showing them in the console surfaces threads that 500/403 on open
|
||||||
|
* because the trailing ":" makes some reverse proxies strip the path
|
||||||
|
* tail (issue #369).
|
||||||
|
*/
|
||||||
|
private void applyMalformedIdGuard(LambdaQueryWrapper<ConversationEntity> w) {
|
||||||
|
w.notLike(ConversationEntity::getConversationId, "%:");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user is a global admin (role=admin), resolved from the DB —
|
* Whether the user is a global admin (role=admin), resolved from the DB —
|
||||||
* never from client-controlled data. Gates webchat row visibility in the
|
* never from client-controlled data. Gates webchat row visibility in the
|
||||||
@ -239,6 +251,7 @@ public class ConversationService {
|
|||||||
// conversation (issue #344), so non-admins must not see those rows.
|
// conversation (issue #344), so non-admins must not see those rows.
|
||||||
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
||||||
.and(w -> applyOwnerScope(w, username, isGlobalAdmin(username)))
|
.and(w -> applyOwnerScope(w, username, isGlobalAdmin(username)))
|
||||||
|
.and(this::applyMalformedIdGuard)
|
||||||
.isNull(ConversationEntity::getParentConversationId)
|
.isNull(ConversationEntity::getParentConversationId)
|
||||||
.orderByDesc(ConversationEntity::getPinned)
|
.orderByDesc(ConversationEntity::getPinned)
|
||||||
.orderByDesc(ConversationEntity::getLastActiveTime);
|
.orderByDesc(ConversationEntity::getLastActiveTime);
|
||||||
|
|||||||
@ -79,7 +79,7 @@ class ConversationServiceWebchatVisibilityTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("lenient list, non-admin: excludes webchat principals (no LIKE clause)")
|
@DisplayName("lenient list, non-admin: excludes webchat principals (no 'webchat:%' param)")
|
||||||
void lenientListNonAdminExcludesWebchat() {
|
void lenientListNonAdminExcludesWebchat() {
|
||||||
when(authService.findByUsername("alice")).thenReturn(user("member"));
|
when(authService.findByUsername("alice")).thenReturn(user("member"));
|
||||||
ArgumentCaptor<LambdaQueryWrapper<ConversationEntity>> captor =
|
ArgumentCaptor<LambdaQueryWrapper<ConversationEntity>> captor =
|
||||||
@ -88,12 +88,14 @@ class ConversationServiceWebchatVisibilityTest {
|
|||||||
|
|
||||||
service.listConversations("alice", 1L, true);
|
service.listConversations("alice", 1L, true);
|
||||||
|
|
||||||
String sql = captor.getValue().getTargetSql();
|
// The malformed-id guard still emits a NOT LIKE, so we assert on the
|
||||||
assertThat(sql).doesNotContainIgnoringCase("like");
|
// param value instead of the LIKE keyword.
|
||||||
|
assertThat(captor.getValue().getParamNameValuePairs().values())
|
||||||
|
.doesNotContain("webchat:%");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("strict list excludes webchat principals (no LIKE clause, no role lookup)")
|
@DisplayName("strict list excludes webchat principals (no 'webchat:%' param, no role lookup)")
|
||||||
void strictListExcludesWebchat() {
|
void strictListExcludesWebchat() {
|
||||||
ArgumentCaptor<LambdaQueryWrapper<ConversationEntity>> captor =
|
ArgumentCaptor<LambdaQueryWrapper<ConversationEntity>> captor =
|
||||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
@ -101,8 +103,8 @@ class ConversationServiceWebchatVisibilityTest {
|
|||||||
|
|
||||||
service.listConversations("admin", 1L); // strict 2-arg
|
service.listConversations("admin", 1L); // strict 2-arg
|
||||||
|
|
||||||
String sql = captor.getValue().getTargetSql();
|
assertThat(captor.getValue().getParamNameValuePairs().values())
|
||||||
assertThat(sql).doesNotContainIgnoringCase("like");
|
.doesNotContain("webchat:%");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -133,8 +135,63 @@ class ConversationServiceWebchatVisibilityTest {
|
|||||||
|
|
||||||
service.pageConversations("alice", 1L, 1, 20, null);
|
service.pageConversations("alice", 1L, 1, 20, null);
|
||||||
|
|
||||||
String sql = captor.getValue().getTargetSql();
|
assertThat(captor.getValue().getParamNameValuePairs().values())
|
||||||
assertThat(sql).doesNotContainIgnoringCase("like");
|
.doesNotContain("webchat:%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Malformed conversationId guard — rows whose id ends in ":" (e.g. an
|
||||||
|
// empty-visitorId webchat thread) are filtered out of every admin list
|
||||||
|
// query, regardless of role. Surfacing them triggers 500/403 on open
|
||||||
|
// because the trailing ":" confuses some reverse proxies (issue #369).
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("lenient list: applies NOT LIKE '%:' guard to filter malformed ids")
|
||||||
|
void lenientListAppliesMalformedIdGuard() {
|
||||||
|
when(authService.findByUsername("admin")).thenReturn(user("admin"));
|
||||||
|
ArgumentCaptor<LambdaQueryWrapper<ConversationEntity>> captor =
|
||||||
|
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
when(conversationMapper.selectList(captor.capture())).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.listConversations("admin", 1L, true);
|
||||||
|
|
||||||
|
// Assert on the rendered SQL (not the param values, which MyBatis-Plus
|
||||||
|
// percent-escapes internally) so the test stays independent of that
|
||||||
|
// implementation detail.
|
||||||
|
String sql = captor.getValue().getTargetSql().toLowerCase();
|
||||||
|
assertThat(sql).contains("not like");
|
||||||
|
assertThat(sql).contains("conversation_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("page query: applies the same NOT LIKE '%:' guard")
|
||||||
|
void pageAppliesMalformedIdGuard() {
|
||||||
|
when(authService.findByUsername("admin")).thenReturn(user("admin"));
|
||||||
|
ArgumentCaptor<LambdaQueryWrapper<ConversationEntity>> captor =
|
||||||
|
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
when(conversationMapper.selectPage(any(Page.class), captor.capture()))
|
||||||
|
.thenReturn(new Page<>());
|
||||||
|
|
||||||
|
service.pageConversations("admin", 1L, 1, 20, null);
|
||||||
|
|
||||||
|
String sql = captor.getValue().getTargetSql().toLowerCase();
|
||||||
|
assertThat(sql).contains("not like");
|
||||||
|
assertThat(sql).contains("conversation_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("strict list also applies the guard — malformed ids never leak to owner-only views")
|
||||||
|
void strictListAppliesMalformedIdGuard() {
|
||||||
|
ArgumentCaptor<LambdaQueryWrapper<ConversationEntity>> captor =
|
||||||
|
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
when(conversationMapper.selectList(captor.capture())).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.listConversations("admin", 1L); // strict 2-arg
|
||||||
|
|
||||||
|
String sql = captor.getValue().getTargetSql().toLowerCase();
|
||||||
|
assertThat(sql).contains("not like");
|
||||||
|
assertThat(sql).contains("conversation_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UserEntity user(String role) {
|
private static UserEntity user(String role) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user