diff --git a/force-app/main/default/classes/UTIL_Typeahead.cls b/force-app/main/default/classes/UTIL_Typeahead.cls index e98f893b59..4123b714a5 100644 --- a/force-app/main/default/classes/UTIL_Typeahead.cls +++ b/force-app/main/default/classes/UTIL_Typeahead.cls @@ -35,6 +35,36 @@ */ public with sharing class UTIL_Typeahead { + /******************************************************************************************************* + * @description Allow-list pattern for filterClause: one or more "field operator literal" comparisons + * joined by AND/OR. Quoted literals cannot contain a single quote, so a caller cannot break out of the + * literal to inject additional SOSL/SOQL syntax. + ********************************************************************************************************/ + private static final Pattern FILTER_CLAUSE_PATTERN = Pattern.compile( + '(?i)^\\s*[a-z_][a-z0-9_]*\\s*(=|!=|<>|<=|>=|<|>|like)\\s*(\'[^\']*\'|-?\\d+(\\.\\d+)?|true|false|null)' + + '(\\s+(and|or)\\s+[a-z_][a-z0-9_]*\\s*(=|!=|<>|<=|>=|<|>|like)\\s*(\'[^\']*\'|-?\\d+(\\.\\d+)?|true|false|null))*\\s*$' + ); + + private static final Pattern FILTER_CLAUSE_SPLIT = Pattern.compile('(?i)\\s+(?:and|or)\\s+'); + private static final Pattern FILTER_FIELD_TOKEN = Pattern.compile('(?i)^\\s*([a-z_][a-z0-9_]*)\\s*(?:=|!=|<>|<=|>=|<|>|like)'); + + public class InvalidFilterClauseException extends Exception {} + + /******************************************************************************************************* + * @description Confirms every field referenced in an already syntax-validated filterClause actually + * exists on sObjectName, using the same describe-driven validation UTIL_Where/UTIL_Describe use + * elsewhere for dynamic SOQL. Guards against field names that are syntactically valid but bogus. + ********************************************************************************************************/ + private static Boolean allFilterClauseFieldsAreValid(String filterClause, String sObjectName) { + for (String clause : FILTER_CLAUSE_SPLIT.split(filterClause)) { + Matcher fieldMatcher = FILTER_FIELD_TOKEN.matcher(clause); + if (!fieldMatcher.find() || !UTIL_Describe.isValidField(sObjectName, fieldMatcher.group(1))) { + return false; + } + } + return true; + } + /******************************************************************************************************* * @description Searches the database (using SOSL) for the records that meet the criteria. * @param queryString (SearchQuery) The word of phrase to search for. @@ -69,9 +99,14 @@ public with sharing class UTIL_Typeahead { String sLimit = (recordLimit == null || recordLimit == 0 || recordLimit >= 2000) ? '' : ( ' LIMIT ' + String.valueOf(recordLimit)); - // can't escape the filter clause - String sWhere = (filterClause == null || filterClause == '') ? '' : - ( ' WHERE ' + filterClause ); + String sWhere = ''; + if (filterClause != null && filterClause != '') { + if (!FILTER_CLAUSE_PATTERN.matcher(filterClause).matches() || + !allFilterClauseFieldsAreValid(filterClause, sObjectName)) { + throw new InvalidFilterClauseException('Invalid filterClause.'); + } + sWhere = ' WHERE ' + filterClause; + } list> results = Search.query( 'FIND \'' + sQuery + '*\'' + sInFields + diff --git a/force-app/main/default/classes/UTIL_Typeahead_TEST.cls b/force-app/main/default/classes/UTIL_Typeahead_TEST.cls index 6bdd39311f..e4a1ab4374 100644 --- a/force-app/main/default/classes/UTIL_Typeahead_TEST.cls +++ b/force-app/main/default/classes/UTIL_Typeahead_TEST.cls @@ -56,4 +56,45 @@ public with sharing class UTIL_Typeahead_TEST { system.assertEquals('Test Account', accts[0].get('Name')); system.assertEquals('Another Account', accts[1].get('Name')); } + + private static testMethod void testValidFilterClauseIsAccepted() { + Test.setFixedSearchResults(new list()); + + Test.startTest(); + list accts = UTIL_Typeahead.searchRecords( + 'test', 'Account', null, null, 'Name = \'Test Account\' AND BillingCity = \'Test City\'', 'Id', null + ); + Test.stopTest(); + + system.assertEquals(0, accts.size()); + } + + private static testMethod void testNonexistentFieldInFilterClauseIsRejected() { + Boolean threwException = false; + + try { + UTIL_Typeahead.searchRecords( + 'test', 'Account', null, null, 'NotARealField__c = \'x\'', 'Id', null + ); + } catch (UTIL_Typeahead.InvalidFilterClauseException e) { + threwException = true; + } + + system.assertEquals(true, threwException, 'A filterClause referencing a nonexistent field should be rejected.'); + } + + private static testMethod void testInjectedFilterClauseIsRejected() { + Boolean threwException = false; + + try { + UTIL_Typeahead.searchRecords( + 'test', 'Account', null, null, + 'Name = \'\' OR Name LIKE \'%\') RETURNING Contact(Id) --', 'Id', null + ); + } catch (UTIL_Typeahead.InvalidFilterClauseException e) { + threwException = true; + } + + system.assertEquals(true, threwException, 'An unsafe filterClause should be rejected.'); + } } \ No newline at end of file diff --git a/force-app/main/default/pages/STG_PanelHome.page b/force-app/main/default/pages/STG_PanelHome.page index 82b92c5125..02da705801 100644 --- a/force-app/main/default/pages/STG_PanelHome.page +++ b/force-app/main/default/pages/STG_PanelHome.page @@ -13,7 +13,7 @@ -
+
{!$Label.stgTools} diff --git a/force-app/main/default/pages/STG_PanelRelReciprocal.page b/force-app/main/default/pages/STG_PanelRelReciprocal.page index 1a6009f469..24b61eb59a 100644 --- a/force-app/main/default/pages/STG_PanelRelReciprocal.page +++ b/force-app/main/default/pages/STG_PanelRelReciprocal.page @@ -79,6 +79,7 @@
+
@@ -125,5 +126,6 @@
+
\ No newline at end of file diff --git a/force-app/main/default/pages/STG_SettingsManager.page b/force-app/main/default/pages/STG_SettingsManager.page index bae297de16..ac56ea94a1 100644 --- a/force-app/main/default/pages/STG_SettingsManager.page +++ b/force-app/main/default/pages/STG_SettingsManager.page @@ -44,6 +44,12 @@ .hide { display: none; } + + /* Contain horizontal overflow of wide panels to the content column so the sidebar and headings don't scroll with it (WCAG Reflow) */ + .slds .content { + min-width: 0; + overflow-x: auto; + }