How did Strapi patch this vulnerability?
Jumping to Strapi v4.10.8 when this patch was released, we can run Strapi using a debugger and track when the invalid fields are being stripped from the filter. In the
findMany
method within the Entity Service API, we can see that our filter input is being stripped by
transformParamsToQuery
.
After
transformParamsToQuery
is called the result
query
no longer contains our filter.
The source code for
transformParamsToQuery
shows that if the
filters
parameter is non-empty, then it calls
convertFiltersQueryParams(filters, schema)
where
schema
is the schema of the model that is being queried.
transformParamsToQuery
in
packages/core/utils/lib/convert-query-params.js
const transformParamsToQuery = (uid, params) => {
// NOTE: can be a CT, a Compo or nothing in the case of polymorphism (DZ & morph relations)
const schema = strapi.getModel(uid);
const query = {};
const { _q, sort, filters, fields, populate, page, pageSize, start, limit } = params;
if (!isNil(_q)) {
query._q = _q;
}
if (!isNil(sort)) {
query.orderBy = convertSortQueryParams(sort);
}
if (!isNil(filters)) {
query.where = convertFiltersQueryParams(filters, schema);
}
if (!isNil(fields)) {
query.select = convertFieldsQueryParams(fields);
}
if (!isNil(populate)) {
query.populate = convertPopulateQueryParams(populate, schema);
}
validatePaginationParams(page, pageSize, start, limit);
if (!isNil(page)) {
query.page = convertPageQueryParams(page);
}
if (!isNil(pageSize)) {
query.pageSize = convertPageSizeQueryParams(pageSize, page);
}
if (!isNil(start)) {
query.offset = convertStartQueryParams(start);
}
if (!isNil(limit)) {
query.limit = convertLimitQueryParams(limit);
}
convertPublicationStateParams(schema, params, query);
return query;
};
Looking at
convertFiltersQueryParams
function, we can see that the filter is sanitised by a method named
convertAndSanitizeFilters
.
convertFiltersQueryParams
in
packages/core/utils/lib/convert-query-params.js
const convertFiltersQueryParams = (filters, schema) => {
// Filters need to be either an array or an object
// Here we're only checking for 'object' type since typeof [] => object and typeof {} => object
if (!isObject(filters)) {
throw new Error('The filters parameter must be an object or an array');
}
// Don't mutate the original object
const filtersCopy = cloneDeep(filters);
return convertAndSanitizeFilters(filtersCopy, schema);
};
The code for
convertAndSanitizeFilters
is shown below.
convertAndSanitizeFilters
in
packages/core/utils/lib/convert-query-params.js
const convertAndSanitizeFilters = (filters, schema) => {
if (Array.isArray(filters)) {
return (
filters
// Sanitize each filter
.map((filter) => convertAndSanitizeFilters(filter, schema))
// Filter out empty filters
.filter((filter) => !isObject(filter) || !isEmpty(filter))
);
}
// This must come after check for Array or else arrays are not filtered
if (!isPlainObject(filters)) {
return filters;
}
const removeOperator = (operator) => delete filters[operator];
// Here, `key` can either be an operator or an attribute name
for (const [key, value] of Object.entries(filters)) {
const attribute = get(key, schema?.attributes);
const validKey = isOperator(key) || isValidSchemaAttribute(key, schema);
if (!validKey) {
removeOperator(key);
}
// Handle attributes
else if (attribute) {
// Relations
if (attribute.type === 'relation') {
filters[key] = convertAndSanitizeFilters(value, strapi.getModel(attribute.target));
}
// Components
else if (attribute.type === 'component') {
filters[key] = convertAndSanitizeFilters(value, strapi.getModel(attribute.component));
}
// Media
else if (attribute.type === 'media') {
filters[key] = convertAndSanitizeFilters(value, strapi.getModel('plugin::upload.file'));
}
// Dynamic Zones
else if (attribute.type === 'dynamiczone') {
removeOperator(key);
}
// Password attributes
else if (attribute.type === 'password') {
// Always remove password attributes from filters object
removeOperator(key);
}
// Scalar attributes
else {
filters[key] = convertAndSanitizeFilters(value, schema);
}
}
// Handle operators
else if (['$null', '$notNull'].includes(key)) {
filters[key] = parseType({ type: 'boolean', value: filters[key], forceCast: true });
} else if (isObject(value)) {
filters[key] = convertAndSanitizeFilters(value, schema);
}
// Remove empty objects & arrays
if (isPlainObject(filters[key]) && isEmpty(filters[key])) {
removeOperator(key);
}
}
return filters;
};
We can see that for each key in the filter, the following line of code is executed that checks if either the key is an operator or within the schema of the model.
const validKey = isOperator(key) || isValidSchemaAttribute(key, schema);
Looking at the source code for
isValidSchemaAttribute
, we can see that Strapi now checks if the key in the filter is actually an attribute of the model schema by executing
Object.keys(schema.attributes).includes(key)
.
isValidSchemaAttribute
in
packages/core/utils/lib/convert-query-params.js
const isValidSchemaAttribute = (key, schema) => {
if (key === 'id') {
return true;
}
if (!schema) {
return false;
}
return Object.keys(schema.attributes).includes(key);
};
We can confirm that this
isValidSchemaAttribute
validation check by going back to Strapi v4.8.2 and seeing that there was no check if an attribute was in the schema for a model.
The validation check is missing in
convertAndSanitizeFilters
in Strapi v4.8.2