Skip to content

Commit 5f33ccb

Browse files
authored
Merge pull request #195 from jumpserver/pr@new_terminal@sync_dev_20260721
Merge remote-tracking branch 'origin/dev' into sync_dev_20260721
2 parents 2f58e29 + 2fc8b43 commit 5f33ccb

16 files changed

Lines changed: 266 additions & 88 deletions

File tree

backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public void exportData(String path, DataViewData data) throws Exception {
141141
} else if (obj instanceof Date) {
142142
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
143143
writeString(writer, fmt.format(obj));
144+
writer.write(",");
144145
} else {
145146
writeString(writer, row.get(field.getName()));
146147
writer.write(",");

backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseActionHandler.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ public EventEmitter onShowProperties(TreeNode node) {
162162
public EventEmitter onShowObjectProperties(String type, String sql, TreeNode node) throws SQLException {
163163
var sqlActuator = this.getDatasource().getConnectionManager().getSqlActuator();
164164
var objName = TreeUtils.getValue(node.getKey(), type);
165-
var result = sqlActuator.execute(SQL.of(sql, objName));
165+
var command = SQL.of(sql, objName.replace("'", "''"));
166+
log.info("resource action show_properties: type={}, node={}", type, node.getKey());
167+
var result = sqlActuator.execute(command);
166168

167169
var detailDialog = new DetailDialog(node.getKey(), type + MessageUtils.get("Properties"));
168170
detailDialog.setWidth("50%");

backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.jumpserver.chen.framework.driver.DriverClassLoader;
1111
import org.jumpserver.chen.framework.driver.DriverManager;
1212
import org.jumpserver.chen.framework.i18n.MessageUtils;
13+
import org.jumpserver.chen.framework.utils.SqlIdentifierUtils;
1314

1415
import java.lang.reflect.InvocationTargetException;
1516
import java.sql.Connection;
@@ -120,6 +121,9 @@ public DruidDataSource getOrInitDataSource(String database) throws SQLException
120121
if (StringUtils.isEmpty(database)) {
121122
database = this.connectInfo.getDb();
122123
}
124+
// Reject URL metacharacters before interpolation into the JDBC URL,
125+
// preventing injection of driver connection properties.
126+
SqlIdentifierUtils.validateDatabaseName(database);
123127
if (this.dataSourceMap.containsKey(database)) {
124128
return this.dataSourceMap.get(database);
125129
}

backend/framework/src/main/java/org/jumpserver/chen/framework/session/SessionManager.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99

1010
@Slf4j
1111
public class SessionManager {
12+
// 绑定创建 Chen 会话的 Servlet HTTP session,WS 握手时用它阻止 token 被跨浏览器重放。
13+
public static final String WEB_SESSION_ID_ATTRIBUTE = "webSessionId";
1214
private final static SessionManager instance = new SessionManager();
1315
private final static ThreadLocal<String> token = new ThreadLocal<>();
1416
private final Map<String, Session> store = new ConcurrentHashMap<>();
17+
// 每个 Chen 会话只能有一个主 /ws/session 连接,值为 WebSocket session id。
18+
private final Map<String, String> primaryWebSockets = new ConcurrentHashMap<>();
1519

1620
public static String registerSession(Session session) {
1721
String token = createToken();
@@ -23,6 +27,7 @@ public static String registerSession(Session session) {
2327

2428
public static void unregisterSession(String token) {
2529
instance.store.remove(token);
30+
instance.primaryWebSockets.remove(token);
2631
log.info("session {} unregistered, current session count {}", token, instance.getCurrentSessionCount());
2732
}
2833

@@ -54,6 +59,17 @@ public static Session getSession(String token) {
5459
return instance.store.get(token);
5560
}
5661

62+
public static boolean claimPrimaryWebSocket(String token, String webSocketId) {
63+
// 原子占用,避免并发握手同时替换当前主连接。
64+
String existing = instance.primaryWebSockets.putIfAbsent(token, webSocketId);
65+
return existing == null || existing.equals(webSocketId);
66+
}
67+
68+
public static boolean releasePrimaryWebSocket(String token, String webSocketId) {
69+
// 只允许占用者释放,防止被拒绝的重放连接关闭正常会话。
70+
return instance.primaryWebSockets.remove(token, webSocketId);
71+
}
72+
5773

5874
private static String createToken() {
5975
return UUID.randomUUID().toString().replace("-", "");
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.jumpserver.chen.framework.utils;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
5+
import java.sql.SQLException;
6+
7+
/**
8+
* Validates runtime database identifiers before they are interpolated into a
9+
* JDBC URL, so that URL/property metacharacters in a database name cannot
10+
* inject driver connection properties (e.g. PostgreSQL socketFactory/loggerFile,
11+
* SQLServer ";prop=val").
12+
*/
13+
@Slf4j
14+
public final class SqlIdentifierUtils {
15+
16+
// Characters that act as delimiters in at least one supported JDBC URL form.
17+
// Rejecting them keeps a database name from escaping its ${db} placeholder.
18+
private static final String FORBIDDEN_CHARS = "?&/:@#\\;= \t\r\n";
19+
20+
private SqlIdentifierUtils() {
21+
}
22+
23+
/**
24+
* Reject database names containing URL/property metacharacters or control
25+
* characters. Blank values are allowed; callers fall back to the configured db.
26+
*/
27+
public static void validateDatabaseName(String name) throws SQLException {
28+
if (name == null || name.isEmpty()) {
29+
return;
30+
}
31+
for (int i = 0; i < name.length(); i++) {
32+
char c = name.charAt(i);
33+
if (c < 0x20 || FORBIDDEN_CHARS.indexOf(c) >= 0) {
34+
// Do not echo the value: it may contain log-forging characters.
35+
log.warn("Rejected database name containing URL metacharacter");
36+
throw new SQLException("Invalid database identifier");
37+
}
38+
}
39+
}
40+
}

backend/framework/src/main/java/org/jumpserver/chen/framework/ws/SessionWebSocketHandler.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio
3030
}
3131

3232
var token = (String) session.getAttributes().get("token");
33+
// 拒绝第二个主会话,避免重放连接替换 PacketIO 并在关闭时终止原会话。
34+
if (!SessionManager.claimPrimaryWebSocket(token, session.getId())) {
35+
log.warn("Reject duplicate primary WebSocket connection");
36+
session.close(CloseStatus.POLICY_VIOLATION);
37+
return;
38+
}
39+
log.info("Primary WebSocket connection established");
3340
SessionManager.setContext(token);
3441

3542
Session sess = SessionManager.getCurrentSession();
@@ -96,10 +103,15 @@ public void handleMessage(WebSocketSession session, WebSocketMessage<?> message)
96103
@Override
97104
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
98105
var token = (String) session.getAttributes().get("token");
106+
// 被拒绝的连接没有占用主会话,不得影响当前仍在线的用户。
107+
if (!SessionManager.releasePrimaryWebSocket(token, session.getId())) {
108+
return;
109+
}
110+
log.info("Primary WebSocket connection closed: code={}", closeStatus.getCode());
99111
SessionManager.setContext(token);
100112
var sess = SessionManager.getCurrentSession();
101113
if (sess != null) {
102114
sess.close();
103115
}
104116
}
105-
}
117+
}

backend/modules/src/main/java/org.jumpserver.chen.modules/mysql/MysqlConnectionManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
public class MysqlConnectionManager extends BaseConnectionManager {
1919

20-
private static final String jdbcUrlTemplate = "jdbc:mysql://${host}:${port}/${db}?useSSL=false&useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=CONVERT_TO_NULL&tinyInt1isBit=false&jdbcCompliantTruncation=false";
20+
private static final String jdbcUrlTemplate = "jdbc:mysql://${host}:${port}/${db}?useSSL=false&useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=CONVERT_TO_NULL&tinyInt1isBit=false&jdbcCompliantTruncation=false&allowPublicKeyRetrieval=true";
2121
private String jdbcUrl;
2222

2323
public MysqlConnectionManager(DBConnectInfo connectInfo, Datasource datasource) {

backend/modules/src/main/java/org.jumpserver.chen.modules/oracle/OracleConnectionManager.java

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.jumpserver.chen.framework.datasource.sql.SQL;
77

88
import java.sql.SQLException;
9+
import java.util.Objects;
910
import java.util.Properties;
1011
import java.util.concurrent.CompletableFuture;
1112
import java.util.concurrent.ExecutionException;
@@ -40,31 +41,23 @@ public void ping() {
4041

4142
var pool = Executors.newFixedThreadPool(2);
4243

43-
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> {
44-
try {
45-
this.ping(sidUrl, props);
46-
return sidUrl;
47-
} catch (SQLException e) {
48-
return null;
49-
}
50-
}, pool);
51-
52-
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> {
53-
try {
54-
this.ping(serviceUrl, props);
55-
return serviceUrl;
56-
} catch (SQLException e) {
57-
return null;
58-
}
59-
}, pool);
44+
CompletableFuture<ConnectionAttempt> f1 = CompletableFuture.supplyAsync(
45+
() -> this.attemptConnection(sidUrl, props), pool
46+
);
47+
48+
CompletableFuture<ConnectionAttempt> f2 = CompletableFuture.supplyAsync(
49+
() -> this.attemptConnection(serviceUrl, props), pool
50+
);
6051

6152
CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(f1, f2);
6253
try {
6354
combinedFuture.get(); // 等待所有Future完成
6455
// 判断哪个Future成功完成,并设置jdbcUrl
65-
this.jdbcUrl = f1.get() != null ? f1.get() : f2.get();
56+
var sidAttempt = f1.get();
57+
var serviceNameAttempt = f2.get();
58+
this.jdbcUrl = sidAttempt.jdbcUrl() != null ? sidAttempt.jdbcUrl() : serviceNameAttempt.jdbcUrl();
6659
if (this.jdbcUrl == null) {
67-
throw new RuntimeException("Both SID and ServiceName connections failed.");
60+
throw connectionFailed(sidAttempt.error(), serviceNameAttempt.error());
6861
}
6962
} catch (InterruptedException | ExecutionException e) {
7063
throw new RuntimeException("Error occurred while pinging database", e);
@@ -73,6 +66,26 @@ public void ping() {
7366
}
7467
}
7568

69+
private ConnectionAttempt attemptConnection(String jdbcUrl, Properties props) {
70+
try {
71+
this.ping(jdbcUrl, props);
72+
return new ConnectionAttempt(jdbcUrl, null);
73+
} catch (SQLException e) {
74+
return new ConnectionAttempt(null, e);
75+
}
76+
}
77+
78+
private RuntimeException connectionFailed(SQLException sidError, SQLException serviceNameError) {
79+
var message = Objects.equals(sidError.getMessage(), serviceNameError.getMessage())
80+
? sidError.getMessage()
81+
: "SID: %s; ServiceName: %s".formatted(sidError.getMessage(), serviceNameError.getMessage());
82+
var error = new RuntimeException(message, sidError);
83+
error.addSuppressed(serviceNameError);
84+
return error;
85+
}
86+
87+
private record ConnectionAttempt(String jdbcUrl, SQLException error) {}
88+
7689
private static final String SQL_GET_VERSION = "select concat(product,concat(version,status)) as version from product_component_version where product like 'Oracle%'";
7790

7891
@Override

backend/modules/src/main/java/org.jumpserver.chen.modules/postgresql/PostgresqlSQLHintsHandler.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.jumpserver.chen.framework.datasource.entity.resource.Field;
77
import org.jumpserver.chen.framework.datasource.entity.resource.Table;
88
import org.jumpserver.chen.framework.datasource.sql.SQL;
9+
import org.jumpserver.chen.framework.utils.SqlIdentifierUtils;
910
import org.jumpserver.chen.framework.utils.TreeUtils;
1011

1112
import java.sql.SQLException;
@@ -46,6 +47,9 @@ public Map<String, List<String>> getHints(String nodeKey, String context) throws
4647

4748
var db = TreeUtils.getValue(nodeKey, "database");
4849
if (StringUtils.isNotEmpty(db)) {
50+
// nodeKey is client-controlled; reject URL metacharacters before it
51+
// reaches the JDBC URL via setDatabaseContext.
52+
SqlIdentifierUtils.validateDatabaseName(db);
4953
this.connectionManager.setDatabaseContext(db);
5054
}
5155

backend/web/src/main/java/org/jumpserver/chen/web/config/WebSocketConfig.java

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.jumpserver.chen.web.config;
22

33
import lombok.extern.slf4j.Slf4j;
4+
import org.jumpserver.chen.framework.session.SessionManager;
45
import org.jumpserver.chen.framework.ws.ConsoleWebSocketHandler;
56
import org.jumpserver.chen.framework.ws.DBConsoleWebsocketHandler;
67
import org.jumpserver.chen.framework.ws.SessionWebSocketHandler;
@@ -9,22 +10,21 @@
910
import org.springframework.http.HttpStatus;
1011
import org.springframework.http.server.ServerHttpRequest;
1112
import org.springframework.http.server.ServerHttpResponse;
13+
import org.springframework.http.server.ServletServerHttpRequest;
1214
import org.springframework.web.socket.WebSocketHandler;
13-
import org.springframework.web.socket.config.annotation.EnableWebSocket;
14-
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
15-
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
1615
import org.springframework.web.socket.server.HandshakeInterceptor;
1716
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
17+
import org.springframework.web.socket.server.support.WebSocketHandlerMapping;
18+
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler;
1819

1920
import java.net.InetSocketAddress;
2021
import java.net.URI;
2122
import java.util.*;
2223
import java.util.stream.Collectors;
2324

2425
@Configuration
25-
@EnableWebSocket
2626
@Slf4j
27-
public class WebSocketConfig implements WebSocketConfigurer {
27+
public class WebSocketConfig {
2828

2929

3030
@Bean
@@ -38,14 +38,25 @@ public ServletServerContainerFactoryBean createWebSocketContainer() {
3838
}
3939

4040

41-
@Override
42-
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
43-
registry
44-
.addHandler(new ConsoleWebSocketHandler(), "/ws/console")
45-
.addHandler(new SessionWebSocketHandler(), "/ws/session")
46-
.addHandler(new DBConsoleWebsocketHandler(), "/ws/db-console")
47-
.addInterceptors(new ServletWebSocketHandshakeInterceptor())
48-
.setAllowedOrigins("*");
41+
@Bean
42+
public WebSocketHandlerMapping chenWebSocketHandlerMapping() {
43+
var handlers = new LinkedHashMap<String, Object>();
44+
handlers.put("/ws/session", createRequestHandler(new SessionWebSocketHandler()));
45+
handlers.put("/ws/console", createRequestHandler(new ConsoleWebSocketHandler()));
46+
handlers.put("/ws/db-console", createRequestHandler(new DBConsoleWebsocketHandler()));
47+
48+
var mapping = new WebSocketHandlerMapping();
49+
// 与 Spring 默认 WebSocket 映射一致,确保 WS 请求优先于普通 MVC 映射处理。
50+
mapping.setOrder(1);
51+
mapping.setUrlMap(handlers);
52+
return mapping;
53+
}
54+
55+
private WebSocketHttpRequestHandler createRequestHandler(WebSocketHandler webSocketHandler) {
56+
var requestHandler = new WebSocketHttpRequestHandler(webSocketHandler);
57+
// 仅使用 Chen 的动态校验,避免 WebSocketHandlerRegistry 追加第二个 Origin 拦截器。
58+
requestHandler.setHandshakeInterceptors(List.of(new ServletWebSocketHandshakeInterceptor()));
59+
return requestHandler;
4960
}
5061

5162
// 仅用于跨 Host 请求的精确白名单,格式为逗号分隔的 host 或 host:port。
@@ -146,6 +157,12 @@ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse res
146157

147158
String origin = request.getHeaders().getOrigin();
148159
InetSocketAddress requestHost = request.getHeaders().getHost();
160+
// 某些 Servlet 请求对象未填充 Host header,回退到容器解析到的主机和端口。
161+
if (requestHost == null && request instanceof ServletServerHttpRequest servletRequest) {
162+
requestHost = new InetSocketAddress(
163+
servletRequest.getServletRequest().getServerName(),
164+
servletRequest.getServletRequest().getServerPort());
165+
}
149166

150167
if (!checkOrigin(origin, requestHost, TRUSTED_DOMAINS)) {
151168
log.warn("Reject WebSocket handshake: untrusted or invalid origin");
@@ -161,6 +178,21 @@ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse res
161178
}
162179

163180
var token = protocols.get(0);
181+
var session = SessionManager.getSession(token);
182+
var servletRequest = request instanceof ServletServerHttpRequest servletRequestWrapper
183+
? servletRequestWrapper.getServletRequest() : null;
184+
var httpSession = servletRequest == null ? null : servletRequest.getSession(false);
185+
var sessionBound = session != null && httpSession != null && Objects.equals(
186+
session.getAttribute(SessionManager.WEB_SESSION_ID_ATTRIBUTE), httpSession.getId());
187+
// token 必须仍存活,且必须来自创建它的同一浏览器 HTTP session。
188+
if (!sessionBound) {
189+
// 仅输出校验维度,不记录 token、Cookie 或 HTTP session ID 等敏感信息。
190+
log.warn("Reject WebSocket handshake: tokenExists={}, httpSessionExists={}, sessionBound={}",
191+
session != null, httpSession != null, sessionBound);
192+
response.setStatusCode(HttpStatus.UNAUTHORIZED);
193+
return false;
194+
}
195+
log.info("Accept WebSocket handshake: HTTP session binding verified");
164196
attributes.put("token", token);
165197
response.getHeaders().put("Sec-WebSocket-Protocol", protocols);
166198
return true;

0 commit comments

Comments
 (0)