diff --git a/Dockerfile b/Dockerfile
index 2518fb95..5065fe51 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM jumpserver/chen-base:20260701_092702 AS stage-build
+FROM jumpserver/chen-base:20260811_065334 AS stage-build
ENV LANG=en_US.UTF-8
WORKDIR /opt/chen/
diff --git a/Dockerfile-base b/Dockerfile-base
index 6b6557db..d2087272 100644
--- a/Dockerfile-base
+++ b/Dockerfile-base
@@ -24,7 +24,7 @@ RUN set -ex \
&& dpkg -i check_linux_${TARGETARCH}.deb \
&& rm -f check_linux_${TARGETARCH}.deb
-ARG WISP_VERSION=v0.2.13
+ARG WISP_VERSION=v0.2.14
RUN set -ex \
&& wget https://github.com/jumpserver/wisp/releases/download/${WISP_VERSION}/wisp_linux_${TARGETARCH}.deb \
&& dpkg -i wisp_linux_${TARGETARCH}.deb \
diff --git a/backend/framework/pom.xml b/backend/framework/pom.xml
index aaedccc4..16ff34a6 100644
--- a/backend/framework/pom.xml
+++ b/backend/framework/pom.xml
@@ -19,9 +19,9 @@
- com.alibaba
+ com.jumpserver
druid
- 1.2.18
+ 1.2.28-jms-chen.1
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java
index a3fab598..ab78aa33 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/QueryConsole.java
@@ -37,9 +37,8 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Optional;
import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j
@@ -51,6 +50,7 @@ public class QueryConsole extends AbstractConsole {
private StateManager stateManager;
private final Map dataViews = new HashMap<>();
private volatile Map allowedContexts = Map.of();
+ private final SQLChunkTransferManager sqlChunkTransfers = new SQLChunkTransferManager();
private static final Gson GSON = new Gson();
@@ -92,15 +92,13 @@ public void onConnect(Connect connect) {
try {
var currentContext = this.getSqlActuator().getCurrentSchema();
- if (StringUtils.isEmpty(currentContext) && !StringUtils.isEmpty(context)) {
+ if (StringUtils.isNotEmpty(context)
+ && !StringUtils.equals(currentContext, context)) {
this.getSqlActuator().changeSchema(context);
- this.getState().setCurrentContext(context);
- } else {
- if (!StringUtils.isEmpty(context) && !currentContext.equals(context)) {
- this.getSqlActuator().changeSchema(context);
- this.getState().setCurrentContext(context);
- }
}
+ this.getState().setCurrentContext(
+ StringUtils.defaultIfEmpty(context, currentContext)
+ );
var schemas = this.getSqlActuator().getSchemas();
this.replaceAllowedContexts(schemas);
@@ -168,7 +166,7 @@ private void onAction(QueryConsoleAction action) {
this.handleSQLChunk(action);
}
case QueryConsoleAction.ACTION_RUN_SQL_COMPLETE -> {
- this.handleSQLComplete();
+ this.handleSQLComplete(action);
}
case QueryConsoleAction.ACTION_RUN_SQL_FILE -> {
@@ -184,6 +182,7 @@ private void onAction(QueryConsoleAction action) {
case QueryConsoleAction.ACTION_CANCEL -> {
+ this.sqlChunkTransfers.cancelAll();
this.onCancel();
this.getState().setInQuery(false);
this.stateManager.commit();
@@ -195,65 +194,26 @@ private void onAction(QueryConsoleAction action) {
}
}
- private final ConcurrentHashMap sqlChunks = new ConcurrentHashMap<>();
- private CountDownLatch latch;
- private int expectedChunks = -1;
-
private void handleSQLChunk(QueryConsoleAction action) {
- var data = (Map) action.getData();
- var chunk = (String) data.get("chunk");
- var index = (Integer) data.get("index");
- var total = (Integer) data.get("total");
-
- synchronized (this) {
- if (expectedChunks == -1) {
- expectedChunks = total;
- latch = new CountDownLatch(total);
- }
- }
-
- if (sqlChunks.putIfAbsent(index, chunk) == null) {
- latch.countDown();
+ Optional sql;
+ try {
+ sql = this.sqlChunkTransfers.receiveChunk(action.getData());
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ this.getConsoleLogger().error("Invalid SQL chunk transfer: %s", e.getMessage());
+ return;
}
+ sql.ifPresent(this::onSQL);
}
- /**
- * 处理分段 SQL 接收完成
- */
- private void handleSQLComplete() {
+ private void handleSQLComplete(QueryConsoleAction action) {
+ Optional sql;
try {
-
- // 等待所有分段接收完成
- boolean completed = latch.await(10, TimeUnit.SECONDS); // 超时10秒
-
- if (!completed) {
- this.getConsoleLogger().error("read sql message timeout!!");
- return;
- }
-
- // 按照索引顺序合并所有分段
- StringBuilder sqlBuilder = new StringBuilder();
- for (int i = 0; i < expectedChunks; i++) {
- sqlBuilder.append(sqlChunks.get(i));
- }
-
- // 合并完成后清理缓存
- var sql = sqlBuilder.toString();
- sqlChunks.clear();
- expectedChunks = -1;
-
- // 执行完整 SQL
- this.getState().setInQuery(true);
- this.stateManager.commit();
-
- this.onSQL(sql);
-
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- } finally {
- this.getState().setInQuery(false);
- this.stateManager.commit();
+ sql = this.sqlChunkTransfers.receiveComplete(action.getData());
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ this.getConsoleLogger().error("Invalid SQL chunk transfer: %s", e.getMessage());
+ return;
}
+ sql.ifPresent(this::onSQL);
}
private void onDataViewAction(DataViewAction action) {
@@ -538,6 +498,7 @@ private void sendDataView(DataView dataView, boolean clearOthers) {
@Override
public void close() {
+ this.sqlChunkTransfers.close();
if (this.currentPlan != null) {
// flush
var session = SessionManager.getCurrentSession();
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/SQLChunkTransferManager.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/SQLChunkTransferManager.java
new file mode 100644
index 00000000..35b4a571
--- /dev/null
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/SQLChunkTransferManager.java
@@ -0,0 +1,289 @@
+package org.jumpserver.chen.framework.console;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+final class SQLChunkTransferManager implements AutoCloseable {
+
+ static final int MAX_CHUNK_SIZE = 4096;
+ static final int MAX_CHUNKS = 1024;
+ private static final int MAX_ACTIVE_TRANSFERS = 2;
+ private static final int MAX_TRACKED_TRANSFERS = 64;
+ private static final int MAX_REQUEST_ID_LENGTH = 128;
+ private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30);
+ private static final ScheduledThreadPoolExecutor TIMEOUT_EXECUTOR = createTimeoutExecutor();
+
+ private final Object lock = new Object();
+ private final Map transfers = new HashMap<>();
+ private final ScheduledExecutorService scheduler;
+ private final Duration timeout;
+ private boolean closed;
+
+ SQLChunkTransferManager() {
+ this(TIMEOUT_EXECUTOR, DEFAULT_TIMEOUT);
+ }
+
+ SQLChunkTransferManager(ScheduledExecutorService scheduler, Duration timeout) {
+ this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
+ this.timeout = Objects.requireNonNull(timeout, "timeout");
+ if (timeout.isNegative() || timeout.isZero()) {
+ throw new IllegalArgumentException("timeout must be positive");
+ }
+ }
+
+ Optional receiveChunk(Object rawData) {
+ Map, ?> data = requireMap(rawData);
+ String requestId = requireRequestId(data);
+ int total;
+ int index;
+ String chunk;
+ try {
+ total = requireTotal(data);
+ index = requireInt(data, "index");
+ if (index < 0 || index >= total) {
+ throw new IllegalArgumentException("index is outside the transfer range");
+ }
+ chunk = requireChunk(data);
+ } catch (IllegalArgumentException e) {
+ reject(requestId);
+ throw e;
+ }
+
+ synchronized (lock) {
+ ensureOpen();
+ ChunkTransfer transfer = getOrCreate(requestId, total);
+ if (transfer.terminal) {
+ return Optional.empty();
+ }
+ if (transfer.total != total) {
+ rejectLocked(transfer);
+ throw new IllegalArgumentException("total does not match the existing transfer");
+ }
+
+ String existing = transfer.chunks[index];
+ if (existing != null) {
+ if (!existing.equals(chunk)) {
+ rejectLocked(transfer);
+ throw new IllegalArgumentException("duplicate chunk has different content");
+ }
+ return Optional.empty();
+ }
+
+ transfer.chunks[index] = chunk;
+ transfer.receivedChunks += 1;
+ return assembleIfReady(transfer);
+ }
+ }
+
+ Optional receiveComplete(Object rawData) {
+ Map, ?> data = requireMap(rawData);
+ String requestId = requireRequestId(data);
+ int total;
+ try {
+ total = requireTotal(data);
+ } catch (IllegalArgumentException e) {
+ reject(requestId);
+ throw e;
+ }
+
+ synchronized (lock) {
+ ensureOpen();
+ ChunkTransfer transfer = getOrCreate(requestId, total);
+ if (transfer.terminal) {
+ return Optional.empty();
+ }
+ if (transfer.total != total) {
+ rejectLocked(transfer);
+ throw new IllegalArgumentException("total does not match the existing transfer");
+ }
+
+ transfer.completeReceived = true;
+ return assembleIfReady(transfer);
+ }
+ }
+
+ void cancelAll() {
+ synchronized (lock) {
+ clearTransfersLocked();
+ }
+ }
+
+ @Override
+ public void close() {
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ clearTransfersLocked();
+ }
+ }
+
+ private ChunkTransfer getOrCreate(String requestId, int total) {
+ ChunkTransfer existing = transfers.get(requestId);
+ if (existing != null) {
+ return existing;
+ }
+ if (transfers.size() >= MAX_TRACKED_TRANSFERS) {
+ throw new IllegalArgumentException("too many tracked chunk transfers");
+ }
+ long activeTransfers = transfers.values().stream()
+ .filter(transfer -> !transfer.terminal)
+ .count();
+ if (activeTransfers >= MAX_ACTIVE_TRANSFERS) {
+ throw new IllegalArgumentException("too many active chunk transfers");
+ }
+
+ ChunkTransfer transfer = new ChunkTransfer(total);
+ transfers.put(requestId, transfer);
+ try {
+ transfer.timeoutFuture = scheduler.schedule(
+ () -> expire(requestId, transfer),
+ timeout.toMillis(),
+ TimeUnit.MILLISECONDS
+ );
+ } catch (RuntimeException e) {
+ transfers.remove(requestId, transfer);
+ throw e;
+ }
+ return transfer;
+ }
+
+ private Optional assembleIfReady(ChunkTransfer transfer) {
+ if (!transfer.completeReceived || transfer.receivedChunks != transfer.total) {
+ return Optional.empty();
+ }
+
+ StringBuilder sql = new StringBuilder();
+ for (String chunk : transfer.chunks) {
+ sql.append(chunk);
+ }
+ String assembled = sql.toString();
+ rejectLocked(transfer);
+ return Optional.of(assembled);
+ }
+
+ private void reject(String requestId) {
+ synchronized (lock) {
+ ChunkTransfer transfer = transfers.get(requestId);
+ if (transfer != null) {
+ rejectLocked(transfer);
+ }
+ }
+ }
+
+ private void rejectLocked(ChunkTransfer transfer) {
+ if (transfer.terminal) {
+ return;
+ }
+ transfer.terminal = true;
+ transfer.completeReceived = false;
+ transfer.receivedChunks = 0;
+ Arrays.fill(transfer.chunks, null);
+ }
+
+ private void expire(String requestId, ChunkTransfer expectedTransfer) {
+ synchronized (lock) {
+ if (transfers.remove(requestId, expectedTransfer)) {
+ rejectLocked(expectedTransfer);
+ }
+ }
+ }
+
+ private void clearTransfersLocked() {
+ for (ChunkTransfer transfer : transfers.values()) {
+ rejectLocked(transfer);
+ if (transfer.timeoutFuture != null) {
+ transfer.timeoutFuture.cancel(false);
+ }
+ }
+ transfers.clear();
+ }
+
+ private void ensureOpen() {
+ if (closed) {
+ throw new IllegalStateException("chunk transfer manager is closed");
+ }
+ }
+
+ private static Map, ?> requireMap(Object data) {
+ if (!(data instanceof Map, ?> map)) {
+ throw new IllegalArgumentException("chunk data must be an object");
+ }
+ return map;
+ }
+
+ private static String requireRequestId(Map, ?> data) {
+ Object value = data.get("requestId");
+ if (!(value instanceof String requestId)
+ || requestId.isBlank()
+ || requestId.length() > MAX_REQUEST_ID_LENGTH) {
+ throw new IllegalArgumentException("requestId is invalid");
+ }
+ return requestId;
+ }
+
+ private static int requireTotal(Map, ?> data) {
+ int total = requireInt(data, "total");
+ if (total <= 0 || total > MAX_CHUNKS) {
+ throw new IllegalArgumentException("total is outside the allowed range");
+ }
+ return total;
+ }
+
+ private static int requireInt(Map, ?> data, String key) {
+ Object value = data.get(key);
+ if (!(value instanceof Number number)) {
+ throw new IllegalArgumentException(key + " must be a number");
+ }
+ try {
+ return new BigDecimal(number.toString()).intValueExact();
+ } catch (ArithmeticException | NumberFormatException e) {
+ throw new IllegalArgumentException(key + " must be a finite int", e);
+ }
+ }
+
+ private static String requireChunk(Map, ?> data) {
+ Object value = data.get("chunk");
+ if (!(value instanceof String chunk)) {
+ throw new IllegalArgumentException("chunk must be a string");
+ }
+ if (chunk.length() > MAX_CHUNK_SIZE) {
+ throw new IllegalArgumentException("chunk exceeds the allowed size");
+ }
+ return chunk;
+ }
+
+ private static ScheduledThreadPoolExecutor createTimeoutExecutor() {
+ ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> {
+ Thread thread = new Thread(runnable, "query-console-chunk-timeout");
+ thread.setDaemon(true);
+ return thread;
+ });
+ executor.setRemoveOnCancelPolicy(true);
+ return executor;
+ }
+
+ private static final class ChunkTransfer {
+ private final int total;
+ private final String[] chunks;
+ private int receivedChunks;
+ private boolean completeReceived;
+ private boolean terminal;
+ private ScheduledFuture> timeoutFuture;
+
+ private ChunkTransfer(int total) {
+ this.total = total;
+ this.chunks = new String[total];
+ }
+ }
+}
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
index 8b3a10b0..131757d6 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/console/dataview/export/DataExport.java
@@ -141,6 +141,7 @@ public void exportData(String path, DataViewData data) throws Exception {
} else if (obj instanceof Date) {
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
writeString(writer, fmt.format(obj));
+ writer.write(",");
} else {
writeString(writer, row.get(field.getName()));
writer.write(",");
@@ -157,9 +158,9 @@ public void exportData(String path, DataViewData data) throws Exception {
private static void writeString(BufferedWriter writer, Object object) throws IOException {
var str = object.toString();
- if (str.contains(",")) {
- str = "\"" + str + "\"";
+ if (str.contains("\"") || str.contains(",")) {
+ str = "\"" + str.replace("\"", "\"\"") + "\"";
}
writer.write(str);
}
-}
\ No newline at end of file
+}
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseActionHandler.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseActionHandler.java
index a6136410..f2fa7bd7 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseActionHandler.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseActionHandler.java
@@ -162,7 +162,9 @@ public EventEmitter onShowProperties(TreeNode node) {
public EventEmitter onShowObjectProperties(String type, String sql, TreeNode node) throws SQLException {
var sqlActuator = this.getDatasource().getConnectionManager().getSqlActuator();
var objName = TreeUtils.getValue(node.getKey(), type);
- var result = sqlActuator.execute(SQL.of(sql, objName));
+ var command = SQL.of(sql, objName.replace("'", "''"));
+ log.info("resource action show_properties: type={}, node={}", type, node.getKey());
+ var result = sqlActuator.execute(command);
var detailDialog = new DetailDialog(node.getKey(), type + MessageUtils.get("Properties"));
detailDialog.setWidth("50%");
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java
index 577a3699..b3d1dfce 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseConnectionManager.java
@@ -10,6 +10,7 @@
import org.jumpserver.chen.framework.driver.DriverClassLoader;
import org.jumpserver.chen.framework.driver.DriverManager;
import org.jumpserver.chen.framework.i18n.MessageUtils;
+import org.jumpserver.chen.framework.utils.SqlIdentifierUtils;
import java.lang.reflect.InvocationTargetException;
import java.sql.Connection;
@@ -120,6 +121,9 @@ public DruidDataSource getOrInitDataSource(String database) throws SQLException
if (StringUtils.isEmpty(database)) {
database = this.connectInfo.getDb();
}
+ // Reject URL metacharacters before interpolation into the JDBC URL,
+ // preventing injection of driver connection properties.
+ SqlIdentifierUtils.validateDatabaseName(database);
if (this.dataSourceMap.containsKey(database)) {
return this.dataSourceMap.get(database);
}
diff --git a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java
index 757aa54f..99b0c187 100644
--- a/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java
+++ b/backend/framework/src/main/java/org/jumpserver/chen/framework/datasource/base/BaseSQLActuator.java
@@ -25,6 +25,17 @@
import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.*;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -41,6 +52,22 @@ public abstract class BaseSQLActuator implements SQLActuator {
// Keep large JDBC text values bounded so one cell cannot fail or stall the whole result view.
private static final int MAX_TEXT_DISPLAY_LENGTH = 1024 * 1024;
private static final String TRUNCATED_SUFFIX = "...[truncated]";
+ private static final DateTimeFormatter LOCAL_DATE_TIME_DISPLAY_FORMATTER = new DateTimeFormatterBuilder()
+ .appendPattern("uuuu-MM-dd HH:mm:ss")
+ .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+ .toFormatter();
+ private static final DateTimeFormatter LOCAL_TIME_DISPLAY_FORMATTER = new DateTimeFormatterBuilder()
+ .appendPattern("HH:mm:ss")
+ .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+ .toFormatter();
+ private static final DateTimeFormatter OFFSET_DATE_TIME_DISPLAY_FORMATTER = new DateTimeFormatterBuilder()
+ .append(LOCAL_DATE_TIME_DISPLAY_FORMATTER)
+ .appendOffsetId()
+ .toFormatter();
+ private static final DateTimeFormatter OFFSET_TIME_DISPLAY_FORMATTER = new DateTimeFormatterBuilder()
+ .append(LOCAL_TIME_DISPLAY_FORMATTER)
+ .appendOffsetId()
+ .toFormatter();
private ConnectionManager connectionManager;
private Connection connection;
@@ -172,7 +199,7 @@ private void executeStatement(SQLExecutePlan plan, Statement statement, SQLQuery
List