Skip to content

Commit 3bf2b51

Browse files
authored
fix(framework, types, medusa): add disallowed query config to restrict retrieved fields (#16125)
1 parent 70c1227 commit 3bf2b51

17 files changed

Lines changed: 239 additions & 4 deletions

File tree

.changeset/brown-bugs-sort.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@medusajs/framework": patch
3+
"@medusajs/types": patch
4+
"@medusajs/medusa": patch
5+
---
6+
7+
fix(framework, types, medusa): add disallowed query config to restrict retrieved fields

packages/core/framework/src/http/utils/__tests__/get-query-config.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,4 +573,99 @@ describe("prepareListQuery", () => {
573573
})
574574
})
575575
})
576+
577+
describe("disallowed fields", () => {
578+
it("should strip a disallowed top-level relation and its entire subtree", async () => {
579+
const validated: RequestQueryFields = {
580+
fields: "id,orders.email,orders.customer.first_name",
581+
limit: 10,
582+
offset: 0,
583+
}
584+
585+
const queryConfig: QueryConfig<any> = {
586+
entity: "region",
587+
disallowed: ["orders", "customer"],
588+
isList: true,
589+
}
590+
591+
const result = await prepareListQuery(validated, queryConfig)
592+
593+
expect(result.remoteQueryConfig.fields).toEqual(["id"])
594+
})
595+
596+
it("should strip a disallowed segment appearing at any depth", async () => {
597+
const validated: RequestQueryFields = {
598+
fields: "id,variants.order_items.order.total",
599+
limit: 10,
600+
offset: 0,
601+
}
602+
603+
const queryConfig: QueryConfig<any> = {
604+
entity: "product",
605+
disallowed: ["order_items"],
606+
isList: true,
607+
}
608+
609+
const result = await prepareListQuery(validated, queryConfig)
610+
611+
expect(result.remoteQueryConfig.fields).toEqual(["id"])
612+
})
613+
614+
it("should strip disallowed star fields", async () => {
615+
const validated: RequestQueryFields = {
616+
fields: "id,*orders",
617+
limit: 10,
618+
offset: 0,
619+
}
620+
621+
const queryConfig: QueryConfig<any> = {
622+
entity: "region",
623+
disallowed: ["orders"],
624+
isList: true,
625+
}
626+
627+
const result = await prepareListQuery(validated, queryConfig)
628+
629+
expect(result.remoteQueryConfig.fields).toEqual(["id"])
630+
})
631+
632+
it("should keep allowed fields that only share a prefix with a disallowed segment", async () => {
633+
const validated: RequestQueryFields = {
634+
fields: "id,order_id,customer_id",
635+
limit: 10,
636+
offset: 0,
637+
}
638+
639+
const queryConfig: QueryConfig<any> = {
640+
entity: "order",
641+
disallowed: ["orders", "customer"],
642+
isList: true,
643+
}
644+
645+
const result = await prepareListQuery(validated, queryConfig)
646+
647+
expect(result.remoteQueryConfig.fields).toEqual([
648+
"id",
649+
"order_id",
650+
"customer_id",
651+
])
652+
})
653+
654+
it("should not remove anything when no disallowed fields are configured", async () => {
655+
const validated: RequestQueryFields = {
656+
fields: "id,orders.email",
657+
limit: 10,
658+
offset: 0,
659+
}
660+
661+
const queryConfig: QueryConfig<any> = {
662+
entity: "region",
663+
isList: true,
664+
}
665+
666+
const result = await prepareListQuery(validated, queryConfig)
667+
668+
expect(result.remoteQueryConfig.fields).toEqual(["id", "orders.email"])
669+
})
670+
})
576671
})

packages/core/framework/src/http/utils/field-filtering/field-validator.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,32 @@ export class RestrictedFieldFilter implements IFieldFilter {
8181
return notAllowedFields
8282
}
8383
}
84+
85+
/**
86+
* Filter that disallows specific fields as a hard security boundary.
87+
* Any requested field whose path contains a disallowed segment is returned as
88+
* not allowed (e.g. `orders` matches both `orders` and `orders.customer.email`).
89+
* Behaves like {@link RestrictedFieldFilter}, but is enforced independently of
90+
* any feature flag so it can be relied upon to keep sensitive relations off
91+
* unauthenticated endpoints.
92+
*/
93+
export class DisallowedFieldFilter implements IFieldFilter {
94+
private disallowed: string[]
95+
96+
constructor({ disallowed }: { disallowed: string[] }) {
97+
this.disallowed = disallowed
98+
}
99+
100+
getNotAllowedFields(context: FieldFilterContext): string[] {
101+
const { parsedFields } = context
102+
const { fields, starFields } = parsedFields
103+
const fieldsToCheck = [...fields, ...Array.from(starFields)]
104+
105+
return fieldsToCheck.filter((field) => {
106+
const fieldSegments = field.split(".")
107+
return this.disallowed.some((disallowedField) =>
108+
fieldSegments.includes(disallowedField)
109+
)
110+
})
111+
}
112+
}

packages/core/framework/src/http/utils/field-filtering/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,8 @@ export interface IFieldFilter {
3232
}
3333

3434
export { FieldParser } from "./field-parser"
35-
export { AllowedFieldFilter, RestrictedFieldFilter } from "./field-validator"
35+
export {
36+
AllowedFieldFilter,
37+
DisallowedFieldFilter,
38+
RestrictedFieldFilter,
39+
} from "./field-validator"

