Skip to content

Commit

Permalink
enhancement!: validate input in content API create and update control…
Browse files Browse the repository at this point in the history
…lers

Co-authored-by: Jean-Sébastien Herbaux <Convly@users.noreply.github.com>
  • Loading branch information
innerdvations and Convly committed May 7, 2024
1 parent 17b4116 commit 7a6d9a2
Show file tree
Hide file tree
Showing 39 changed files with 414 additions and 95 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('Permissions Manager - Validate', () => {
expect(async () => {
// @ts-expect-error
await validateHelpers.validateInput(data, { subject: fooModel.uid });
}).rejects.toThrow('Invalid parameter a');
}).rejects.toThrow('Invalid key a');
});
});

Expand All @@ -115,7 +115,7 @@ describe('Permissions Manager - Validate', () => {
expect(async () => {
// @ts-expect-error
await validateHelpers.validateQuery(data, { subject: fooModel.uid });
}).rejects.toThrow(`Invalid parameter ${invalidParam}`);
}).rejects.toThrow(`Invalid key ${invalidParam}`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ export default ({ action, ability, model }: any) => {
*/
const pickAllowedAdminUserFields = ({ attribute, key, value }: any, { set }: any) => {
const pickAllowedFields = pick(ADMIN_USER_ALLOWED_FIELDS);
if (!attribute) {
return;
}

if (attribute.type === 'relation' && attribute.target === 'admin::user' && value) {
if (Array.isArray(value)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ const COMPONENT_FIELDS = ['__component'];

const STATIC_FIELDS = [ID_ATTRIBUTE, DOC_ID_ATTRIBUTE];

const throwInvalidParam = ({ key, path }: { key: string; path?: string | null }) => {
const msg =
path && path !== key ? `Invalid parameter ${key} at ${path}` : `Invalid parameter ${key}`;
const throwInvalidKey = ({ key, path }: { key: string; path?: string | null }) => {
const msg = path && path !== key ? `Invalid key ${key} at ${path}` : `Invalid key ${key}`;

throw new ValidationError(msg);
};
Expand All @@ -64,7 +63,7 @@ export default ({ action, ability, model }: any) => {
traverse.traverseQueryFilters(throwPassword, ctx),
traverse.traverseQueryFilters(({ key, value, path }) => {
if (isObject(value) && isEmpty(value)) {
throwInvalidParam({ key, path: path.attribute });
throwInvalidKey({ key, path: path.attribute });
}
}, ctx)
);
Expand All @@ -75,7 +74,7 @@ export default ({ action, ability, model }: any) => {
traverse.traverseQuerySort(throwPassword, ctx),
traverse.traverseQuerySort(({ key, attribute, value, path }) => {
if (!isScalarAttribute(attribute) && isEmpty(value)) {
throwInvalidParam({ key, path: path.attribute });
throwInvalidKey({ key, path: path.attribute });
}
}, ctx)
);
Expand Down Expand Up @@ -182,7 +181,7 @@ export default ({ action, ability, model }: any) => {
const isHidden = getOr(false, ['config', 'attributes', key, 'hidden'], schema);

if (isHidden) {
throwInvalidParam({ key, path: path.attribute });
throwInvalidKey({ key, path: path.attribute });
}
};

Expand All @@ -191,7 +190,7 @@ export default ({ action, ability, model }: any) => {
*/
const throwDisallowedAdminUserFields = ({ key, attribute, schema, path }: any) => {
if (schema.uid === 'admin::user' && attribute && !ADMIN_USER_ALLOWED_FIELDS.includes(key)) {
throwInvalidParam({ key, path: path.attribute });
throwInvalidKey({ key, path: path.attribute });
}
};

Expand Down
4 changes: 4 additions & 0 deletions packages/core/core/src/core-api/controller/collection-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const createCollectionTypeController = ({
throw new errors.ValidationError('Missing "data" payload in the request body');
}

await this.validateInput(body.data, ctx);

const sanitizedInputData = await this.sanitizeInput(body.data, ctx);

const entity = await strapi.service(uid).create({
Expand Down Expand Up @@ -84,6 +86,8 @@ const createCollectionTypeController = ({
throw new errors.ValidationError('Missing "data" payload in the request body');
}

await this.validateInput(body.data, ctx);

const sanitizedInputData = await this.sanitizeInput(body.data, ctx);

const entity = await strapi.service(uid).update(id, {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/core/src/core-api/controller/single-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const createSingleTypeController = ({
throw new errors.ValidationError('Missing "data" payload in the request body');
}

await this.validateInput(body.data, ctx);

const sanitizedInputData = await this.sanitizeInput(body.data, ctx);

const entity = await strapi.service(uid).createOrUpdate({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const addRelationDocId = curry(
const extractDataIds = (idMap: IdMap, data: Record<string, any>, source: Options) => {
return traverseEntityRelations(
async ({ attribute, value }) => {
if (!attribute) {
return;
}

const targetUid = attribute.target!;
const addDocId = addRelationDocId(idMap, targetUid, source);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ const getRelationIds = curry(
const transformDataIdsVisitor = (idMap: IdMap, data: Record<string, any>, source: Options) => {
return traverseEntityRelations(
async ({ key, value, attribute }, { set }) => {
if (!attribute) {
return;
}

// Find relational attributes, and return the document ids
const targetUid = attribute.target!;
const getIds = getRelationIds(idMap, targetUid, source);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,23 @@ const traverseEntityRelations = async (
) => {
return traverseEntity(
async (options, utils) => {
if (options.attribute.type !== 'relation') {
const { attribute } = options;

if (!attribute) {
return;
}

if (attribute.type !== 'relation') {
return;
}

// TODO: Handle join columns
if (options.attribute.useJoinTable === false) {
if (attribute.useJoinTable === false) {
return;
}

// TODO: Handle morph relations (they have multiple targets)
const target = options.attribute.target as UID.Schema | undefined;
const target = attribute.target as UID.Schema | undefined;
if (!target) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const signEntityMediaVisitor: SignEntityMediaVisitor = async (
) => {
const { signFileUrls } = getService('file');

if (!attribute) {
return;
}

if (attribute.type !== 'media') {
return;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/upload/server/src/services/extensions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const signEntityMediaVisitor: SignEntityMediaVisitor = async (
) => {
const { signFileUrls } = getService('file');

if (!attribute) {
return;
}

if (attribute.type !== 'media') {
return;
}
Expand Down
44 changes: 36 additions & 8 deletions packages/core/utils/src/content-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ const hasDraftAndPublish = (model: Model) =>
const isDraft = <T extends object>(data: T, model: Model) =>
hasDraftAndPublish(model) && _.get(data, PUBLISHED_AT_ATTRIBUTE) === null;

const isSchema = (data: unknown): data is Model => {
return (
typeof data === 'object' &&
data !== null &&
'modelType' in data &&
typeof data.modelType === 'string' &&
['component', 'contentType'].includes(data.modelType)
);
};

const isComponentSchema = (data: unknown): data is Model & { modelType: 'component' } => {
return isSchema(data) && data.modelType === 'component';
};

const isContentTypeSchema = (data: unknown): data is Model & { modelType: 'contentType' } => {
return isSchema(data) && data.modelType === 'contentType';
};

const isSingleType = ({ kind = COLLECTION_TYPE }) => kind === SINGLE_TYPE;
const isCollectionType = ({ kind = COLLECTION_TYPE }) => kind === COLLECTION_TYPE;
const isKind = (kind: Kind) => (model: Model) => model.kind === kind;
Expand All @@ -141,22 +159,28 @@ const isPrivateAttribute = (model: Model, attributeName: string) => {
return getStoredPrivateAttributes(model).includes(attributeName);
};

const isScalarAttribute = (attribute: Attribute) => {
return !['media', 'component', 'relation', 'dynamiczone'].includes(attribute?.type);
const isScalarAttribute = (attribute?: Attribute) => {
return attribute && !['media', 'component', 'relation', 'dynamiczone'].includes(attribute.type);
};
const isMediaAttribute = (attribute: Attribute) => attribute?.type === 'media';
const isRelationalAttribute = (attribute: Attribute): attribute is RelationalAttribute =>
const isMediaAttribute = (attribute?: Attribute) => attribute?.type === 'media';
const isRelationalAttribute = (attribute?: Attribute): attribute is RelationalAttribute =>
attribute?.type === 'relation';

const HAS_RELATION_REORDERING = ['manyToMany', 'manyToOne', 'oneToMany'];
const hasRelationReordering = (attribute?: Attribute) =>
isRelationalAttribute(attribute) && HAS_RELATION_REORDERING.includes(attribute.relation);

const isComponentAttribute = (
attribute: Attribute
): attribute is ComponentAttribute | DynamicZoneAttribute =>
['component', 'dynamiczone'].includes(attribute?.type);

const isDynamicZoneAttribute = (attribute: Attribute): attribute is DynamicZoneAttribute =>
attribute?.type === 'dynamiczone';
const isMorphToRelationalAttribute = (attribute: Attribute) => {
return isRelationalAttribute(attribute) && attribute?.relation?.startsWith?.('morphTo');
const isDynamicZoneAttribute = (attribute?: Attribute): attribute is DynamicZoneAttribute =>
!!attribute && attribute.type === 'dynamiczone';
const isMorphToRelationalAttribute = (attribute?: Attribute) => {
return (
!!attribute && isRelationalAttribute(attribute) && attribute.relation?.startsWith?.('morphTo')
);
};

const getComponentAttributes = (schema: Model) => {
Expand Down Expand Up @@ -213,9 +237,13 @@ const getContentTypeRoutePrefix = (contentType: WithRequired<Model, 'info'>) =>
};

export {
isSchema,
isContentTypeSchema,
isComponentSchema,
isScalarAttribute,
isMediaAttribute,
isRelationalAttribute,
hasRelationReordering,
isComponentAttribute,
isDynamicZoneAttribute,
isMorphToRelationalAttribute,
Expand Down
40 changes: 25 additions & 15 deletions packages/core/utils/src/traverse-entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export interface VisitorOptions {
schema: Model;
key: string;
value: Data[keyof Data];
attribute: AnyAttribute;
attribute?: AnyAttribute;
path: Path;
getModel(uid: string): Model;
parent?: Parent;
}

export type Visitor = (visitorOptions: VisitorOptions, visitorUtils: VisitorUtils) => void;
Expand All @@ -23,25 +24,35 @@ export interface Path {
}

export interface TraverseOptions {
path?: Path;
schema: Model;
path?: Path;
parent?: Parent;
getModel(uid: string): Model;
}

export interface Parent {
path: Path;
schema?: Model;
key?: string;
attribute?: AnyAttribute;
}

const traverseEntity = async (visitor: Visitor, options: TraverseOptions, entity: Data) => {
const { path = { raw: null, attribute: null }, schema, getModel } = options;

let parent = options.parent;

const traverseMorphRelationTarget = async (visitor: Visitor, path: Path, entry: Data) => {
const targetSchema = getModel(entry.__type!);

const traverseOptions = { schema: targetSchema, path, getModel };
const traverseOptions: TraverseOptions = { schema: targetSchema, path, getModel, parent };

return traverseEntity(visitor, traverseOptions, entry);
};

const traverseRelationTarget =
(schema: Model) => async (visitor: Visitor, path: Path, entry: Data) => {
const traverseOptions = { schema, path, getModel };
const traverseOptions: TraverseOptions = { schema, path, getModel, parent };

return traverseEntity(visitor, traverseOptions, entry);
};
Expand All @@ -50,20 +61,20 @@ const traverseEntity = async (visitor: Visitor, options: TraverseOptions, entity
const targetSchemaUID = 'plugin::upload.file';
const targetSchema = getModel(targetSchemaUID);

const traverseOptions = { schema: targetSchema, path, getModel };
const traverseOptions: TraverseOptions = { schema: targetSchema, path, getModel, parent };

return traverseEntity(visitor, traverseOptions, entry);
};

const traverseComponent = async (visitor: Visitor, path: Path, schema: Model, entry: Data) => {
const traverseOptions = { schema, path, getModel };
const traverseOptions: TraverseOptions = { schema, path, getModel, parent };

return traverseEntity(visitor, traverseOptions, entry);
};

const visitDynamicZoneEntry = async (visitor: Visitor, path: Path, entry: Data) => {
const targetSchema = getModel(entry.__component!);
const traverseOptions = { schema: targetSchema, path, getModel };
const traverseOptions: TraverseOptions = { schema: targetSchema, path, getModel, parent };

return traverseEntity(visitor, traverseOptions, entry);
};
Expand All @@ -82,12 +93,7 @@ const traverseEntity = async (visitor: Visitor, options: TraverseOptions, entity
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i];
// Retrieve the attribute definition associated to the key from the schema
const attribute = schema.attributes[key];

// If the attribute doesn't exist within the schema, ignore it
if (isNil(attribute)) {
continue;
}
const attribute = schema.attributes[key] as AnyAttribute | undefined;

const newPath = { ...path };

Expand All @@ -106,18 +112,22 @@ const traverseEntity = async (visitor: Visitor, options: TraverseOptions, entity
attribute,
path: newPath,
getModel,
parent,
};

await visitor(visitorOptions, visitorUtils);

// Extract the value for the current key (after calling the visitor)
const value = copy[key];

// Ignore Nil values
if (isNil(value)) {
// Ignore Nil values or attributes
if (isNil(value) || isNil(attribute)) {
continue;
}

// The current attribute becomes the parent once visited
parent = { schema, key, attribute, path: newPath };

if (isRelationalAttribute(attribute)) {
const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');

Expand Down
2 changes: 1 addition & 1 deletion packages/core/utils/src/traverse/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface VisitorOptions {
value: unknown;
schema: Model;
key: string;
attribute: AnyAttribute;
attribute?: AnyAttribute;
path: Path;
getModel(uid: string): Model;
}
Expand Down

0 comments on commit 7a6d9a2

Please sign in to comment.