diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2DBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2DBManager.java index dcb9f1b89d..7a2aaaae23 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2DBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2DBManager.java @@ -1,6 +1,7 @@ package ai.chat2db.plugin.h2; import ai.chat2db.spi.IDbManager; +import ai.chat2db.plugin.h2.identifier.H2IdentifierProcessor; import ai.chat2db.spi.DefaultDBManager; import ai.chat2db.community.domain.api.model.async.AsyncContext; import ai.chat2db.spi.sql.Chat2DBContext; @@ -25,10 +26,11 @@ public void exportDatabase(Connection connection, String databaseName, String sc } private void exportSchema(Connection connection, String schemaName, AsyncContext asyncContext) throws SQLException { - String sql = String.format("SCRIPT NODATA NOPASSWORDS NOSETTINGS DROP SCHEMA %s;", schemaName); + String template = "SCRIPT NODATA NOPASSWORDS NOSETTINGS DROP SCHEMA %s;"; if (asyncContext.isContainsData()) { - sql = sql.replace("NODATA", ""); + template = template.replace("NODATA", ""); } + String sql = String.format(template, H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { while (resultSet.next()) { String script = resultSet.getString("SCRIPT"); @@ -51,7 +53,8 @@ public void connectDatabase(Connection connection, String database) { } String schemaName = connectInfo.getSchemaName(); try { - DefaultSQLExecutor.getInstance().execute(connection, String.format(SQL_SET_SCHEMA, schemaName)); + DefaultSQLExecutor.getInstance().execute(connection, + String.format(SQL_SET_SCHEMA, H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(schemaName))); } catch (SQLException e) { } @@ -60,6 +63,6 @@ public void connectDatabase(Connection connection, String database) { @Override public String dropTable(Connection connection, String databaseName, String schemaName, String tableName) { - return String.format(SQL_DROP_TABLE, tableName); + return String.format(SQL_DROP_TABLE, H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName)); } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2Meta.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2Meta.java index d0e523947a..59112bc2e7 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2Meta.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2Meta.java @@ -20,7 +20,9 @@ import ai.chat2db.community.domain.api.model.sql.*; import ai.chat2db.spi.model.value.*; import ai.chat2db.community.domain.api.model.view.*; +import ai.chat2db.plugin.h2.identifier.H2IdentifierProcessor; import ai.chat2db.spi.DefaultSQLExecutor; +import ai.chat2db.spi.ISQLIdentifierProcessor; import ai.chat2db.spi.util.SortUtils; import jakarta.validation.constraints.NotEmpty; import lombok.extern.slf4j.Slf4j; @@ -30,6 +32,11 @@ @Slf4j public class H2Meta extends DefaultMetaService implements IDbMetaData { + @Override + public ISQLIdentifierProcessor getSQLIdentifierProcessor() { + return H2IdentifierProcessor.INSTANCE; + } + @Override @@ -50,21 +57,21 @@ private String getDDL(Connection connection, String databaseName, String schemaN while (columns.next()) { String columnName = columns.getString("COLUMN_NAME"); String columnType = columns.getString("TYPE_NAME"); + int dataType = columns.getInt("DATA_TYPE"); int columnSize = columns.getInt("COLUMN_SIZE"); + int decimalDigits = columns.getInt("DECIMAL_DIGITS"); String remarks = columns.getString("REMARKS"); String defaultValue = columns.getString("COLUMN_DEF"); String nullable = columns.getInt("NULLABLE") == ResultSetMetaData.columnNullable ? "NULL" : "NOT NULL"; StringBuilder columnDefinition = new StringBuilder(); - columnDefinition.append(columnName).append(" ").append(columnType); - if (columnSize != 0) { - columnDefinition.append("(").append(columnSize).append(")"); - } + columnDefinition.append(H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(columnName)).append(" ") + .append(H2SqlGuards.renderMetadataType(columnType, dataType, columnSize, decimalDigits)); columnDefinition.append(" ").append(nullable); if (defaultValue != null) { - columnDefinition.append(" DEFAULT ").append(defaultValue); + columnDefinition.append(" DEFAULT ").append(H2SqlGuards.escapeColumnDefault(defaultValue)); } if (remarks != null) { - columnDefinition.append(SQL_COMMENT).append(remarks).append("'"); + columnDefinition.append(SQL_COMMENT).append(getSQLIdentifierProcessor().escapeString(remarks)).append("'"); } columnDefinitions.add(columnDefinition.toString()); } @@ -86,15 +93,16 @@ private String getDDL(Connection connection, String databaseName, String schemaN } StringBuilder createTableDDL = new StringBuilder(SQL_CREATE_TABLE); - createTableDDL.append(tableName).append(" (\n"); + createTableDDL.append(H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName)).append(" (\n"); createTableDDL.append(String.join(",\n", columnDefinitions)); createTableDDL.append("\n);\n"); for (Map.Entry> entry : indexMap.entrySet()) { String indexName = entry.getKey(); List columnList = entry.getValue(); - String indexColumns = String.join(", ", columnList); - String createIndexDDL = String.format(SQL_CREATE_INDEX, indexName, tableName, - indexColumns); + String indexColumns = columnList.stream().map(H2IdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(", ")); + String createIndexDDL = String.format(SQL_CREATE_INDEX, H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(indexName), + H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName), indexColumns); createTableDDL.append(createIndexDDL); } return createTableDDL.toString(); @@ -110,7 +118,8 @@ private String getDDL(Connection connection, String databaseName, String schemaN public Function function(Connection connection, @NotEmpty String databaseName, String schemaName, String functionName) { - String sql = String.format(ROUTINES_SQL, "FUNCTION", databaseName, functionName); + String sql = String.format(ROUTINES_SQL, "FUNCTION", getSQLIdentifierProcessor().escapeString(databaseName), + getSQLIdentifierProcessor().escapeString(functionName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Function function = new Function(); function.setDatabaseName(databaseName); @@ -133,7 +142,8 @@ public Function function(Connection connection, @NotEmpty String databaseName, S @Override public List triggers(Connection connection, String databaseName, String schemaName) { List triggers = new ArrayList<>(); - String sql = String.format(TRIGGER_SQL_LIST, databaseName,schemaName); + String sql = String.format(TRIGGER_SQL_LIST, getSQLIdentifierProcessor().escapeString(databaseName), + getSQLIdentifierProcessor().escapeString(schemaName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { Trigger trigger = new Trigger(); @@ -150,7 +160,8 @@ public List triggers(Connection connection, String databaseName, String public Trigger trigger(Connection connection, @NotEmpty String databaseName, String schemaName, String triggerName) { - String sql = String.format(TRIGGER_SQL, databaseName, triggerName); + String sql = String.format(TRIGGER_SQL, getSQLIdentifierProcessor().escapeString(databaseName), + getSQLIdentifierProcessor().escapeString(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Trigger trigger = new Trigger(); trigger.setDatabaseName(databaseName); @@ -166,7 +177,8 @@ public Trigger trigger(Connection connection, @NotEmpty String databaseName, Str @Override public Procedure procedure(Connection connection, @NotEmpty String databaseName, String schemaName, String procedureName) { - String sql = String.format(ROUTINES_SQL, "PROCEDURE", databaseName, procedureName); + String sql = String.format(ROUTINES_SQL, "PROCEDURE", getSQLIdentifierProcessor().escapeString(databaseName), + getSQLIdentifierProcessor().escapeString(procedureName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Procedure procedure = new Procedure(); procedure.setDatabaseName(databaseName); @@ -184,7 +196,8 @@ public Procedure procedure(Connection connection, @NotEmpty String databaseName, @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { - String sql = String.format(VIEW_SQL, databaseName, schemaName, viewName); + String sql = String.format(VIEW_SQL, getSQLIdentifierProcessor().escapeString(databaseName), + getSQLIdentifierProcessor().escapeString(schemaName), getSQLIdentifierProcessor().escapeString(viewName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { Table table = new Table(); table.setDatabaseName(databaseName); @@ -204,7 +217,7 @@ public ISqlBuilder getSqlBuilder() { @Override public String getMetaDataName(String... names) { - return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(name -> "\"" + name + "\"").collect(Collectors.joining(".")); + return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(H2IdentifierProcessor.INSTANCE::quoteIdentifierAlways).collect(Collectors.joining(".")); } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2SqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2SqlGuards.java new file mode 100644 index 0000000000..94e5610d8d --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/H2SqlGuards.java @@ -0,0 +1,127 @@ +package ai.chat2db.plugin.h2; + +import java.sql.Types; +import java.util.regex.Pattern; + +import ai.chat2db.plugin.h2.identifier.H2IdentifierProcessor; + +/** + * Validation helpers for non-escapable SQL positions in H2 DDL generation + * (column type names and column default expressions reported by JDBC metadata). + * Escaping itself lives in {@link H2IdentifierProcessor}. + */ +public final class H2SqlGuards { + + /** + * Conservative allow-list for column type names reported by JDBC metadata + * (e.g. {@code INTEGER}, {@code CHARACTER VARYING}). Anything else is rejected + * so hostile or corrupt metadata cannot smuggle SQL into generated DDL. + */ + private static final Pattern SAFE_TYPE_NAME = Pattern.compile( + "^[A-Za-z][A-Za-z0-9_]*(?:\\s+[A-Za-z][A-Za-z0-9_]*)*$"); + + private static final String STRING_LITERAL_SOURCE = "'(?:''|[^'])*'"; + private static final String IDENTIFIER_SOURCE = "(?:[A-Za-z_][A-Za-z0-9_$]*|\"(?:\"\"|[^\"])+\")"; + private static final Pattern STRING_LITERAL = Pattern.compile("^" + STRING_LITERAL_SOURCE + "$"); + private static final Pattern NUMERIC_LITERAL = Pattern.compile( + "^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$"); + private static final Pattern SIMPLE_CONSTANT = Pattern.compile("(?i)^(?:NULL|TRUE|FALSE)$"); + private static final Pattern CURRENT_TEMPORAL = Pattern.compile( + "(?i)^(?:CURRENT_DATE|CURRENT_TIME|CURRENT_TIMESTAMP|LOCALTIME|LOCALTIMESTAMP)(?:\\(\\d+\\))?$"); + private static final Pattern SAFE_NO_ARG_FUNCTION = Pattern.compile( + "(?i)^(?:NOW|RANDOM_UUID|UUID)\\(\\s*(?:\\d+)?\\s*\\)$"); + private static final Pattern TYPED_LITERAL = Pattern.compile( + "(?i)^(?:DATE|TIME(?:\\s+WITH\\s+TIME\\s+ZONE)?|TIMESTAMP(?:\\s+WITH\\s+TIME\\s+ZONE)?|UUID|JSON|GEOMETRY)\\s+" + + STRING_LITERAL_SOURCE + "$"); + private static final Pattern BINARY_LITERAL = Pattern.compile("(?i)^(?:X|BINARY)\\s*'[0-9A-F]*'$"); + private static final Pattern SEQUENCE_EXPRESSION = Pattern.compile( + "(?i)^NEXT\\s+VALUE\\s+FOR\\s+" + IDENTIFIER_SOURCE + "(?:\\." + IDENTIFIER_SOURCE + ")?$"); + + private H2SqlGuards() { + } + + /** + * Validates a column type name obtained from JDBC metadata before it is embedded + * into generated DDL. Returns the type name unchanged when it matches the + * allow-list; throws otherwise (fail closed). + */ + public static String requireSafeTypeName(String typeName) { + if (typeName != null && !SAFE_TYPE_NAME.matcher(typeName).matches()) { + throw new IllegalArgumentException("Unsafe column type name from metadata: " + typeName); + } + return typeName; + } + + /** + * Reconstructs a type declaration from JDBC metadata without treating display width as a + * type parameter. H2 reports values such as 64 for BIGINT and 26 for TIMESTAMP in + * COLUMN_SIZE, but those values are not legal declarations for these types. + */ + public static String renderMetadataType(String typeName, int dataType, int columnSize, int decimalDigits) { + String safeTypeName = requireSafeTypeName(typeName); + StringBuilder declaration = new StringBuilder(safeTypeName); + switch (dataType) { + case Types.CHAR: + case Types.VARCHAR: + case Types.NCHAR: + case Types.NVARCHAR: + case Types.BINARY: + case Types.VARBINARY: + appendTypeArguments(declaration, columnSize, null); + break; + case Types.DECIMAL: + case Types.NUMERIC: + appendTypeArguments(declaration, columnSize, Math.max(decimalDigits, 0)); + break; + case Types.FLOAT: + appendTypeArguments(declaration, columnSize, null); + break; + case Types.TIME: + case Types.TIME_WITH_TIMEZONE: + case Types.TIMESTAMP: + case Types.TIMESTAMP_WITH_TIMEZONE: + if (decimalDigits > 0) { + appendTypeArguments(declaration, decimalDigits, null); + } + break; + default: + break; + } + return declaration.toString(); + } + + private static void appendTypeArguments(StringBuilder declaration, int precision, Integer scale) { + if (precision <= 0) { + return; + } + declaration.append('(').append(precision); + if (scale != null) { + declaration.append(',').append(scale); + } + declaration.append(')'); + } + + /** + * Validates a column default obtained from JDBC metadata. Only complete literal forms and + * common H2-generated expressions are accepted; invalid input is rejected rather than + * silently converted into a string literal with different semantics. + * Returns an empty string for {@code null}. + */ + public static String escapeColumnDefault(String columnDefault) { + if (columnDefault == null) { + return ""; + } + String trimmed = columnDefault.trim(); + if (STRING_LITERAL.matcher(trimmed).matches() + || NUMERIC_LITERAL.matcher(trimmed).matches() + || SIMPLE_CONSTANT.matcher(trimmed).matches() + || CURRENT_TEMPORAL.matcher(trimmed).matches() + || SAFE_NO_ARG_FUNCTION.matcher(trimmed).matches() + || TYPED_LITERAL.matcher(trimmed).matches() + || BINARY_LITERAL.matcher(trimmed).matches() + || SEQUENCE_EXPRESSION.matcher(trimmed).matches()) { + return trimmed; + } + throw new IllegalArgumentException("Unsafe H2 column default from metadata: " + columnDefault); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/builder/H2SqlBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/builder/H2SqlBuilder.java index d2e13fa682..fbf3707e90 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/builder/H2SqlBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/builder/H2SqlBuilder.java @@ -1,24 +1,279 @@ package ai.chat2db.plugin.h2.builder; +import ai.chat2db.community.domain.api.enums.plugin.DmlTypeEnum; +import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; +import ai.chat2db.community.domain.api.enums.plugin.IndexTypeEnum; +import ai.chat2db.community.domain.api.model.metadata.Database; +import ai.chat2db.community.domain.api.model.metadata.Schema; +import ai.chat2db.community.domain.api.model.metadata.Table; +import ai.chat2db.community.domain.api.model.metadata.TableColumn; +import ai.chat2db.community.domain.api.model.metadata.TableIndex; +import ai.chat2db.community.domain.api.model.metadata.TableIndexColumn; +import ai.chat2db.plugin.h2.identifier.H2IdentifierProcessor; import ai.chat2db.spi.constant.SQLConstants; +import ai.chat2db.plugin.h2.H2SqlGuards; import ai.chat2db.spi.DefaultSqlBuilder; -import ai.chat2db.community.domain.api.model.metadata.Schema; +import ai.chat2db.community.domain.api.config.TableBuilderConfig; +import ai.chat2db.spi.model.request.UpdateSqlRequest; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + import static ai.chat2db.plugin.h2.constant.H2SqlBuilderConstants.*; public class H2SqlBuilder extends DefaultSqlBuilder { + @Override + public String quoteIdentifier(String identifier) { + return H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier); + } + @Override + public String quoteQualifiedIdentifier(String... identifiers) { + return Arrays.stream(identifiers) + .filter(StringUtils::isNotBlank) + .map(H2IdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(SQLConstants.DOT)); + } + @Override + protected void buildTableName(String databaseName, String schemaName, String tableName, StringBuilder script) { + script.append(quoteQualifiedIdentifier(databaseName, schemaName, tableName)); + } + + @Override + protected void buildColumns(List columnList, StringBuilder script) { + if (CollectionUtils.isNotEmpty(columnList)) { + script.append(SQLConstants.SPACE_OPEN_PARENTHESIS) + .append(columnList.stream().map(H2IdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(SQLConstants.COMMA))) + .append(SQLConstants.CLOSE_PARENTHESIS_SPACE); + } + } + + @Override + public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) { + StringBuilder script = new StringBuilder(); + script.append(SQLConstants.CREATE_TABLE_SQL_PREFIX); + script.append(quoteIdentifier(table.getName())) + .append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR); + + List comments = new ArrayList<>(); + for (TableColumn column : table.getColumnList()) { + if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { + continue; + } + script.append(SQLConstants.TAB).append(SQLConstants.SPACE) + .append(quoteIdentifier(column.getName())).append(SQLConstants.SPACE) + .append(H2SqlGuards.requireSafeTypeName(column.getColumnType())); + if (column.getColumnSize() != null) { + script.append(SQLConstants.OPEN_PARENTHESIS).append(column.getColumnSize()); + if (column.getDecimalDigits() != null) { + script.append(SQLConstants.COMMA).append(column.getDecimalDigits()); + } + script.append(SQLConstants.CLOSE_PARENTHESIS); + } + + if (column.getNullable() != null && 1 == column.getNullable()) { + script.append(SQLConstants.NULL_SQL); + } else { + script.append(SQLConstants.NOT_NULL_SQL_WITH_PREFIX); + } + if (StringUtils.isNotBlank(column.getComment())) { + comments.add(generateCommentSql(column)); + } + script.append(SQLConstants.COMMA_LINE_SEPARATOR); + } + script = new StringBuilder(script.substring(0, script.length() - 2)); + script.append(SQLConstants.LINE_SEPARATOR_CLOSE_PARENTHESIS); + script.append(SQLConstants.SEMICOLON); + if (CollectionUtils.isNotEmpty(comments)) { + script.append(SQLConstants.LINE_SEPARATOR); + comments.forEach(script::append); + } + if (CollectionUtils.isNotEmpty(table.getIndexList())) { + script.append(generateIndexSql(table.getIndexList())); + } + return script.toString(); + } + + private String generateCommentSql(TableColumn column) { + return SQLConstants.COMMENT_ON_COLUMN_SQL_PREFIX + + quoteIdentifier(column.getTableName()) + SQLConstants.DOT + quoteIdentifier(column.getName()) + + SQLConstants.SQL_IS_SINGLE_QUOTE + + H2IdentifierProcessor.INSTANCE.escapeString(column.getComment()) + + SQLConstants.SINGLE_QUOTE_SEMICOLON_LINE_SEPARATOR; + } + + private String generateIndexSql(List indexList) { + StringBuilder script = new StringBuilder(); + for (TableIndex index : indexList) { + List columnList = index.getColumnList(); + if (CollectionUtils.isEmpty(columnList)) { + continue; + } + script.append(SQLConstants.CREATE_SQL_PREFIX); + if (IndexTypeEnum.UNIQUE.getName().equals(index.getType())) { + script.append(SQLConstants.UNIQUE_SQL); + } + script.append(SQLConstants.INDEX_SQL); + script.append(quoteIdentifier(index.getName())); + script.append(SQLConstants.SQL_ON); + script.append(quoteIdentifier(index.getTableName())); + script.append(SQLConstants.SPACE_OPEN_PARENTHESIS); + script.append(columnList.stream() + .map(column -> quoteIdentifier(column.getColumnName())) + .collect(Collectors.joining(SQLConstants.COMMA))); + script.append(SQLConstants.CLOSE_PARENTHESIS_SEMICOLON_LINE_SEPARATOR); + } + return script.toString(); + } + + @Override + public String buildAlterTable(Table oldTable, Table newTable) { + StringBuilder script = new StringBuilder(); + if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { + script.append(SQLConstants.ALTER_TABLE_SQL_PREFIX).append(quoteIdentifier(oldTable.getName())) + .append(SQLConstants.TABLE_RENAME_SQL).append(quoteIdentifier(newTable.getName())) + .append(SQLConstants.SEMICOLON).append(SQLConstants.LINE_SEPARATOR); + } + if (!StringUtils.equalsIgnoreCase(oldTable.getComment(), newTable.getComment())) { + script.append(SQLConstants.COMMENT_ON_TABLE_SQL_PREFIX).append(quoteIdentifier(newTable.getName())) + .append(SQLConstants.SQL_IS_SINGLE_QUOTE) + .append(H2IdentifierProcessor.INSTANCE.escapeString(newTable.getComment())) + .append(SQLConstants.SINGLE_QUOTE_SEMICOLON).append(SQLConstants.LINE_SEPARATOR); + } + for (TableColumn tableColumn : newTable.getColumnList()) { + if (StringUtils.isNotBlank(tableColumn.getEditStatus()) && StringUtils.isNotBlank( + tableColumn.getColumnType()) && StringUtils.isNotBlank(tableColumn.getName())) { + script.append(generateAlterColumnSql(tableColumn)).append(SQLConstants.LINE_SEPARATOR); + } + } + for (TableIndex tableIndex : newTable.getIndexList()) { + if (StringUtils.isNotBlank(tableIndex.getEditStatus()) && StringUtils.isNotBlank(tableIndex.getType())) { + script.append(generateAlterIndexSql(tableIndex)).append(SQLConstants.LINE_SEPARATOR); + } + } + return script.toString(); + } + + private String generateAlterColumnSql(TableColumn tableColumn) { + if (EditStatusEnum.DELETE.name().equals(tableColumn.getEditStatus())) { + return SQLConstants.ALTER_TABLE_SQL_PREFIX + quoteIdentifier(tableColumn.getTableName()) + + " DROP COLUMN " + quoteIdentifier(tableColumn.getName()) + + SQLConstants.SEMICOLON; + } + if (EditStatusEnum.ADD.name().equals(tableColumn.getEditStatus())) { + return SQLConstants.ALTER_TABLE_SQL_PREFIX + quoteIdentifier(tableColumn.getTableName()) + + " ADD COLUMN " + quoteIdentifier(tableColumn.getName()) + + SQLConstants.SPACE + H2SqlGuards.requireSafeTypeName(tableColumn.getColumnType()) + SQLConstants.SEMICOLON; + } + if (EditStatusEnum.MODIFY.name().equals(tableColumn.getEditStatus())) { + return SQLConstants.ALTER_TABLE_SQL_PREFIX + quoteIdentifier(tableColumn.getTableName()) + + " MODIFY COLUMN " + quoteIdentifier(tableColumn.getName()) + + SQLConstants.SPACE + H2SqlGuards.requireSafeTypeName(tableColumn.getColumnType()) + SQLConstants.SEMICOLON; + } + if (tableColumn.getComment() != null) { + return generateCommentSql(tableColumn); + } + return SQLConstants.EMPTY; + } + + private String generateAlterIndexSql(TableIndex tableIndex) { + if (EditStatusEnum.DELETE.name().equals(tableIndex.getEditStatus())) { + return SQLConstants.DROP_INDEX_SQL_PREFIX + quoteIdentifier(tableIndex.getName()) + + SQLConstants.SEMICOLON; + } + if (EditStatusEnum.ADD.name().equals(tableIndex.getEditStatus())) { + boolean unique = IndexTypeEnum.UNIQUE.getName().equals(tableIndex.getType()); + String columnNames = tableIndex.getColumnList().stream() + .map(column -> quoteIdentifier(column.getColumnName())) + .collect(Collectors.joining(SQLConstants.COMMA + SQLConstants.SPACE)); + return SQLConstants.CREATE_SQL_PREFIX + (unique ? SQLConstants.UNIQUE_SQL : SQLConstants.EMPTY) + + SQLConstants.INDEX_SQL + quoteIdentifier(tableIndex.getName()) + + SQLConstants.SQL_ON + quoteIdentifier(tableIndex.getTableName()) + + SQLConstants.SPACE_OPEN_PARENTHESIS + columnNames + + SQLConstants.CLOSE_PARENTHESIS + SQLConstants.SEMICOLON; + } + return SQLConstants.EMPTY; + } + + @Override + public String buildUpdate(UpdateSqlRequest request) { + Map row = request.getRow(); + Map primaryKeyMap = request.getPrimaryKeyMap(); + StringBuilder script = new StringBuilder(); + script.append(SQLConstants.UPDATE_SQL_PREFIX); + buildTableName(request.getDatabaseName(), request.getSchemaName(), request.getTableName(), script); + + script.append(SQLConstants.SET_SQL); + List setClauses = row.entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.toList()); + script.append(String.join(SQLConstants.COMMA, setClauses)); + + if (MapUtils.isNotEmpty(primaryKeyMap)) { + script.append(SQLConstants.WHERE_SQL); + List whereClauses = primaryKeyMap.entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.toList()); + script.append(String.join(SQLConstants.SQL_AND, whereClauses)); + } + return script.toString(); + } + + @Override + public String buildTemplate(Table table, String type) { + if (table == null || CollectionUtils.isEmpty(table.getColumnList()) || StringUtils.isBlank(type)) { + return SQLConstants.EMPTY; + } + String tableName = quoteIdentifier(table.getName()); + List columnNames = table.getColumnList().stream() + .map(column -> quoteIdentifier(column.getName())) + .collect(Collectors.toList()); + if (DmlTypeEnum.INSERT.name().equalsIgnoreCase(type)) { + return SQLConstants.INSERT_INTO_SQL_PREFIX + tableName + SQLConstants.SPACE_OPEN_PARENTHESIS + + String.join(SQLConstants.COMMA, columnNames) + + SQLConstants.CLOSE_PARENTHESIS + SQLConstants.VALUES_SQL + SQLConstants.OPEN_PARENTHESIS + + columnNames.stream().map(name -> SQLConstants.SPACE) + .collect(Collectors.joining(SQLConstants.COMMA)) + + SQLConstants.CLOSE_PARENTHESIS; + } else if (DmlTypeEnum.UPDATE.name().equalsIgnoreCase(type)) { + String setClause = columnNames.stream() + .map(name -> name + SQLConstants.EQUAL_SQL + SQLConstants.SPACE) + .collect(Collectors.joining(SQLConstants.COMMA)); + return SQLConstants.UPDATE_SQL_PREFIX + tableName + SQLConstants.SET_SQL_LOWER + + setClause + SQLConstants.WHERE_SQL_LOWER; + } else if (DmlTypeEnum.DELETE.name().equalsIgnoreCase(type)) { + return SQLConstants.DELETE_FROM_SQL_PREFIX + tableName + SQLConstants.WHERE_SQL_LOWER; + } else if (DmlTypeEnum.SELECT.name().equalsIgnoreCase(type)) { + return SQLConstants.SELECT_SQL_PREFIX + String.join(SQLConstants.COMMA, columnNames) + + SQLConstants.FROM_WHERE_SQL + tableName; + } + return SQLConstants.EMPTY; + } + + @Override + public String buildCreateDatabase(Database database) { + return SQLConstants.CREATE_DATABASE_SQL_PREFIX + quoteIdentifier(database.getName()); + } @Override public String buildCreateSchema(Schema schema) { StringBuilder sqlBuilder = new StringBuilder(); - sqlBuilder.append(SQL_CREATE_SCHEMA + schema.getName() + SQLConstants.DOUBLE_QUOTE_SEMICOLON); + String quotedSchemaName = quoteIdentifier(schema.getName()); + sqlBuilder.append(SQL_CREATE_SCHEMA).append(quotedSchemaName).append(SQLConstants.SEMICOLON); if (StringUtils.isNotBlank(schema.getComment())) { - sqlBuilder.append(SQL_COMMENT_ON_SCHEMA_DOUBLE_QUOTE).append(schema.getName()).append(VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE).append(schema.getComment()).append(SQLConstants.SINGLE_QUOTE_SEMICOLON); + sqlBuilder.append(SQL_COMMENT_ON_SCHEMA).append(quotedSchemaName).append(VALUE_IS_SINGLE_QUOTE) + .append(H2IdentifierProcessor.INSTANCE.escapeString(schema.getComment())) + .append(SQLConstants.SINGLE_QUOTE_SEMICOLON); } return sqlBuilder.toString(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2DBManagerConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2DBManagerConstants.java index 7b97357d3a..681787b592 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2DBManagerConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2DBManagerConstants.java @@ -18,7 +18,7 @@ public final class H2DBManagerConstants { public static final String SQL_DROP_TABLE = "DROP TABLE %s"; - public static final String SQL_SET_SCHEMA = "SET SCHEMA \"%s\""; + public static final String SQL_SET_SCHEMA = "SET SCHEMA %s"; private H2DBManagerConstants() { } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2SqlBuilderConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2SqlBuilderConstants.java index dc465f9947..6ec9f8ff8b 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2SqlBuilderConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/constant/H2SqlBuilderConstants.java @@ -9,9 +9,9 @@ public final class H2SqlBuilderConstants { - public static final String SQL_COMMENT_ON_SCHEMA_DOUBLE_QUOTE = "\nCOMMENT ON SCHEMA \""; - public static final String VALUE_DOUBLE_QUOTE_IS_SINGLE_QUOTE = "\" IS '"; - public static final String SQL_CREATE_SCHEMA = "CREATE SCHEMA \""; + public static final String SQL_COMMENT_ON_SCHEMA = "\nCOMMENT ON SCHEMA "; + public static final String VALUE_IS_SINGLE_QUOTE = " IS '"; + public static final String SQL_CREATE_SCHEMA = "CREATE SCHEMA "; private H2SqlBuilderConstants() { } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/identifier/H2IdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/identifier/H2IdentifierProcessor.java new file mode 100644 index 0000000000..9e779bb809 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/main/java/ai/chat2db/plugin/h2/identifier/H2IdentifierProcessor.java @@ -0,0 +1,55 @@ +package ai.chat2db.plugin.h2.identifier; + +import ai.chat2db.spi.DefaultSQLIdentifierProcessor; +import org.apache.commons.lang3.StringUtils; +import org.h2.util.ParserUtil; + +/** + * H2 dialect identifier processor: double-quoted identifiers with embedded-quote + * doubling, and single-quote doubling for string literals. Shared stateless + * instance available via {@link #INSTANCE} for call sites without MetaData access. + */ +public class H2IdentifierProcessor extends DefaultSQLIdentifierProcessor { + + public static final H2IdentifierProcessor INSTANCE = new H2IdentifierProcessor(); + + /** + * SPI-facing conditional quoting: null/blank pass through unchanged; identifiers that + * H2 can use unquoted without case folding stay plain; anything else is safely quoted. + */ + @Override + public String quoteIdentifier(String identifier) { + if (StringUtils.isBlank(identifier)) { + return identifier; + } + if (ParserUtil.isSimpleIdentifier(identifier, true, false)) { + return identifier; + } + return quoteIdentifierAlways(identifier); + } + + @Override + public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { + return quoteIdentifier(identifier); + } + + /** + * Unconditional quoting for DDL-generation call sites. + */ + @Override + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } + return "\"" + StringUtils.replace(identifier, "\"", "\"\"") + "\""; + } + + /** + * Escapes a value interpolated into a single-quoted SQL string literal by + * doubling every single quote. + */ + @Override + public String escapeString(String str) { + return str == null ? null : StringUtils.replace(str, "'", "''"); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2DBManagerSecurityTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2DBManagerSecurityTest.java new file mode 100644 index 0000000000..fa8cc9fe6e --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2DBManagerSecurityTest.java @@ -0,0 +1,129 @@ +package ai.chat2db.plugin.h2; + +import java.io.File; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import ai.chat2db.community.domain.api.config.DriverConfig; +import ai.chat2db.community.domain.api.model.async.AsyncContext; +import ai.chat2db.spi.model.datasource.ConnectInfo; +import ai.chat2db.spi.sql.Chat2DBContext; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class H2DBManagerSecurityTest { + + @Test + void exportSchemaEscapesSchemaName() throws Exception { + List captured = new ArrayList<>(); + Connection connection = captureConnection(captured); + AsyncContext asyncContext = new AsyncContext(null, null, tempFile(), false); + + new H2DBManager().exportDatabase(connection, "TEST", "EVIL\" SCHEMA", asyncContext); + + assertEquals(List.of("SCRIPT NODATA NOPASSWORDS NOSETTINGS DROP SCHEMA \"EVIL\"\" SCHEMA\";"), + captured); + } + + @Test + void exportSchemaAppliesNodataSentinelBeforeSubstitutingSchemaName() throws Exception { + List captured = new ArrayList<>(); + Connection connection = captureConnection(captured); + AsyncContext asyncContext = new AsyncContext(null, null, tempFile(), true); + + new H2DBManager().exportDatabase(connection, "TEST", "ANODATA", asyncContext); + + assertEquals(List.of("SCRIPT NOPASSWORDS NOSETTINGS DROP SCHEMA \"ANODATA\";"), captured); + } + + @Test + void connectDatabaseEscapesSetSchemaName() throws Exception { + ConnectInfo connectInfo = new ConnectInfo(); + connectInfo.setDriverConfig(new DriverConfig()); + connectInfo.setSchemaName("EVIL\" SCHEMA"); + Chat2DBContext.putContext(connectInfo); + try { + List captured = new ArrayList<>(); + Connection connection = proxy(Connection.class, (p, method, args) -> { + if ("prepareStatement".equals(method.getName())) { + captured.add((String) args[0]); + return proxy(PreparedStatement.class, (p2, method2, args2) -> { + throw new SQLException("stop after capture"); + }); + } + return defaultValue(method.getReturnType()); + }); + + new H2DBManager().connectDatabase(connection, "TEST"); + + assertEquals(List.of("SET SCHEMA \"EVIL\"\" SCHEMA\""), captured); + } finally { + Chat2DBContext.removeContext(); + } + } + + private static File tempFile() throws Exception { + File file = File.createTempFile("h2-export", ".sql"); + file.deleteOnExit(); + return file; + } + + private static Connection captureConnection(List captured) { + return proxy(Connection.class, (p, method, args) -> { + if ("prepareStatement".equals(method.getName())) { + captured.add((String) args[0]); + return proxy(PreparedStatement.class, (p2, method2, args2) -> { + if ("executeQuery".equals(method2.getName())) { + return proxy(ResultSet.class, (p3, method3, args3) -> { + if ("next".equals(method3.getName())) { + return false; + } + return defaultValue(method3.getReturnType()); + }); + } + return defaultValue(method2.getReturnType()); + }); + } + return defaultValue(method.getReturnType()); + }); + } + + private static T proxy(Class type, InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == short.class) { + return (short) 0; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0F; + } + return 0D; + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2IdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2IdentifierProcessorTest.java new file mode 100644 index 0000000000..c114d12bde --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2IdentifierProcessorTest.java @@ -0,0 +1,122 @@ +package ai.chat2db.plugin.h2; + +import java.sql.Types; + +import ai.chat2db.community.domain.api.model.metadata.Schema; +import ai.chat2db.plugin.h2.builder.H2SqlBuilder; +import ai.chat2db.plugin.h2.identifier.H2IdentifierProcessor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class H2IdentifierProcessorTest { + + @Test + void escapeSqlLiteralDoublesSingleQuotes() { + assertEquals("O''Brien", H2IdentifierProcessor.INSTANCE.escapeString("O'Brien")); + assertNull(H2IdentifierProcessor.INSTANCE.escapeString(null)); + assertEquals("plain", H2IdentifierProcessor.INSTANCE.escapeString("plain")); + } + + @Test + void identifierQuotingPreservesRawValuesAndRoundTrips() { + assertEquals("\"A\"\"B\"", H2IdentifierProcessor.INSTANCE.quoteIdentifier("A\"B")); + assertEquals("\"\"\"ALREADY\"\"\"", + H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways("\"ALREADY\"")); + assertEquals("A\"B", H2IdentifierProcessor.INSTANCE.removeIdentifierQuote( + H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways("A\"B"))); + assertEquals("\"ALREADY\"", H2IdentifierProcessor.INSTANCE.removeIdentifierQuote( + H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways("\"ALREADY\""))); + } + + @Test + void getMetaDataNameNeutralizesEmbeddedQuotes() { + H2Meta meta = new H2Meta(); + String result = meta.getMetaDataName("PUBLIC", "A\".\"B"); + assertEquals("\"PUBLIC\".\"A\"\".\"\"B\"", result); + assertFalse(result.contains("A\".\"B\"."), "injection payload must not break out of the quoted identifier"); + } + + @Test + void buildCreateSchemaEscapesNameAndComment() { + Schema schema = new Schema(); + schema.setName("EVIL\" SCHEMA"); + schema.setComment("x'); DROP TABLE USERS; --"); + String sql = new H2SqlBuilder().buildCreateSchema(schema); + assertEquals("CREATE SCHEMA \"EVIL\"\" SCHEMA\";\n" + + "COMMENT ON SCHEMA \"EVIL\"\" SCHEMA\" IS 'x''); DROP TABLE USERS; --';", sql); + } + + @Test + void dropTableQuotesAndEscapesTableName() { + String sql = new H2DBManager().dropTable(null, "TEST", "PUBLIC", "T\"; DROP TABLE U; --"); + assertEquals("DROP TABLE \"T\"\"; DROP TABLE U; --\"", sql); + } + + @Test + void identifierProcessorKeepsConditionalAndAlwaysQuoteTracksSeparate() { + assertNull(H2IdentifierProcessor.INSTANCE.quoteIdentifier(null)); + assertNull(H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); + assertEquals("", H2IdentifierProcessor.INSTANCE.quoteIdentifier("")); + assertEquals("\"\"\"\"", H2IdentifierProcessor.INSTANCE.quoteIdentifier("\"")); + assertEquals("PLAIN", H2IdentifierProcessor.INSTANCE.quoteIdentifier("PLAIN")); + assertEquals("\"plain\"", H2IdentifierProcessor.INSTANCE.quoteIdentifier("plain")); + assertEquals("\"SELECT\"", H2IdentifierProcessor.INSTANCE.quoteIdentifier("SELECT")); + assertEquals("\"plain\"", H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways("plain")); + assertEquals("\"\"", H2IdentifierProcessor.INSTANCE.quoteIdentifierAlways("")); + assertEquals("PLAIN", H2IdentifierProcessor.INSTANCE.quoteIdentifierIgnoreCase("PLAIN")); + } + + @Test + void requireSafeTypeNameAcceptsRealH2TypesAndRejectsInjection() { + assertEquals("INTEGER", H2SqlGuards.requireSafeTypeName("INTEGER")); + assertEquals("CHARACTER VARYING", H2SqlGuards.requireSafeTypeName("CHARACTER VARYING")); + assertEquals("DOUBLE PRECISION", H2SqlGuards.requireSafeTypeName("DOUBLE PRECISION")); + assertThrows(IllegalArgumentException.class, + () -> H2SqlGuards.requireSafeTypeName("INT; DROP TABLE USERS; --")); + assertThrows(IllegalArgumentException.class, + () -> H2SqlGuards.requireSafeTypeName("INT')")); + assertThrows(IllegalArgumentException.class, + () -> H2SqlGuards.requireSafeTypeName("INTEGER) NOT NULL")); + assertNull(H2SqlGuards.requireSafeTypeName(null)); + } + + @Test + void renderMetadataTypeUsesJdbcPrecisionSemantics() { + assertEquals("BIGINT", H2SqlGuards.renderMetadataType("BIGINT", Types.BIGINT, 64, 0)); + assertEquals("CHARACTER VARYING(64)", + H2SqlGuards.renderMetadataType("CHARACTER VARYING", Types.VARCHAR, 64, 0)); + assertEquals("NUMERIC(12,3)", + H2SqlGuards.renderMetadataType("NUMERIC", Types.NUMERIC, 12, 3)); + assertEquals("TIMESTAMP(6)", + H2SqlGuards.renderMetadataType("TIMESTAMP", Types.TIMESTAMP, 26, 6)); + } + + @Test + void escapeColumnDefaultKeepsWellFormedLiteralsAndExpressions() { + assertEquals("'O''Brien'", H2SqlGuards.escapeColumnDefault("'O''Brien'")); + assertEquals("CURRENT_TIMESTAMP", H2SqlGuards.escapeColumnDefault("CURRENT_TIMESTAMP")); + assertEquals("42", H2SqlGuards.escapeColumnDefault("42")); + assertEquals("-1", H2SqlGuards.escapeColumnDefault("-1")); + assertEquals("NEXT VALUE FOR SEQ1", H2SqlGuards.escapeColumnDefault("NEXT VALUE FOR SEQ1")); + assertEquals("NEXT VALUE FOR \"PUBLIC\".\"SEQ1\"", + H2SqlGuards.escapeColumnDefault("NEXT VALUE FOR \"PUBLIC\".\"SEQ1\"")); + assertEquals("DATE '2026-07-29'", H2SqlGuards.escapeColumnDefault("DATE '2026-07-29'")); + assertEquals("RANDOM_UUID()", H2SqlGuards.escapeColumnDefault("RANDOM_UUID()")); + assertEquals("", H2SqlGuards.escapeColumnDefault(null)); + } + + @Test + void escapeColumnDefaultRejectsStructuralBreakout() { + assertThrows(IllegalArgumentException.class, + () -> H2SqlGuards.escapeColumnDefault("'x'); DROP TABLE USERS; --'")); + assertThrows(IllegalArgumentException.class, + () -> H2SqlGuards.escapeColumnDefault("0; DROP TABLE USERS; --")); + assertThrows(IllegalArgumentException.class, + () -> H2SqlGuards.escapeColumnDefault("0, INJECTED INTEGER")); + assertEquals("'||'", H2SqlGuards.escapeColumnDefault("'||'")); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2MetaResultSetLifecycleTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2MetaResultSetLifecycleTest.java index c0601b7061..6e549d75ab 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2MetaResultSetLifecycleTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2MetaResultSetLifecycleTest.java @@ -6,6 +6,7 @@ import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.ResultSetMetaData; +import java.sql.Types; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -27,6 +28,7 @@ void closesColumnsBeforeOpeningIndexesAndClosesIndexes() throws Exception { ResultSet columns = resultSet(List.of(Map.of( "COLUMN_NAME", "ID", "TYPE_NAME", "INTEGER", + "DATA_TYPE", Types.INTEGER, "COLUMN_SIZE", 32, "NULLABLE", ResultSetMetaData.columnNoNulls )), "columns", columnsClosed, lifecycle); @@ -54,8 +56,8 @@ void closesColumnsBeforeOpeningIndexesAndClosesIndexes() throws Exception { String ddl = new H2Meta().tableDDL(connection, "TEST", "PUBLIC", "USERS"); - assertEquals("CREATE TABLE USERS (\nID INTEGER(32) NOT NULL\n);\n" - + "CREATE INDEX IDX_USERS_ID ON USERS (ID);", ddl); + assertEquals("CREATE TABLE \"USERS\" (\n\"ID\" INTEGER NOT NULL\n);\n" + + "CREATE INDEX \"IDX_USERS_ID\" ON \"USERS\" (\"ID\");", ddl); assertTrue(columnsClosed.get()); assertTrue(indexesClosed.get()); assertEquals(List.of("columns.close", "metadata.getIndexInfo", "indexes.close"), lifecycle); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2MetaSecurityTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2MetaSecurityTest.java new file mode 100644 index 0000000000..e6867aba36 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2MetaSecurityTest.java @@ -0,0 +1,203 @@ +package ai.chat2db.plugin.h2; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.sql.Types; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class H2MetaSecurityTest { + + @Test + void tableDdlFromRealH2MetadataExecutesAndPreservesDefaults() throws Exception { + String suffix = Long.toUnsignedString(System.nanoTime()); + try (Connection source = DriverManager.getConnection("jdbc:h2:mem:h2_meta_source_" + suffix); + Connection target = DriverManager.getConnection("jdbc:h2:mem:h2_meta_target_" + suffix); + Statement sourceStatement = source.createStatement(); + Statement targetStatement = target.createStatement()) { + sourceStatement.execute("CREATE SEQUENCE \"S\"\"EQ\""); + sourceStatement.execute("CREATE TABLE \"T\"\"SOURCE\" (" + + "\"ID\" BIGINT DEFAULT NEXT VALUE FOR \"S\"\"EQ\", " + + "\"CREATED_AT\" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " + + "\"LABEL\" CHARACTER VARYING(64) DEFAULT 'O''Brien')"); + + targetStatement.execute("CREATE SEQUENCE \"S\"\"EQ\""); + String ddl = new H2Meta().tableDDL(source, source.getCatalog(), "PUBLIC", "T\"SOURCE"); + targetStatement.execute(ddl); + targetStatement.executeUpdate("INSERT INTO \"T\"\"SOURCE\" DEFAULT VALUES"); + + try (ResultSet row = targetStatement.executeQuery( + "SELECT \"ID\", \"CREATED_AT\", \"LABEL\" FROM \"T\"\"SOURCE\"")) { + row.next(); + assertEquals(1L, row.getLong(1)); + assertNotNull(row.getTimestamp(2)); + assertEquals("O'Brien", row.getString(3)); + } + } + } + + @Test + void tableDDLEscapesColumnNameAndRemarksAttacks() { + Map column = new HashMap<>(); + column.put("COLUMN_NAME", "C\"; DROP TABLE U; --"); + column.put("TYPE_NAME", "INTEGER"); + column.put("DATA_TYPE", Types.INTEGER); + column.put("COLUMN_SIZE", 32); + column.put("NULLABLE", ResultSetMetaData.columnNoNulls); + column.put("REMARKS", "x'); DROP TABLE U; --"); + + String ddl = new H2Meta().tableDDL(connection(List.of(column), List.of()), "TEST", "PUBLIC", "USERS"); + + assertEquals("CREATE TABLE \"USERS\" (\n" + + "\"C\"\"; DROP TABLE U; --\" INTEGER NOT NULL COMMENT 'x''); DROP TABLE U; --'\n" + + ");\n", ddl); + } + + @Test + void tableDDLRejectsHostileColumnDefaults() { + Map wrappedAttack = baseColumn(); + wrappedAttack.put("COLUMN_DEF", "'x'); DROP TABLE U; --'"); + String ddl = new H2Meta().tableDDL(connection(List.of(wrappedAttack), List.of()), "TEST", "PUBLIC", "USERS"); + assertEquals("", ddl); + + Map bareAttack = baseColumn(); + bareAttack.put("COLUMN_DEF", "0, INJECTED INTEGER"); + ddl = new H2Meta().tableDDL(connection(List.of(bareAttack), List.of()), "TEST", "PUBLIC", "USERS"); + assertEquals("", ddl); + } + + @Test + void tableDDLPreservesBenignColumnDefaults() { + Map literal = baseColumn(); + literal.put("COLUMN_DEF", "'O''Brien'"); + String ddl = new H2Meta().tableDDL(connection(List.of(literal), List.of()), "TEST", "PUBLIC", "USERS"); + assertEquals("CREATE TABLE \"USERS\" (\n" + + "\"ID\" INTEGER NOT NULL DEFAULT 'O''Brien'\n" + + ");\n", ddl); + + Map expression = baseColumn(); + expression.put("COLUMN_DEF", "CURRENT_TIMESTAMP"); + ddl = new H2Meta().tableDDL(connection(List.of(expression), List.of()), "TEST", "PUBLIC", "USERS"); + assertEquals("CREATE TABLE \"USERS\" (\n" + + "\"ID\" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP\n" + + ");\n", ddl); + } + + @Test + void tableDDLFailsClosedOnHostileTypeName() { + Map column = baseColumn(); + column.put("TYPE_NAME", "INT; DROP TABLE U; --"); + + String ddl = new H2Meta().tableDDL(connection(List.of(column), List.of()), "TEST", "PUBLIC", "USERS"); + + assertEquals("", ddl); + } + + @Test + void tableDDLEscapesIndexNamesAndColumns() { + Map index = new HashMap<>(); + index.put("INDEX_NAME", "I\"; DROP TABLE U; --"); + index.put("COLUMN_NAME", "C\"; X"); + + String ddl = new H2Meta().tableDDL(connection(List.of(baseColumn()), List.of(index)), "TEST", "PUBLIC", + "USERS"); + + assertEquals("CREATE TABLE \"USERS\" (\n" + + "\"ID\" INTEGER NOT NULL\n" + + ");\n" + + "CREATE INDEX \"I\"\"; DROP TABLE U; --\" ON \"USERS\" (\"C\"\"; X\");", ddl); + } + + private static Map baseColumn() { + Map column = new HashMap<>(); + column.put("COLUMN_NAME", "ID"); + column.put("TYPE_NAME", "INTEGER"); + column.put("DATA_TYPE", Types.INTEGER); + column.put("COLUMN_SIZE", 32); + column.put("NULLABLE", ResultSetMetaData.columnNoNulls); + return column; + } + + private static Connection connection(List> columns, List> indexes) { + ResultSet columnsRs = resultSet(columns); + ResultSet indexesRs = resultSet(indexes); + DatabaseMetaData metaData = proxy(DatabaseMetaData.class, (p, method, args) -> { + if ("getColumns".equals(method.getName())) { + return columnsRs; + } + if ("getIndexInfo".equals(method.getName())) { + return indexesRs; + } + return defaultValue(method.getReturnType()); + }); + return proxy(Connection.class, (p, method, args) -> { + if ("getMetaData".equals(method.getName())) { + return metaData; + } + return defaultValue(method.getReturnType()); + }); + } + + private static ResultSet resultSet(List> rows) { + AtomicInteger row = new AtomicInteger(-1); + return proxy(ResultSet.class, (p, method, args) -> { + switch (method.getName()) { + case "next": + return row.incrementAndGet() < rows.size(); + case "getString": + Object stringValue = rows.get(row.get()).get((String) args[0]); + return stringValue == null ? null : stringValue.toString(); + case "getInt": + Object intValue = rows.get(row.get()).get((String) args[0]); + return intValue == null ? 0 : ((Number) intValue).intValue(); + default: + return defaultValue(method.getReturnType()); + } + }); + } + + private static T proxy(Class type, InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, handler)); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == short.class) { + return (short) 0; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0F; + } + return 0D; + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2SqlBuilderSecurityTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2SqlBuilderSecurityTest.java new file mode 100644 index 0000000000..cdbfa067c6 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-h2/src/test/java/ai/chat2db/plugin/h2/H2SqlBuilderSecurityTest.java @@ -0,0 +1,260 @@ +package ai.chat2db.plugin.h2; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.List; + +import ai.chat2db.community.domain.api.config.TableBuilderConfig; +import ai.chat2db.community.domain.api.model.metadata.Database; +import ai.chat2db.community.domain.api.model.metadata.Table; +import ai.chat2db.community.domain.api.model.metadata.TableColumn; +import ai.chat2db.community.domain.api.model.metadata.TableIndex; +import ai.chat2db.community.domain.api.model.metadata.TableIndexColumn; +import ai.chat2db.plugin.h2.builder.H2SqlBuilder; +import ai.chat2db.spi.model.request.DropTableRequest; +import ai.chat2db.spi.model.request.SingleInsertSqlRequest; +import ai.chat2db.spi.model.request.TruncateTableRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class H2SqlBuilderSecurityTest { + + private static final String EVIL = "T\"; DROP TABLE U; --"; + private static final String EVIL_QUOTED = "\"T\"\"; DROP TABLE U; --\""; + private static final String COMMENT_ATTACK = "x'); DROP TABLE U; --"; + + private final H2SqlBuilder builder = new H2SqlBuilder(); + + @Test + void buildCreateTableEscapesNamesCommentsAndIndexes() { + Table table = new Table(); + table.setName(EVIL); + TableColumn column = new TableColumn(); + column.setName("C\"; X"); + column.setTableName(EVIL); + column.setColumnType("INTEGER"); + column.setNullable(0); + column.setComment(COMMENT_ATTACK); + table.setColumnList(List.of(column)); + TableIndex index = new TableIndex(); + index.setName("I\"; X"); + index.setTableName(EVIL); + index.setType("Normal"); + TableIndexColumn indexColumn = new TableIndexColumn(); + indexColumn.setColumnName("C\"; X"); + index.setColumnList(List.of(indexColumn)); + table.setIndexList(List.of(index)); + + String sql = builder.buildCreateTable(table, TableBuilderConfig.defaultConfig()); + + assertEquals("CREATE TABLE " + EVIL_QUOTED + " (\n" + + "\t \"C\"\"; X\" INTEGER NOT NULL\n" + + ");\n" + + "COMMENT ON COLUMN " + EVIL_QUOTED + ".\"C\"\"; X\" IS 'x''); DROP TABLE U; --';\n" + + "CREATE INDEX \"I\"\"; X\" ON " + EVIL_QUOTED + " (\"C\"\"; X\");\n", + sql); + assertFalse(sql.contains("ON T\";"), "index table name must stay inside the quoted identifier"); + } + + @Test + void buildAlterTableEscapesRenameCommentsColumnsAndIndexes() { + Table oldTable = new Table(); + oldTable.setName("O\"; X"); + Table newTable = new Table(); + newTable.setName("N\"; X"); + newTable.setComment(COMMENT_ATTACK); + + TableColumn add = column("A\"; X", "ADD"); + TableColumn delete = column("D\"; X", "DELETE"); + TableColumn modify = column("M\"; X", "MODIFY"); + TableColumn commentOnly = column("K\"; X", "COMMENTED"); + commentOnly.setComment(COMMENT_ATTACK); + newTable.setColumnList(List.of(add, delete, modify, commentOnly)); + + TableIndex dropIndex = index("I\"; X", "DELETE"); + TableIndex addIndex = index("J\"; X", "ADD"); + newTable.setIndexList(List.of(dropIndex, addIndex)); + + String sql = builder.buildAlterTable(oldTable, newTable); + + assertEquals("ALTER TABLE \"O\"\"; X\" RENAME TO \"N\"\"; X\";\n" + + "COMMENT ON TABLE \"N\"\"; X\" IS 'x''); DROP TABLE U; --';\n" + + "ALTER TABLE " + EVIL_QUOTED + " ADD COLUMN \"A\"\"; X\" INTEGER;\n" + + "ALTER TABLE " + EVIL_QUOTED + " DROP COLUMN \"D\"\"; X\";\n" + + "ALTER TABLE " + EVIL_QUOTED + " MODIFY COLUMN \"M\"\"; X\" INTEGER;\n" + + "COMMENT ON COLUMN " + EVIL_QUOTED + ".\"K\"\"; X\" IS 'x''); DROP TABLE U; --';\n\n" + + "DROP INDEX \"I\"\"; X\";\n" + + "CREATE INDEX \"J\"\"; X\" ON " + EVIL_QUOTED + " (\"C\"\"; X\");\n", + sql); + } + + private static TableColumn column(String name, String editStatus) { + TableColumn column = new TableColumn(); + column.setName(name); + column.setTableName(EVIL); + column.setColumnType("INTEGER"); + column.setEditStatus(editStatus); + return column; + } + + private static TableIndex index(String name, String editStatus) { + TableIndex index = new TableIndex(); + index.setName(name); + index.setTableName(EVIL); + index.setType("Normal"); + index.setEditStatus(editStatus); + TableIndexColumn indexColumn = new TableIndexColumn(); + indexColumn.setColumnName("C\"; X"); + index.setColumnList(List.of(indexColumn)); + return index; + } + + @Test + void buildTemplateEscapesTableAndColumnNames() { + Table table = new Table(); + table.setName(EVIL); + TableColumn column = new TableColumn(); + column.setName("C\"; X"); + table.setColumnList(List.of(column)); + + assertEquals("INSERT INTO " + EVIL_QUOTED + " (\"C\"\"; X\") VALUES ( )", + builder.buildTemplate(table, "INSERT")); + assertEquals("UPDATE " + EVIL_QUOTED + " set \"C\"\"; X\" = where ", + builder.buildTemplate(table, "UPDATE")); + assertEquals("DELETE FROM " + EVIL_QUOTED + " where ", + builder.buildTemplate(table, "DELETE")); + assertEquals("SELECT \"C\"\"; X\" FROM where" + EVIL_QUOTED, + builder.buildTemplate(table, "SELECT")); + } + + @Test + void buildUpdateEscapesTableAndColumnKeys() { + UpdateSqlRequest request = new UpdateSqlRequest(); + request.setDatabaseName("D\"; X"); + request.setSchemaName("S\"; X"); + request.setTableName(EVIL); + LinkedHashMap row = new LinkedHashMap<>(); + row.put("K\"; X", "1"); + request.setRow(row); + LinkedHashMap primaryKey = new LinkedHashMap<>(); + primaryKey.put("P\"; X", "'v'"); + request.setPrimaryKeyMap(primaryKey); + + assertEquals("UPDATE \"D\"\"; X\".\"S\"\"; X\"." + EVIL_QUOTED + + " SET \"K\"\"; X\" = 1 WHERE \"P\"\"; X\" = 'v'", + builder.buildUpdate(request)); + } + + @Test + void databaseAndSchemaDdlEscapesNames() { + Database database = new Database(); + database.setName("DB\"; X"); + assertEquals("CREATE DATABASE \"DB\"\"; X\"", builder.buildCreateDatabase(database)); + assertEquals("DROP DATABASE \"DB\"\"; X\"", builder.buildDropDatabase("DB\"; X")); + assertEquals("USE \"DB\"\"; X\"", builder.buildUseDatabase("DB\"; X")); + assertEquals("DROP SCHEMA \"S\"\"; X\"", builder.buildDropSchema("S\"; X")); + } + + @Test + void qualifiedIdentifierPathsEscapeEveryPart() { + assertEquals("SELECT COUNT(1) FROM \"D\"\"; X\".\"S\"\"; X\"." + EVIL_QUOTED, + builder.buildSelectCount("D\"; X", "S\"; X", EVIL)); + DropTableRequest dropTable = new DropTableRequest(); + dropTable.setDatabaseName("D\"; X"); + dropTable.setSchemaName("S\"; X"); + dropTable.setTableName(EVIL); + assertEquals("DROP TABLE \"D\"\"; X\".\"S\"\"; X\"." + EVIL_QUOTED, + builder.buildDropTable(dropTable)); + TruncateTableRequest truncate = new TruncateTableRequest(); + truncate.setDatabaseName("D\"; X"); + truncate.setSchemaName("S\"; X"); + truncate.setTableName(EVIL); + assertEquals("TRUNCATE TABLE \"D\"\"; X\".\"S\"\"; X\"." + EVIL_QUOTED, + builder.buildTruncateTable(truncate)); + assertEquals("SELECT * FROM \"D\"\"; X\".\"S\"\"; X\"." + EVIL_QUOTED, + builder.buildSelectTable("D\"; X", "S\"; X", EVIL)); + } + + @Test + void buildCreateTableRejectsHostileColumnType() { + Table table = new Table(); + table.setName("T"); + TableColumn column = new TableColumn(); + column.setName("C"); + column.setColumnType("INTEGER); DROP TABLE U; --"); + column.setNullable(0); + table.setColumnList(List.of(column)); + + assertThrows(IllegalArgumentException.class, + () -> builder.buildCreateTable(table, TableBuilderConfig.defaultConfig())); + } + + @Test + void buildAlterTableRejectsHostileColumnType() { + Table oldTable = new Table(); + oldTable.setName("T"); + Table newTable = new Table(); + newTable.setName("T"); + newTable.setColumnList(List.of(column("C", "ADD"))); + newTable.getColumnList().get(0).setColumnType("INTEGER; DROP TABLE U; --"); + newTable.setIndexList(List.of()); + + assertThrows(IllegalArgumentException.class, () -> builder.buildAlterTable(oldTable, newTable)); + } + + @Test + void buildInsertEscapesTableAndColumnNames() { + SingleInsertSqlRequest request = new SingleInsertSqlRequest(); + request.setDatabaseName("D\"; X"); + request.setSchemaName("S\"; X"); + request.setTableName(EVIL); + request.setColumnList(List.of("C\"; X")); + request.setValueList(List.of("'v'")); + + assertEquals("INSERT INTO \"D\"\"; X\".\"S\"\"; X\"." + EVIL_QUOTED + + " (\"C\"\"; X\") VALUES ('v')", + builder.buildInsert(request)); + } + + @Test + void generatedDdlAndLegalDefaultExpressionsExecuteOnH2() throws Exception { + Table table = new Table(); + table.setName("SAFE_TABLE"); + TableColumn column = new TableColumn(); + column.setName("VALUE"); + column.setColumnType("INTEGER"); + column.setNullable(0); + table.setColumnList(List.of(column)); + + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:escaping_" + System.nanoTime()); + Statement statement = connection.createStatement()) { + statement.execute(builder.buildCreateTable(table, TableBuilderConfig.defaultConfig())); + + String timestampDefault = H2SqlGuards.escapeColumnDefault("CURRENT_TIMESTAMP"); + String stringDefault = H2SqlGuards.escapeColumnDefault("'O''Brien'"); + statement.execute("CREATE TABLE \"DEFAULTS\" (\"CREATED_AT\" TIMESTAMP DEFAULT " + + timestampDefault + ", \"LABEL\" VARCHAR(64) DEFAULT " + stringDefault + ")"); + statement.executeUpdate("INSERT INTO \"DEFAULTS\" DEFAULT VALUES"); + + try (ResultSet resultSet = statement.executeQuery("SELECT \"CREATED_AT\", \"LABEL\" FROM \"DEFAULTS\"")) { + assertTrue(resultSet.next()); + assertTrue(resultSet.getTimestamp(1) != null); + assertEquals("O'Brien", resultSet.getString(2)); + } + + assertThrows(IllegalArgumentException.class, + () -> H2SqlGuards.escapeColumnDefault("0, INJECTED INTEGER")); + try (ResultSet injected = connection.getMetaData().getColumns(null, null, "SAFE_TABLE", "INJECTED")) { + assertFalse(injected.next()); + } + } + } +}