Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package uk.gov.hmcts.ccd.data.casedetails.search;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.stereotype.Component;
import uk.gov.hmcts.ccd.data.casedetails.search.MetaData.CaseField;
import uk.gov.hmcts.ccd.endpoint.exceptions.BadRequestException;
Expand All @@ -15,7 +16,7 @@ public class SortOrderQueryBuilder {
private static final String CREATED_DATE = "created_date";
private static final String SPACE = " ";
private static final String COMMA = ",";
private static final String CASE_FIELD_ID_PATTERN = "^['a-zA-Z0-9\\[\\]\\#%\\&()\\.?_\\£\\s\\xA0-]+$";
private static final String CASE_FIELD_ID_PATTERN = "^[a-zA-Z0-9_.\\[\\]]+$";


public String buildSortOrderClause(MetaData metaData) {
Expand All @@ -37,7 +38,7 @@ public String buildSortOrderClause(MetaData metaData) {
});
// always sort with creation_date as a last order so that it supports cases where
// no values at all for the configured fields and also default fallback.
return sb.append(CREATED_DATE + SPACE + fromOptionalString(metaData.getSortDirection())).toString();
return sb.append(CREATED_DATE + SPACE).append(fromOptionalString(metaData.getSortDirection())).toString();
}

private String getMataFieldName(String fieldName) {
Expand All @@ -47,7 +48,7 @@ private String getMataFieldName(String fieldName) {
}

private static String convertFieldNameToJsonbSqlFormat(final String in) {
return DATA_FIELD + " #>> '{" + StringUtils.replace(in, ".", ",") + "}'";
return DATA_FIELD + " #>> '{" + Strings.CS.replace(in, ".", ",") + "}'";
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ public abstract class GrantTypeSqlQueryBuilder extends GrantTypeQueryBuilder {

public static final String CASE_ACCESS_CATEGORY = "data" + " #>> '{CaseAccessCategory}'";

public static final String JURISDICTION_PARAM = "jurisdiction_%s_%s";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These ABAC parameter names are predictable and share the same namespace as user search criteria params. A user field like case.region_1_basic is allowed, becomes
region_1_basic, and is bound after ABAC params in SearchQueryFactoryOperation, overwriting this predicate value. Please use a reserved/collision-proof prefix for all ABAC
params and add a regression test.

Supporting refs if needed:
SearchQueryFactoryOperation.java:89-90
Criterion.java:26-27
FieldMapSanitizeOperation.java:19


public static final String REGION_PARAM = "region_%s_%s";

public static final String LOCATION_PARAM = "location_%s_%s";

public static final String CASE_ACCESS_GROUP_ID_PARAM = "case_access_group_id_%s_%s";

public static final String CASE_ACCESS_CATEGORY_PARAM = "case_access_category_%s_%s";

protected GrantTypeSqlQueryBuilder(AccessControlService accessControlService,
CaseDataAccessControl caseDataAccessControl,
ApplicationParams applicationParams) {
Expand All @@ -66,25 +76,26 @@ public String createQuery(List<RoleAssignment> roleAssignments,
.map(groupedSearchRoleAssignments -> {
final int count = index.incrementAndGet();
String innerQuery = EMPTY;
SearchRoleAssignment representative = groupedSearchRoleAssignments.get(0);
SearchRoleAssignment representative = groupedSearchRoleAssignments.getFirst();
Set<String> readableCaseStates = getReadableCaseStates(representative, caseStates, caseType);
if (readableCaseStates.isEmpty()) {
return innerQuery;
}

innerQuery = addOptionalInQueryForCaseGroupId(representative.getCaseAccessGroupId(),
innerQuery);
innerQuery, params, paramName, count);
innerQuery = addEqualsQueryForOptionalAttribute(representative.getJurisdiction(),
innerQuery, JURISDICTION);
innerQuery, JURISDICTION, params, String.format(JURISDICTION_PARAM, count, paramName));
innerQuery = addEqualsQueryForOptionalAttribute(representative.getRegion(),
innerQuery, REGION);
innerQuery, REGION, params, String.format(REGION_PARAM, count, paramName));
innerQuery = addEqualsQueryForOptionalAttribute(representative.getLocation(),
innerQuery, LOCATION);
innerQuery, LOCATION, params, String.format(LOCATION_PARAM, count, paramName));
innerQuery = addInQueryForReference(params, paramName, innerQuery,
groupedSearchRoleAssignments, count);
innerQuery = addInQueryForState(params, paramName, readableCaseStates, caseStates, innerQuery, count);
innerQuery = addInQueryForClassification(params, paramName, innerQuery, representative, count);
innerQuery = addInQueryForCaseAccessCategory(caseType, representative, innerQuery);
innerQuery = addInQueryForCaseAccessCategory(caseType, representative, innerQuery,
params, paramName, count);

return StringUtils.isNotBlank(innerQuery) ? String.format(QUERY_WRAPPER, innerQuery) : innerQuery;
}).filter(strQuery -> !StringUtils.isEmpty(strQuery)).collect(Collectors.joining(OR));
Expand All @@ -105,15 +116,22 @@ private String addInQueryForState(Map<String, Object> params,
return parentQuery;
}

private String addOptionalInQueryForCaseGroupId(String caseAccessGroupId, String parentQuery) {
private String addOptionalInQueryForCaseGroupId(String caseAccessGroupId,
String parentQuery,
Map<String, Object> params,
String paramName,
int count) {
if (!getApplicationParams().getCaseGroupAccessFilteringEnabled()) {
return parentQuery;
}
if (StringUtils.isBlank(caseAccessGroupId)) {
return parentQuery;
}
String caseAccessGroupIdParam = String.format(CASE_ACCESS_GROUP_ID_PARAM, count, paramName);
params.put(caseAccessGroupIdParam, caseAccessGroupId);
return parentQuery + getOperator(parentQuery, AND)
+ "data->'CaseAccessGroups' @> '[{\"value\":{\"caseAccessGroupId\": \"" + caseAccessGroupId + "\"}}]'";
+ "data->'CaseAccessGroups' @> jsonb_build_array(jsonb_build_object('value', "
+ "jsonb_build_object('caseAccessGroupId', CAST(:" + caseAccessGroupIdParam + " AS text))))";
}

private String addInQueryForReference(Map<String, Object> params,
Expand Down Expand Up @@ -149,10 +167,13 @@ private String addInQueryForClassification(Map<String, Object> params,

private String addEqualsQueryForOptionalAttribute(String attribute,
String parentQuery,
String matchName) {
String matchName,
Map<String, Object> params,
String attributeParam) {
if (StringUtils.isNotBlank(attribute)) {
params.put(attributeParam, attribute);
parentQuery = parentQuery + getOperator(parentQuery, AND)
+ String.format("%s='%s'", matchName, attribute);
+ String.format("%s = :%s", matchName, attributeParam);
}
return parentQuery;
}
Expand All @@ -166,20 +187,40 @@ public String getOperator(String query, String operator) {

private String addInQueryForCaseAccessCategory(CaseTypeDefinition caseType,
SearchRoleAssignment representative,
String parentQuery) {
String caseAccessCategoriesQuery = getCaseAccessCategoriesQuery(representative.getRoleAssignment(), caseType);
String parentQuery,
Map<String, Object> params,
String paramName,
int count) {
String caseAccessCategoriesQuery = getCaseAccessCategoriesQuery(representative.getRoleAssignment(), caseType,
params, paramName, count);
if (StringUtils.isNotBlank(caseAccessCategoriesQuery)) {
parentQuery = parentQuery + getOperator(parentQuery, AND)
+ String.format(QUERY_WRAPPER, caseAccessCategoriesQuery);
}
return parentQuery;
}

private String getCaseAccessCategoriesQuery(RoleAssignment roleAssignment, CaseTypeDefinition caseType) {
private String getCaseAccessCategoriesQuery(RoleAssignment roleAssignment,
CaseTypeDefinition caseType,
Map<String, Object> params,
String paramName,
int count) {
List<String> caseAccessCategories = getCaseAccessCategories(roleAssignment, caseType);

AtomicInteger categoryIndex = new AtomicInteger();
return caseAccessCategories.stream()
.map(cac -> CASE_ACCESS_CATEGORY + " LIKE '" + cac + "%'")
.map(cac -> {
String cacParam = String.format(CASE_ACCESS_CATEGORY_PARAM,
count + "_" + categoryIndex.incrementAndGet(), paramName);
params.put(cacParam, escapeLikeWildcards(cac) + "%");
return CASE_ACCESS_CATEGORY + " LIKE :" + cacParam + " ESCAPE '\\'";
})
.collect(Collectors.joining(" OR "));
}

private static String escapeLikeWildcards(String value) {
return value.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package uk.gov.hmcts.ccd.data.casedetails.search;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import uk.gov.hmcts.ccd.WireMockBaseTest;
import uk.gov.hmcts.ccd.endpoint.exceptions.BadRequestException;

import javax.inject.Inject;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
* Executes the clause produced by {@link SortOrderQueryBuilder} against the Testcontainers
* Postgres instance already used by the integration tests.
* Scope of what this proves: a quote-bearing case field id is now rejected by
* CASE_FIELD_ID_PATTERN at build/validation time, so it never reaches the database. The
* well-formed control case still executes cleanly end to end, proving the happy path works.
*/
public class SortOrderQueryBuilderIT extends WireMockBaseTest {

private static final String CASE_TYPE_ID = "CaseTypeOne";
private static final String JURISDICTION_ID = "JurisdictionOne";

@Inject
private SortOrderQueryBuilder sortOrderQueryBuilder;

private JdbcTemplate template;

@BeforeEach
public void setUp() {
template = new JdbcTemplate(db);
}

private String sortClauseFor(String caseFieldId) {
MetaData metaData = new MetaData(CASE_TYPE_ID, JURISDICTION_ID);
metaData.addSortOrderField(SortOrderField.sortOrderWith()
.caseFieldId(caseFieldId)
.metadata(false)
.direction("ASC")
.build());
return sortOrderQueryBuilder.buildSortOrderClause(metaData);
}

@Test
@DisplayName("A well-formed field id produces a clause Postgres can execute")
void wellFormedFieldId_producesExecutableQuery() {
String sql = "SELECT reference FROM case_data ORDER BY " + sortClauseFor("PersonFirstName");

assertDoesNotThrow(() -> template.queryForList(sql));
}

@Test
@DisplayName("A quote-bearing field id is rejected at validation and never reaches Postgres")
void singleQuoteFieldId_rejectedAtValidation_neverReachesDatabase() {
BadRequestException exception =
assertThrows(BadRequestException.class, () -> sortClauseFor("foo'bar"));

assertThat(exception.getMessage(), containsString("Sort order field is invalid."));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package uk.gov.hmcts.ccd.data.casedetails.search;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import uk.gov.hmcts.ccd.endpoint.exceptions.BadRequestException;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
* These tests pin the behaviour of {@link SortOrderQueryBuilder}'s CASE_FIELD_ID_PATTERN, now a
* strict identifier allow-list: only letters, digits, underscore, dot and square brackets are
* permitted. Single quotes, whitespace and hyphens - which could otherwise reach a JSONB path
* literal that cannot be parameterised - are rejected with a BadRequestException.
* The rejection cases below guard the tightened allow-list: they must keep failing closed. Do
* not relax them to make a change pass.
*/
class SortOrderQueryBuilderTest {

private static final String CASE_TYPE_ID = "CaseTypeOne";
private static final String JURISDICTION_ID = "JurisdictionOne";

private SortOrderQueryBuilder sortOrderQueryBuilder;

@BeforeEach
void setUp() {
sortOrderQueryBuilder = new SortOrderQueryBuilder();
}

private String buildForNonMetadataField(String caseFieldId) {
MetaData metaData = new MetaData(CASE_TYPE_ID, JURISDICTION_ID);
metaData.addSortOrderField(SortOrderField.sortOrderWith()
.caseFieldId(caseFieldId)
.metadata(false)
.direction("ASC")
.build());
return sortOrderQueryBuilder.buildSortOrderClause(metaData);
}

private String buildForMetadataField(String caseFieldId) {
MetaData metaData = new MetaData(CASE_TYPE_ID, JURISDICTION_ID);
metaData.addSortOrderField(SortOrderField.sortOrderWith()
.caseFieldId(caseFieldId)
.metadata(true)
.direction("ASC")
.build());
return sortOrderQueryBuilder.buildSortOrderClause(metaData);
}

@Test
@DisplayName("A single quote in a case field id is rejected")
void pattern_rejectsSingleQuote() {
BadRequestException exception =
assertThrows(BadRequestException.class, () -> buildForNonMetadataField("foo'bar"));
assertThat(exception.getMessage(), containsString("Sort order field is invalid."));
}

@Test
@DisplayName("Whitespace in a case field id is rejected")
void pattern_rejectsSpace() {
BadRequestException exception =
assertThrows(BadRequestException.class, () -> buildForNonMetadataField("foo bar"));
assertThat(exception.getMessage(), containsString("Sort order field is invalid."));
}

@Test
@DisplayName("A hyphen in a case field id is rejected")
void pattern_rejectsHyphen() {
BadRequestException exception =
assertThrows(BadRequestException.class, () -> buildForNonMetadataField("foo-bar"));
assertThat(exception.getMessage(), containsString("Sort order field is invalid."));
}

@Test
@DisplayName("Current limit: a semicolon in a case field id is rejected")
void pattern_rejectsSemicolon() {
BadRequestException exception =
assertThrows(BadRequestException.class, () -> buildForNonMetadataField("foo;bar"));
assertThat(exception.getMessage(), containsString("Sort order field is invalid."));
}

@Test
@DisplayName("Current limit: an equals sign in a case field id is rejected")
void pattern_rejectsEquals() {
BadRequestException exception =
assertThrows(BadRequestException.class, () -> buildForNonMetadataField("foo=bar"));
assertThat(exception.getMessage(), containsString("Sort order field is invalid."));
}

@Test
@DisplayName("A single quote is rejected at validation before any JSONB path is produced")
void nonMetadataField_singleQuote_rejectedBeforeSqlIsProduced() {
BadRequestException exception =
assertThrows(BadRequestException.class, () -> buildForNonMetadataField("foo'bar"));
assertThat(exception.getMessage(), containsString("Sort order field is invalid."));
}

@Test
@DisplayName("A well-formed nested field id is converted to a JSONB path as expected")
void nonMetadataField_dottedPath_isConvertedToJsonbPath() {
assertThat(buildForNonMetadataField("Parent.Child"), containsString("data #>> '{Parent,Child}'"));
}

@Test
@DisplayName("Metadata fields route through the CaseField enum and are never concatenated")
void metadataField_routesThroughEnum_notConcatenated() {
assertAll(
() -> assertThrows(IllegalArgumentException.class, () -> buildForMetadataField("notAMetadataField")),
() -> assertThrows(IllegalArgumentException.class, () -> buildForMetadataField("[notAMetadataField]")),
() -> assertThat(buildForMetadataField("[STATE]"), containsString("state ASC")),
() -> assertThat(buildForMetadataField("[STATE]"), not(containsString("data #>>")))
);
}
}
Loading