Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhancement!: validate input in content API create and update controllers #20169

Merged
merged 24 commits into from May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c7d4298
enh: validate input from content API
innerdvations Apr 22, 2024
e22dd81
chore: remove commented code
innerdvations Apr 22, 2024
3ca17ed
chore: remove old comments
innerdvations Apr 22, 2024
457339c
fix: update visitors to handle undefined attributes
innerdvations Apr 22, 2024
7289f89
Merge branch 'v5/main' into enhancement/validate-input
innerdvations Apr 22, 2024
ae03ba7
test: add missing fields
innerdvations Apr 23, 2024
303f9ae
test: update validate-body
innerdvations Apr 23, 2024
eccf992
fix: check for nil attribute
innerdvations Apr 23, 2024
34bcdaf
fix: allow special attributes
innerdvations Apr 23, 2024
d7c45b6
fix: add __type
innerdvations Apr 23, 2024
dc31b97
Merge branch 'v5/main' into enhancement/validate-input
innerdvations Apr 23, 2024
7452260
fix: add non-attributes to list of recognized fields
innerdvations Apr 23, 2024
745a08e
fix: add exceptions for various cases
innerdvations Apr 23, 2024
e4b5a71
enhancement: improve error details
innerdvations Apr 23, 2024
bdf10c0
chore: use constants
innerdvations Apr 23, 2024
659100b
test: fix test expectation string
innerdvations Apr 23, 2024
c02ecf1
chore: change admin api text to match content api
innerdvations Apr 24, 2024
9d0727f
fix: add parent to traverseEntity
innerdvations Apr 24, 2024
60e1f77
Merge branch 'v5/main' into enhancement/validate-input
innerdvations Apr 24, 2024
be18329
Merge branch 'v5/main' into enhancement/validate-input
innerdvations Apr 26, 2024
1495d3e
fix: check for relation reordering support
innerdvations Apr 26, 2024
00d71f7
fix: add oneToMany
innerdvations Apr 26, 2024
33c817c
Merge branch 'v5/main' into enhancement/validate-input
innerdvations Apr 29, 2024
9fdcf93
Merge branch 'v5/main' into enhancement/validate-input
innerdvations May 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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}`);
});
});
});
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) {
innerdvations marked this conversation as resolved.
Show resolved Hide resolved
return;
}

if (attribute.type === 'relation' && attribute.target === 'admin::user' && value) {
if (Array.isArray(value)) {
Expand Down
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
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
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
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
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
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
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
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
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
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
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