Skip to content

Commit ca55bf6

Browse files
authored
fix: query injection error when create (in array form) is nested inside an update (#865)
1 parent 2d43692 commit ca55bf6

7 files changed

Lines changed: 293 additions & 68 deletions

File tree

packages/runtime/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"pluralize": "^8.0.0",
6565
"semver": "^7.3.8",
6666
"superjson": "^1.11.0",
67+
"tiny-invariant": "^1.3.1",
6768
"tslib": "^2.4.1",
6869
"upper-case-first": "^2.0.2",
6970
"uuid": "^9.0.0",

packages/runtime/src/cross/model-meta.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,21 @@ export type ModelMeta = {
102102
/**
103103
* Resolves a model field to its metadata. Returns undefined if not found.
104104
*/
105-
export function resolveField(modelMeta: ModelMeta, model: string, field: string): FieldInfo | undefined {
105+
export function resolveField(modelMeta: ModelMeta, model: string, field: string) {
106106
return modelMeta.fields[lowerCaseFirst(model)]?.[field];
107107
}
108108

109+
/**
110+
* Resolves a model field to its metadata. Throws an error if not found.
111+
*/
112+
export function requireField(modelMeta: ModelMeta, model: string, field: string) {
113+
const f = resolveField(modelMeta, model, field);
114+
if (!f) {
115+
throw new Error(`Field ${model}.${field} cannot be resolved`);
116+
}
117+
return f;
118+
}
119+
109120
/**
110121
* Gets all fields of a model.
111122
*/

packages/runtime/src/cross/nested-write-visitor.ts

Lines changed: 31 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -145,60 +145,61 @@ export class NestedWriteVisitor {
145145
return;
146146
}
147147

148-
const context = { parent, field, nestingPath: [...nestingPath] };
149148
const toplevel = field == undefined;
150149

150+
const context = { parent, field, nestingPath: [...nestingPath] };
151+
const pushNewContext = (field: FieldInfo | undefined, model: string, where: any, unique = false) => {
152+
return { ...context, nestingPath: [...context.nestingPath, { field, model, where, unique }] };
153+
};
154+
151155
// visit payload
152156
switch (action) {
153157
case 'create':
154-
context.nestingPath.push({ field, model, where: {}, unique: false });
155158
for (const item of enumerate(data)) {
159+
const newContext = pushNewContext(field, model, {});
156160
let callbackResult: any;
157161
if (this.callback.create) {
158-
callbackResult = await this.callback.create(model, item, context);
162+
callbackResult = await this.callback.create(model, item, newContext);
159163
}
160164
if (callbackResult !== false) {
161165
const subPayload = typeof callbackResult === 'object' ? callbackResult : item;
162-
await this.visitSubPayload(model, action, subPayload, context.nestingPath);
166+
await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
163167
}
164168
}
165169
break;
166170

167171
case 'createMany':
168172
if (data) {
169-
context.nestingPath.push({ field, model, where: {}, unique: false });
173+
const newContext = pushNewContext(field, model, {});
170174
let callbackResult: any;
171175
if (this.callback.createMany) {
172-
callbackResult = await this.callback.createMany(model, data, context);
176+
callbackResult = await this.callback.createMany(model, data, newContext);
173177
}
174178
if (callbackResult !== false) {
175179
const subPayload = typeof callbackResult === 'object' ? callbackResult : data.data;
176-
await this.visitSubPayload(model, action, subPayload, context.nestingPath);
180+
await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
177181
}
178182
}
179183
break;
180184

181185
case 'connectOrCreate':
182-
context.nestingPath.push({ field, model, where: data.where, unique: false });
183186
for (const item of enumerate(data)) {
187+
const newContext = pushNewContext(field, model, item.where);
184188
let callbackResult: any;
185189
if (this.callback.connectOrCreate) {
186-
callbackResult = await this.callback.connectOrCreate(model, item, context);
190+
callbackResult = await this.callback.connectOrCreate(model, item, newContext);
187191
}
188192
if (callbackResult !== false) {
189193
const subPayload = typeof callbackResult === 'object' ? callbackResult : item.create;
190-
await this.visitSubPayload(model, action, subPayload, context.nestingPath);
194+
await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
191195
}
192196
}
193197
break;
194198

195199
case 'connect':
196200
if (this.callback.connect) {
197201
for (const item of enumerate(data)) {
198-
const newContext = {
199-
...context,
200-
nestingPath: [...context.nestingPath, { field, model, where: item, unique: true }],
201-
};
202+
const newContext = pushNewContext(field, model, item, true);
202203
await this.callback.connect(model, item, newContext);
203204
}
204205
}
@@ -210,31 +211,25 @@ export class NestedWriteVisitor {
210211
// if relation is to-one, the payload can only be boolean `true`
211212
if (this.callback.disconnect) {
212213
for (const item of enumerate(data)) {
213-
const newContext = {
214-
...context,
215-
nestingPath: [
216-
...context.nestingPath,
217-
{ field, model, where: item, unique: typeof item === 'object' },
218-
],
219-
};
214+
const newContext = pushNewContext(field, model, item, typeof item === 'object');
220215
await this.callback.disconnect(model, item, newContext);
221216
}
222217
}
223218
break;
224219

225220
case 'set':
226221
if (this.callback.set) {
227-
context.nestingPath.push({ field, model, where: {}, unique: false });
228-
await this.callback.set(model, data, context);
222+
const newContext = pushNewContext(field, model, {});
223+
await this.callback.set(model, data, newContext);
229224
}
230225
break;
231226

232227
case 'update':
233-
context.nestingPath.push({ field, model, where: data.where, unique: false });
234228
for (const item of enumerate(data)) {
229+
const newContext = pushNewContext(field, model, item.where);
235230
let callbackResult: any;
236231
if (this.callback.update) {
237-
callbackResult = await this.callback.update(model, item, context);
232+
callbackResult = await this.callback.update(model, item, newContext);
238233
}
239234
if (callbackResult !== false) {
240235
const subPayload =
@@ -243,38 +238,38 @@ export class NestedWriteVisitor {
243238
: typeof item.data === 'object'
244239
? item.data
245240
: item;
246-
await this.visitSubPayload(model, action, subPayload, context.nestingPath);
241+
await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
247242
}
248243
}
249244
break;
250245

251246
case 'updateMany':
252-
context.nestingPath.push({ field, model, where: data.where, unique: false });
253247
for (const item of enumerate(data)) {
248+
const newContext = pushNewContext(field, model, item.where);
254249
let callbackResult: any;
255250
if (this.callback.updateMany) {
256-
callbackResult = await this.callback.updateMany(model, item, context);
251+
callbackResult = await this.callback.updateMany(model, item, newContext);
257252
}
258253
if (callbackResult !== false) {
259254
const subPayload = typeof callbackResult === 'object' ? callbackResult : item;
260-
await this.visitSubPayload(model, action, subPayload, context.nestingPath);
255+
await this.visitSubPayload(model, action, subPayload, newContext.nestingPath);
261256
}
262257
}
263258
break;
264259

265260
case 'upsert': {
266-
context.nestingPath.push({ field, model, where: data.where, unique: false });
267261
for (const item of enumerate(data)) {
262+
const newContext = pushNewContext(field, model, item.where);
268263
let callbackResult: any;
269264
if (this.callback.upsert) {
270-
callbackResult = await this.callback.upsert(model, item, context);
265+
callbackResult = await this.callback.upsert(model, item, newContext);
271266
}
272267
if (callbackResult !== false) {
273268
if (typeof callbackResult === 'object') {
274-
await this.visitSubPayload(model, action, callbackResult, context.nestingPath);
269+
await this.visitSubPayload(model, action, callbackResult, newContext.nestingPath);
275270
} else {
276-
await this.visitSubPayload(model, action, item.create, context.nestingPath);
277-
await this.visitSubPayload(model, action, item.update, context.nestingPath);
271+
await this.visitSubPayload(model, action, item.create, newContext.nestingPath);
272+
await this.visitSubPayload(model, action, item.update, newContext.nestingPath);
278273
}
279274
}
280275
}
@@ -284,13 +279,7 @@ export class NestedWriteVisitor {
284279
case 'delete': {
285280
if (this.callback.delete) {
286281
for (const item of enumerate(data)) {
287-
const newContext = {
288-
...context,
289-
nestingPath: [
290-
...context.nestingPath,
291-
{ field, model, where: toplevel ? item.where : item, unique: false },
292-
],
293-
};
282+
const newContext = pushNewContext(field, model, toplevel ? item.where : item);
294283
await this.callback.delete(model, item, newContext);
295284
}
296285
}
@@ -300,13 +289,7 @@ export class NestedWriteVisitor {
300289
case 'deleteMany':
301290
if (this.callback.deleteMany) {
302291
for (const item of enumerate(data)) {
303-
const newContext = {
304-
...context,
305-
nestingPath: [
306-
...context.nestingPath,
307-
{ field, model, where: toplevel ? item.where : item, unique: false },
308-
],
309-
};
292+
const newContext = pushNewContext(field, model, toplevel ? item.where : item);
310293
await this.callback.deleteMany(model, item, newContext);
311294
}
312295
}

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

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22

33
import { lowerCaseFirst } from 'lower-case-first';
4+
import invariant from 'tiny-invariant';
45
import { upperCaseFirst } from 'upper-case-first';
56
import { fromZodError } from 'zod-validation-error';
67
import { CrudFailureReason, PRISMA_TX_FLAG } from '../../constants';
@@ -10,6 +11,7 @@ import {
1011
NestedWriteVisitorContext,
1112
enumerate,
1213
getIdFields,
14+
requireField,
1315
resolveField,
1416
type FieldInfo,
1517
type ModelMeta,
@@ -641,20 +643,62 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
641643

642644
// handles the connection to upstream entity
643645
const reversedQuery = this.utils.buildReversedQuery(context, true, unsafe);
644-
if (reversedQuery[context.field.backLink]) {
645-
// the built reverse query contains a condition for the backlink field, build a "connect" with it
646+
if ((!unsafe || context.field.isRelationOwner) && reversedQuery[context.field.backLink]) {
647+
// if mutation is safe, or current field owns the relation (so the other side has no fk),
648+
// and the reverse query contains the back link, then we can build a "connect" with it
646649
createData = {
647650
...createData,
648651
[context.field.backLink]: {
649652
connect: reversedQuery[context.field.backLink],
650653
},
651654
};
652655
} else {
653-
// otherwise, the reverse query is translated to foreign key setting, merge it to the create data
654-
createData = {
655-
...createData,
656-
...reversedQuery,
657-
};
656+
// otherwise, the reverse query should be translated to foreign key setting
657+
// and merged to the create data
658+
659+
const backLinkField = this.requireBackLink(context.field);
660+
invariant(backLinkField.foreignKeyMapping);
661+
662+
// try to extract foreign key values from the reverse query
663+
let fkValues = Object.values(backLinkField.foreignKeyMapping).reduce<any>((obj, fk) => {
664+
obj[fk] = reversedQuery[fk];
665+
return obj;
666+
}, {});
667+
668+
if (Object.values(fkValues).every((v) => v !== undefined)) {
669+
// all foreign key values are available, merge them to the create data
670+
createData = {
671+
...createData,
672+
...fkValues,
673+
};
674+
} else {
675+
// some foreign key values are missing, need to look up the upstream entity,
676+
// this can happen when the upstream entity doesn't have a unique where clause,
677+
// for example when it's nested inside a one-to-one update
678+
const upstreamQuery = {
679+
where: reversedQuery[backLinkField.name],
680+
select: this.utils.makeIdSelection(backLinkField.type),
681+
};
682+
683+
// fetch the upstream entity
684+
if (this.logger.enabled('info')) {
685+
this.logger.info(
686+
`[policy] \`findUniqueOrThrow\` ${model}: looking up upstream entity of ${
687+
backLinkField.type
688+
}, ${formatObject(upstreamQuery)}`
689+
);
690+
}
691+
const upstreamEntity = await this.prisma[backLinkField.type].findUniqueOrThrow(upstreamQuery);
692+
693+
// map ids to foreign keys
694+
fkValues = Object.entries(backLinkField.foreignKeyMapping).reduce<any>((obj, [id, fk]) => {
695+
obj[fk] = upstreamEntity[id];
696+
return obj;
697+
}, {});
698+
699+
// merge them to the create data
700+
createData = { ...createData, ...fkValues };
701+
}
658702
}
659703
}
660704

@@ -1192,7 +1236,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
11921236
// already in transaction, don't nest
11931237
return action(this.prisma);
11941238
} else {
1195-
return this.prisma.$transaction((tx) => action(tx));
1239+
return this.prisma.$transaction((tx) => action(tx), { maxWait: 100000, timeout: 100000 });
11961240
}
11971241
}
11981242

@@ -1217,11 +1261,8 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
12171261
}
12181262

12191263
private requireBackLink(fieldInfo: FieldInfo) {
1220-
const backLinkField = fieldInfo.backLink && resolveField(this.modelMeta, fieldInfo.type, fieldInfo.backLink);
1221-
if (!backLinkField) {
1222-
throw new Error('Missing back link for field: ' + fieldInfo.name);
1223-
}
1224-
return backLinkField;
1264+
invariant(fieldInfo.backLink, `back link not found for field ${fieldInfo.name}`);
1265+
return requireField(this.modelMeta, fieldInfo.type, fieldInfo.backLink);
12251266
}
12261267

12271268
//#endregion

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)