packages/core/framework/src/http/utils/get-query-config.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { AuthContext, MedusaRequest } from "../types"
1414
import {
1515
AllowedFieldFilter,
16+
DisallowedFieldFilter,
1617
FieldParser,
1718
IFieldFilter,
1819
RestrictedFieldFilter,
@@ -43,9 +44,10 @@ export async function prepareListQuery<T extends RequestQueryFields, TEntity>(
4344
auth_context?: AuthContext
4445
}
4546
) {
46-
let {
47+
const {
4748
allowed = [],
4849
restricted = [],
50+
disallowed = [],
4951
defaults = [],
5052
defaultLimit = 50,
5153
isList,
@@ -97,12 +99,26 @@ export async function prepareListQuery<T extends RequestQueryFields, TEntity>(
9799
})
98100
}
99101

102+
// Disallowed fields are a hard security boundary and are always stripped,
103+
// independent of any feature flag, so sensitive relations (order, customer,
104+
// payment, ...) can never be resolved on the configured endpoint.
105+
if (disallowed.length) {
106+
const disallowedFields = new DisallowedFieldFilter({
107+
disallowed,
108+
}).getNotAllowedFields({ entity: entity as string, parsedFields })
109+
110+
disallowedFields.forEach((field) => {
111+
allFields.delete(field)
112+
starFields.delete(field)
113+
})
114+
}
115+
100116
// TODO: maintain backward compatibility, remove in the future
101117
const { select, relations } = stringToSelectRelationObject(
102118
Array.from(allFields)
103119
)
104120

105-
let allRelations = new Set([...relations, ...Array.from(starFields)])
121+
const allRelations = new Set([...relations, ...Array.from(starFields)])
106122

107123
// End of expand compatibility
108124

@@ -135,7 +151,7 @@ export async function prepareListQuery<T extends RequestQueryFields, TEntity>(
135151
withDeleted: with_deleted,
136152
},
137153
remoteQueryConfig: {
138-
...(!!entity ? { entity } : {}),
154+
...(entity ? { entity } : {}),
139155
// Add starFields that are relations only on which we want all properties with a dedicated format to the remote query
140156
fields: [
141157
...Array.from(allFields),

packages/core/types/src/common/common.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,16 @@ export type QueryConfig<TEntity> = {
459459
* the authorization search.
460460
*/
461461
allowed?: string[]
462+
/**
463+
* Fields and relations that must never be resolved, regardless of what the
464+
* caller requests. Any requested field whose path contains one of these
465+
* segments (e.g. `orders` matches `orders.customer.email`) is stripped before
466+
* the query is executed. Unlike `allowed`, this is a hard security boundary
467+
* that is always enforced, independent of any feature flag. Use it to keep
468+
* sensitive relations (order, customer, payment, ...) off unauthenticated
469+
* endpoints.
470+
*/
471+
disallowed?: string[]
462472
defaultLimit?: number
463473
/**
464474
* If the route that will use that configuration is supposed to return a list of entities. This

packages/medusa/src/api/store/carts/query-config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { disallowedStorePivotFields } from "../utils/disallowed-fields"
2+
13
// TODO: Global todo, review all default fields to prevent over fetching by default
24
export const defaultStoreCartFields = [
35
"id",
@@ -139,5 +141,9 @@ export const defaultStoreCartFields = [
139141

140142
export const retrieveTransformQueryConfig = {
141143
defaults: defaultStoreCartFields,
144+
// The cart legitimately exposes the caller's own customer/address/payment
145+
// data during checkout, but must not be usable to pivot into other
146+
// resources' order or cart data (e.g. region.orders, customer.orders).
147+
disallowed: disallowedStorePivotFields,
142148
isList: false,
143149
}

packages/medusa/src/api/store/collections/query-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { disallowedStoreFields } from "../utils/disallowed-fields"
2+
13
export const defaultStoreCollectionFields = [
24
"id",
35
"title",
@@ -9,6 +11,7 @@ export const defaultStoreCollectionFields = [
911

1012
export const retrieveTransformQueryConfig = {
1113
defaults: defaultStoreCollectionFields,
14+
disallowed: disallowedStoreFields,
1215
isList: false,
1316
}
1417

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { disallowedStoreFields } from "../utils/disallowed-fields"
2+
13
export const defaultAdminPaymentProviderFields = ["id", "is_enabled"]
24

35
export const listTransformPaymentProvidersQueryConfig = {
46
defaults: defaultAdminPaymentProviderFields,
7+
disallowed: disallowedStoreFields,
58
isList: true,
69
}

packages/medusa/src/api/store/product-categories/query-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { disallowedStoreFields } from "../utils/disallowed-fields"
2+
13
export const defaults = [
24
"id",
35
"name",
@@ -15,11 +17,13 @@ export const defaults = [
1517

1618
export const retrieveProductCategoryConfig = {
1719
defaults,
20+
disallowed: disallowedStoreFields,
1821
isList: false,
1922
}
2023

2124
export const listProductCategoryConfig = {
2225
defaults,
26+
disallowed: disallowedStoreFields,
2327
defaultLimit: 50,
2428
isList: true,
2529
}

0 commit comments

Comments
 (0)