Skip to content

Commit 2ad035f

Browse files
authored
feat(db-mongodb): strip keys from the data that don't exist in the schema from read results (#11558)
This change makes so that data that exists in MongoDB but isn't defined in the Payload config won't be included to `payload.find` / `payload.db.find` calls. Now we strip all the additional keys. Consider you have a field named `secretField` that's also `hidden: true` (or `read: () => false`) that contains some sensitive data. Then you removed this field from the database and as for now with the MongoDB adapter this field will be included to the Local API / REST API results without any consideration, as Payload doesn't know about it anymore. This also fixes #11542 if you removed / renamed a relationship field from the schema, Payload won't sanitize ObjectIDs back to strings anymore. Ideally you should create a migration script that completely removes the deleted field from the database with `$unset`, but people rarely do this. If you still need to keep those fields to the result, this PR allows you to do this with the new `allowAdditionalKeys: true` flag.
1 parent 1ad1de7 commit 2ad035f

File tree

5 files changed

+223
-3
lines changed

5 files changed

+223
-3
lines changed

docs/database/mongodb.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,12 @@ export default buildConfig({
3434
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
3535
| `autoPluralization` | Tell Mongoose to auto-pluralize any collection names if it encounters any singular words used as collection `slug`s. |
3636
| `connectOptions` | Customize MongoDB connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose. |
37-
| `collectionsSchemaOptions` | Customize Mongoose schema options for collections. |
37+
| `collectionsSchemaOptions` | Customize Mongoose schema options for collections. |
3838
| `disableIndexHints` | Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination, as it increases the speed of the count function used in that query. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false |
3939
| `migrationDir` | Customize the directory that migrations are stored. |
4040
| `transactionOptions` | An object with configuration properties used in [transactions](https://www.mongodb.com/docs/manual/core/transactions/) or `false` which will disable the use of transactions. |
4141
| `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). |
42+
| `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. |
4243

4344
## Access to Mongoose models
4445

packages/db-mongodb/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ import { upsert } from './upsert.js'
6363
export type { MigrateDownArgs, MigrateUpArgs } from './types.js'
6464

6565
export interface Args {
66+
/**
67+
* By default, Payload strips all additional keys from MongoDB data that don't exist
68+
* in the Payload schema. If you have some data that you want to include to the result
69+
* but it doesn't exist in Payload, you can enable this flag
70+
* @default false
71+
*/
72+
allowAdditionalKeys?: boolean
6673
/** Set to false to disable auto-pluralization of collection names, Defaults to true */
6774
autoPluralization?: boolean
6875
/**
@@ -89,6 +96,7 @@ export interface Args {
8996
* Defaults to disabled.
9097
*/
9198
collation?: Omit<CollationOptions, 'locale'>
99+
92100
collectionsSchemaOptions?: Partial<Record<CollectionSlug, SchemaOptions>>
93101

94102
/** Extra configuration options */
@@ -174,6 +182,7 @@ declare module 'payload' {
174182
}
175183

176184
export function mongooseAdapter({
185+
allowAdditionalKeys = false,
177186
autoPluralization = true,
178187
collectionsSchemaOptions = {},
179188
connectOptions,
@@ -210,6 +219,7 @@ export function mongooseAdapter({
210219
url,
211220
versions: {},
212221
// DatabaseAdapter
222+
allowAdditionalKeys,
213223
beginTransaction: transactionOptions === false ? defaultBeginTransaction() : beginTransaction,
214224
collectionsSchemaOptions,
215225
commitTransaction,

packages/db-mongodb/src/utilities/transform.ts

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type {
22
CollectionConfig,
33
DateField,
44
Field,
5+
FlattenedBlock,
6+
FlattenedField,
57
JoinField,
68
RelationshipField,
79
SanitizedConfig,
@@ -10,7 +12,7 @@ import type {
1012
} from 'payload'
1113

1214
import { Types } from 'mongoose'
13-
import { traverseFields } from 'payload'
15+
import { flattenAllFields, traverseFields } from 'payload'
1416
import { fieldAffectsData, fieldShouldBeLocalized } from 'payload/shared'
1517

1618
import type { MongooseAdapter } from '../index.js'
@@ -228,6 +230,141 @@ type Args = {
228230
validateRelationships?: boolean
229231
}
230232

233+
const stripFields = ({
234+
config,
235+
data,
236+
fields,
237+
reservedKeys = [],
238+
}: {
239+
config: SanitizedConfig
240+
data: any
241+
fields: FlattenedField[]
242+
reservedKeys?: string[]
243+
}) => {
244+
for (const k in data) {
245+
if (!fields.some((field) => field.name === k) && !reservedKeys.includes(k)) {
246+
delete data[k]
247+
}
248+
}
249+
250+
for (const field of fields) {
251+
reservedKeys = []
252+
const fieldData = data[field.name]
253+
if (!fieldData || typeof fieldData !== 'object') {
254+
continue
255+
}
256+
257+
if (field.type === 'blocks') {
258+
reservedKeys.push('blockType')
259+
}
260+
261+
if ('flattenedFields' in field || 'blocks' in field) {
262+
if (field.localized && config.localization) {
263+
for (const localeKey in fieldData) {
264+
if (!config.localization.localeCodes.some((code) => code === localeKey)) {
265+
delete fieldData[localeKey]
266+
continue
267+
}
268+
269+
const localeData = fieldData[localeKey]
270+
271+
if (!localeData || typeof localeData !== 'object') {
272+
continue
273+
}
274+
275+
if (field.type === 'array' || field.type === 'blocks') {
276+
if (!Array.isArray(localeData)) {
277+
continue
278+
}
279+
280+
for (const data of localeData) {
281+
let fields: FlattenedField[] | null = null
282+
283+
if (field.type === 'array') {
284+
fields = field.flattenedFields
285+
} else {
286+
let maybeBlock: FlattenedBlock | undefined = undefined
287+
288+
if (field.blockReferences) {
289+
const maybeBlockReference = field.blockReferences.find(
290+
(each) => typeof each === 'object' && each.slug === data.blockType,
291+
)
292+
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
293+
maybeBlock = maybeBlockReference
294+
}
295+
}
296+
297+
if (!maybeBlock) {
298+
maybeBlock = field.blocks.find((each) => each.slug === data.blockType)
299+
}
300+
301+
if (maybeBlock) {
302+
fields = maybeBlock.flattenedFields
303+
}
304+
}
305+
306+
if (!fields) {
307+
continue
308+
}
309+
310+
stripFields({ config, data, fields, reservedKeys })
311+
}
312+
313+
continue
314+
} else {
315+
stripFields({ config, data: localeData, fields: field.flattenedFields, reservedKeys })
316+
}
317+
}
318+
continue
319+
}
320+
321+
if (field.type === 'array' || field.type === 'blocks') {
322+
if (!Array.isArray(fieldData)) {
323+
continue
324+
}
325+
326+
for (const data of fieldData) {
327+
let fields: FlattenedField[] | null = null
328+
329+
if (field.type === 'array') {
330+
fields = field.flattenedFields
331+
} else {
332+
let maybeBlock: FlattenedBlock | undefined = undefined
333+
334+
if (field.blockReferences) {
335+
const maybeBlockReference = field.blockReferences.find(
336+
(each) => typeof each === 'object' && each.slug === data.blockType,
337+
)
338+
339+
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
340+
maybeBlock = maybeBlockReference
341+
}
342+
}
343+
344+
if (!maybeBlock) {
345+
maybeBlock = field.blocks.find((each) => each.slug === data.blockType)
346+
}
347+
348+
if (maybeBlock) {
349+
fields = maybeBlock.flattenedFields
350+
}
351+
}
352+
353+
if (!fields) {
354+
continue
355+
}
356+
357+
stripFields({ config, data, fields, reservedKeys })
358+
}
359+
360+
continue
361+
} else {
362+
stripFields({ config, data: fieldData, fields: field.flattenedFields, reservedKeys })
363+
}
364+
}
365+
}
366+
}
367+
231368
export const transform = ({
232369
adapter,
233370
data,
@@ -256,6 +393,15 @@ export const transform = ({
256393
if (data.id instanceof Types.ObjectId) {
257394
data.id = data.id.toHexString()
258395
}
396+
397+
if (!adapter.allowAdditionalKeys) {
398+
stripFields({
399+
config,
400+
data,
401+
fields: flattenAllFields({ cache: true, fields }),
402+
reservedKeys: ['id', 'globalType'],
403+
})
404+
}
259405
}
260406

261407
if (operation === 'write' && globalSlug) {

packages/payload/src/utilities/flattenAllFields.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,23 @@ export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => {
1616
}
1717
}
1818

19-
export const flattenAllFields = ({ fields }: { fields: Field[] }): FlattenedField[] => {
19+
const flattenedFieldsCache = new Map<Field[], FlattenedField[]>()
20+
21+
export const flattenAllFields = ({
22+
cache,
23+
fields,
24+
}: {
25+
/** Allows you to get FlattenedField[] from Field[] anywhere without performance overhead by caching. */
26+
cache?: boolean
27+
fields: Field[]
28+
}): FlattenedField[] => {
29+
if (cache) {
30+
const maybeFields = flattenedFieldsCache.get(fields)
31+
if (maybeFields) {
32+
return maybeFields
33+
}
34+
}
35+
2036
const result: FlattenedField[] = []
2137

2238
for (const field of fields) {
@@ -97,5 +113,7 @@ export const flattenAllFields = ({ fields }: { fields: Field[] }): FlattenedFiel
97113
}
98114
}
99115

116+
flattenedFieldsCache.set(fields, result)
117+
100118
return result
101119
}

test/database/int.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,4 +1790,49 @@ describe('database', () => {
17901790
expect(query2.totalDocs).toEqual(1)
17911791
expect(query3.totalDocs).toEqual(1)
17921792
})
1793+
1794+
it('mongodb additional keys stripping', async () => {
1795+
// eslint-disable-next-line jest/no-conditional-in-test
1796+
if (payload.db.name !== 'mognoose') {
1797+
return
1798+
}
1799+
1800+
const arrItemID = randomUUID()
1801+
const res = await payload.db.collections[postsSlug]?.collection.insertOne({
1802+
SECRET_FIELD: 'secret data',
1803+
arrayWithIDs: [
1804+
{
1805+
id: arrItemID,
1806+
additionalKeyInArray: 'true',
1807+
text: 'existing key',
1808+
},
1809+
],
1810+
})
1811+
1812+
let payloadRes: any = await payload.findByID({
1813+
collection: postsSlug,
1814+
id: res!.insertedId.toHexString(),
1815+
})
1816+
1817+
expect(payloadRes.id).toBe(res!.insertedId.toHexString())
1818+
expect(payloadRes['SECRET_FIELD']).toBeUndefined()
1819+
expect(payloadRes.arrayWithIDs).toBeDefined()
1820+
expect(payloadRes.arrayWithIDs[0].id).toBe(arrItemID)
1821+
expect(payloadRes.arrayWithIDs[0].text).toBe('existing key')
1822+
expect(payloadRes.arrayWithIDs[0].additionalKeyInArray).toBeUndefined()
1823+
1824+
// But allows when allowAdditionaKeys is true
1825+
payload.db.allowAdditionalKeys = true
1826+
1827+
payloadRes = await payload.findByID({
1828+
collection: postsSlug,
1829+
id: res!.insertedId.toHexString(),
1830+
})
1831+
1832+
expect(payloadRes.id).toBe(res!.insertedId.toHexString())
1833+
expect(payloadRes['SECRET_FIELD']).toBe('secret data')
1834+
expect(payloadRes.arrayWithIDs[0].additionalKeyInArray).toBe('true')
1835+
1836+
payload.db.allowAdditionalKeys = false
1837+
})
17931838
})

0 commit comments

Comments
 (0)