Skip to content

Commit 9db52db

Browse files
authored
fix: validate zod schema before update operation is executed (#1051)
1 parent 4bf812e commit 9db52db

File tree

4 files changed

+210
-85
lines changed

4 files changed

+210
-85
lines changed

packages/runtime/src/enhancements/policy/handler.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
467467
// Validates the given create payload against Zod schema if any
468468
private validateCreateInputSchema(model: string, data: any) {
469469
const schema = this.utils.getZodSchema(model, 'create');
470-
if (schema) {
470+
if (schema && data) {
471471
const parseResult = schema.safeParse(data);
472472
if (!parseResult.success) {
473473
throw this.utils.deniedByPolicy(
@@ -496,26 +496,29 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
496496

497497
args = this.utils.clone(args);
498498

499-
// do static input validation and check if post-create checks are needed
499+
// go through create items, statically check input to determine if post-create
500+
// check is needed, and also validate zod schema
500501
let needPostCreateCheck = false;
501502
for (const item of enumerate(args.data)) {
503+
const validationResult = this.validateCreateInputSchema(this.model, item);
504+
if (validationResult !== item) {
505+
this.utils.replace(item, validationResult);
506+
}
507+
502508
const inputCheck = this.utils.checkInputGuard(this.model, item, 'create');
503509
if (inputCheck === false) {
510+
// unconditionally deny
504511
throw this.utils.deniedByPolicy(
505512
this.model,
506513
'create',
507514
undefined,
508515
CrudFailureReason.ACCESS_POLICY_VIOLATION
509516
);
510517
} else if (inputCheck === true) {
511-
const r = this.validateCreateInputSchema(this.model, item);
512-
if (r !== item) {
513-
this.utils.replace(item, r);
514-
}
518+
// unconditionally allow
515519
} else if (inputCheck === undefined) {
516520
// static policy check is not possible, need to do post-create check
517521
needPostCreateCheck = true;
518-
break;
519522
}
520523
}
521524

@@ -786,7 +789,13 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
786789

787790
// check if the update actually writes to this model
788791
let thisModelUpdate = false;
789-
const updatePayload: any = (args as any).data ?? args;
792+
const updatePayload = (args as any).data ?? args;
793+
794+
const validatedPayload = this.validateUpdateInputSchema(model, updatePayload);
795+
if (validatedPayload !== updatePayload) {
796+
this.utils.replace(updatePayload, validatedPayload);
797+
}
798+
790799
if (updatePayload) {
791800
for (const key of Object.keys(updatePayload)) {
792801
const field = resolveField(this.modelMeta, model, key);
@@ -857,6 +866,8 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
857866
);
858867
}
859868

869+
args.data = this.validateUpdateInputSchema(model, args.data);
870+
860871
const updateGuard = this.utils.getAuthGuard(db, model, 'update');
861872
if (this.utils.isTrue(updateGuard) || this.utils.isFalse(updateGuard)) {
862873
// injects simple auth guard into where clause
@@ -917,7 +928,10 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
917928
await _registerPostUpdateCheck(model, uniqueFilter);
918929

919930
// convert upsert to update
920-
context.parent.update = { where: args.where, data: args.update };
931+
context.parent.update = {
932+
where: args.where,
933+
data: this.validateUpdateInputSchema(model, args.update),
934+
};
921935
delete context.parent.upsert;
922936

923937
// continue visiting the new payload
@@ -1016,6 +1030,37 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
10161030
return { result, postWriteChecks };
10171031
}
10181032

1033+
// Validates the given update payload against Zod schema if any
1034+
private validateUpdateInputSchema(model: string, data: any) {
1035+
const schema = this.utils.getZodSchema(model, 'update');
1036+
if (schema && data) {
1037+
// update payload can contain non-literal fields, like:
1038+
// { x: { increment: 1 } }
1039+
// we should only validate literal fields
1040+
1041+
const literalData = Object.entries(data).reduce<any>(
1042+
(acc, [k, v]) => ({ ...acc, ...(typeof v !== 'object' ? { [k]: v } : {}) }),
1043+
{}
1044+
);
1045+
1046+
const parseResult = schema.safeParse(literalData);
1047+
if (!parseResult.success) {
1048+
throw this.utils.deniedByPolicy(
1049+
model,
1050+
'update',
1051+
`input failed validation: ${fromZodError(parseResult.error)}`,
1052+
CrudFailureReason.DATA_VALIDATION_VIOLATION,
1053+
parseResult.error
1054+
);
1055+
}
1056+
1057+
// schema may have transformed field values, use it to overwrite the original data
1058+
return { ...data, ...parseResult.data };
1059+
} else {
1060+
return data;
1061+
}
1062+
}
1063+
10191064
private isUnsafeMutate(model: string, args: any) {
10201065
if (!args) {
10211066
return false;
@@ -1046,6 +1091,8 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
10461091
args = this.utils.clone(args);
10471092
this.utils.injectAuthGuardAsWhere(this.prisma, args, this.model, 'update');
10481093

1094+
args.data = this.validateUpdateInputSchema(this.model, args.data);
1095+
10491096
if (this.utils.hasAuthGuard(this.model, 'postUpdate') || this.utils.getZodSchema(this.model)) {
10501097
// use a transaction to do post-update checks
10511098
const postWriteChecks: PostWriteCheckRecord[] = [];

tests/integration/tests/enhancements/with-policy/field-validation.test.ts

Lines changed: 95 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe('With Policy: field validation', () => {
4545
id String @id @default(cuid())
4646
user User @relation(fields: [userId], references: [id])
4747
userId String
48-
slug String @regex("^[0-9a-zA-Z]{4,16}$")
48+
slug String @regex("^[0-9a-zA-Z]{4,16}$") @lower
4949
5050
@@allow('all', true)
5151
}
@@ -508,50 +508,104 @@ describe('With Policy: field validation', () => {
508508
},
509509
});
510510

511-
await expect(
512-
db.userData.create({
513-
data: {
514-
userId: '1',
515-
a: 1,
516-
b: 0,
517-
c: -1,
518-
d: 0,
519-
text1: 'abc123',
520-
text2: 'def',
521-
text3: 'aaa',
522-
text4: 'abcab',
523-
text6: ' AbC ',
524-
text7: 'abc',
511+
let ud = await db.userData.create({
512+
data: {
513+
userId: '1',
514+
a: 1,
515+
b: 0,
516+
c: -1,
517+
d: 0,
518+
text1: 'abc123',
519+
text2: 'def',
520+
text3: 'aaa',
521+
text4: 'abcab',
522+
text6: ' AbC ',
523+
text7: 'abc',
524+
},
525+
});
526+
expect(ud).toMatchObject({ text6: 'abc', text7: 'ABC' });
527+
528+
ud = await db.userData.update({
529+
where: { id: ud.id },
530+
data: {
531+
text4: 'xyz',
532+
text6: ' bCD ',
533+
text7: 'bcd',
534+
},
535+
});
536+
expect(ud).toMatchObject({ text4: 'xyz', text6: 'bcd', text7: 'BCD' });
537+
538+
let u = await db.user.create({
539+
data: {
540+
id: '2',
541+
password: 'abc123!@#',
542+
543+
handle: 'user2',
544+
userData: {
545+
create: {
546+
a: 1,
547+
b: 0,
548+
c: -1,
549+
d: 0,
550+
text1: 'abc123',
551+
text2: 'def',
552+
text3: 'aaa',
553+
text4: 'abcab',
554+
text6: ' AbC ',
555+
text7: 'abc',
556+
},
525557
},
526-
})
527-
).resolves.toMatchObject({ text6: 'abc', text7: 'ABC' });
558+
},
559+
include: { userData: true },
560+
});
561+
expect(u.userData).toMatchObject({
562+
text6: 'abc',
563+
text7: 'ABC',
564+
});
528565

529-
await expect(
530-
db.user.create({
531-
data: {
532-
id: '2',
533-
password: 'abc123!@#',
534-
535-
handle: 'user2',
536-
userData: {
537-
create: {
538-
a: 1,
539-
b: 0,
540-
c: -1,
541-
d: 0,
542-
text1: 'abc123',
543-
text2: 'def',
544-
text3: 'aaa',
545-
text4: 'abcab',
546-
text6: ' AbC ',
547-
text7: 'abc',
548-
},
566+
u = await db.user.update({
567+
where: { id: u.id },
568+
data: {
569+
userData: {
570+
update: {
571+
data: { text4: 'xyz', text6: ' bCD ', text7: 'bcd' },
549572
},
550573
},
551-
include: { userData: true },
552-
})
553-
).resolves.toMatchObject({
554-
userData: expect.objectContaining({ text6: 'abc', text7: 'ABC' }),
574+
},
575+
include: { userData: true },
576+
});
577+
expect(u.userData).toMatchObject({ text4: 'xyz', text6: 'bcd', text7: 'BCD' });
578+
579+
// upsert create
580+
u = await db.user.update({
581+
where: { id: u.id },
582+
data: {
583+
tasks: {
584+
upsert: {
585+
where: { id: 'unknown' },
586+
create: { slug: 'SLUG1' },
587+
update: {},
588+
},
589+
},
590+
},
591+
include: { tasks: true },
592+
});
593+
expect(u.tasks[0]).toMatchObject({ slug: 'slug1' });
594+
595+
// upsert update
596+
u = await db.user.update({
597+
where: { id: u.id },
598+
data: {
599+
tasks: {
600+
upsert: {
601+
where: { id: u.tasks[0].id },
602+
create: {},
603+
update: { slug: 'SLUG2' },
604+
},
605+
},
606+
},
607+
include: { tasks: true },
555608
});
609+
expect(u.tasks[0]).toMatchObject({ slug: 'slug2' });
556610
});
557611
});

0 commit comments

Comments
 (0)