Feat/validate query enhancement (#9705)
* feat(framework): Enhance query validation * feat(framework): Enhance query validation * feat(framework): Enhance query validation * feat(framework): Enhance query validation * fix * split restriction per http domain * fix * fix unit tests * fix middleware * cleanup allowed fields * update docs * missing allowed * export * missing allowed * missing fields * improvements * rm unnecessary fields * wip * update symbol support * update symbol support * update allowed * update allowed
This commit is contained in:
@@ -730,6 +730,27 @@ export type ProjectConfigOptions = {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
authMethodsPerActor?: Record<string, string[]>
|
authMethodsPerActor?: Record<string, string[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the fields that can't be selected in the response unless specified in the allowed query config.
|
||||||
|
* This is useful to restrict sensitive fields from being exposed in the API.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```js title="medusa-config.js"
|
||||||
|
* module.exports = defineConfig({
|
||||||
|
* projectConfig: {
|
||||||
|
* http: {
|
||||||
|
* restrictedFields: {
|
||||||
|
* store: ["order", "orders"],
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
restrictedFields?: {
|
||||||
|
store?: string[]
|
||||||
|
/*admin?: string[]*/
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import z from "zod"
|
|||||||
import { MedusaError } from "@medusajs/utils"
|
import { MedusaError } from "@medusajs/utils"
|
||||||
import { validateAndTransformQuery } from "../utils/validate-query"
|
import { validateAndTransformQuery } from "../utils/validate-query"
|
||||||
import { MedusaNextFunction, MedusaRequest, MedusaResponse } from "../types"
|
import { MedusaNextFunction, MedusaRequest, MedusaResponse } from "../types"
|
||||||
|
import { RestrictedFields } from "../utils/restricted-fields"
|
||||||
|
import { QueryConfig } from "@medusajs/types"
|
||||||
|
|
||||||
export const createSelectParams = () => {
|
export const createSelectParams = () => {
|
||||||
return z.object({
|
return z.object({
|
||||||
@@ -60,6 +62,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
|
|
||||||
it("should transform the input query", async () => {
|
it("should transform the input query", async () => {
|
||||||
let mockRequest = {
|
let mockRequest = {
|
||||||
|
restrictedFields: new RestrictedFields(),
|
||||||
query: {},
|
query: {},
|
||||||
} as MedusaRequest
|
} as MedusaRequest
|
||||||
const mockResponse = {} as MedusaResponse
|
const mockResponse = {} as MedusaResponse
|
||||||
@@ -148,6 +151,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
limit: "10",
|
limit: "10",
|
||||||
offset: "5",
|
offset: "5",
|
||||||
@@ -167,6 +171,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
limit: "10",
|
limit: "10",
|
||||||
offset: "5",
|
offset: "5",
|
||||||
@@ -202,6 +207,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
|
|
||||||
it("should transform the input query taking into account the fields symbols (+,- or no symbol)", async () => {
|
it("should transform the input query taking into account the fields symbols (+,- or no symbol)", async () => {
|
||||||
let mockRequest = {
|
let mockRequest = {
|
||||||
|
restrictedFields: new RestrictedFields(),
|
||||||
query: {
|
query: {
|
||||||
fields: "id",
|
fields: "id",
|
||||||
},
|
},
|
||||||
@@ -234,6 +240,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "+test_prop,-prop-test-something",
|
fields: "+test_prop,-prop-test-something",
|
||||||
},
|
},
|
||||||
@@ -275,6 +282,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "+test_prop,-updated_at",
|
fields: "+test_prop,-updated_at",
|
||||||
},
|
},
|
||||||
@@ -315,11 +323,16 @@ describe("validateAndTransformQuery", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it(`should transform the input and manage the allowed fields and relations properly without error`, async () => {
|
it(`should transform the input and manage the allowed fields and relations properly without error`, async () => {
|
||||||
|
const restrictedFields = new RestrictedFields()
|
||||||
let mockRequest = {
|
let mockRequest = {
|
||||||
|
restrictedFields,
|
||||||
query: {
|
query: {
|
||||||
fields: "*product.variants,+product.id",
|
fields: "product.*, *product.variants,+product.id",
|
||||||
},
|
},
|
||||||
} as unknown as MedusaRequest
|
} as unknown as MedusaRequest
|
||||||
|
|
||||||
|
restrictedFields.add(["product"])
|
||||||
|
|
||||||
const mockResponse = {} as MedusaResponse
|
const mockResponse = {} as MedusaResponse
|
||||||
const nextFunction: MedusaNextFunction = jest.fn()
|
const nextFunction: MedusaNextFunction = jest.fn()
|
||||||
|
|
||||||
@@ -385,12 +398,14 @@ describe("validateAndTransformQuery", () => {
|
|||||||
"metadata.children.id",
|
"metadata.children.id",
|
||||||
"metadata.product.id",
|
"metadata.product.id",
|
||||||
"product.id",
|
"product.id",
|
||||||
|
"product.*",
|
||||||
"product.variants.*",
|
"product.variants.*",
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "store.name",
|
fields: "store.name",
|
||||||
},
|
},
|
||||||
@@ -441,6 +456,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
|
|
||||||
it("should throw when attempting to transform the input if disallowed fields are requested", async () => {
|
it("should throw when attempting to transform the input if disallowed fields are requested", async () => {
|
||||||
let mockRequest = {
|
let mockRequest = {
|
||||||
|
restrictedFields: new RestrictedFields(),
|
||||||
query: {
|
query: {
|
||||||
fields: "+test_prop",
|
fields: "+test_prop",
|
||||||
},
|
},
|
||||||
@@ -459,12 +475,6 @@ describe("validateAndTransformQuery", () => {
|
|||||||
"metadata.children.id",
|
"metadata.children.id",
|
||||||
"metadata.product.id",
|
"metadata.product.id",
|
||||||
],
|
],
|
||||||
defaultRelations: [
|
|
||||||
"metadata",
|
|
||||||
"metadata.parent",
|
|
||||||
"metadata.children",
|
|
||||||
"metadata.product",
|
|
||||||
],
|
|
||||||
allowed: [
|
allowed: [
|
||||||
"id",
|
"id",
|
||||||
"created_at",
|
"created_at",
|
||||||
@@ -490,6 +500,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "product",
|
fields: "product",
|
||||||
},
|
},
|
||||||
@@ -506,12 +517,6 @@ describe("validateAndTransformQuery", () => {
|
|||||||
"metadata.children.id",
|
"metadata.children.id",
|
||||||
"metadata.product.id",
|
"metadata.product.id",
|
||||||
],
|
],
|
||||||
defaultRelations: [
|
|
||||||
"metadata",
|
|
||||||
"metadata.parent",
|
|
||||||
"metadata.children",
|
|
||||||
"metadata.product",
|
|
||||||
],
|
|
||||||
allowed: [
|
allowed: [
|
||||||
"id",
|
"id",
|
||||||
"created_at",
|
"created_at",
|
||||||
@@ -537,6 +542,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "store",
|
fields: "store",
|
||||||
},
|
},
|
||||||
@@ -580,6 +586,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "*product",
|
fields: "*product",
|
||||||
},
|
},
|
||||||
@@ -621,6 +628,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "*product.variants",
|
fields: "*product.variants",
|
||||||
},
|
},
|
||||||
@@ -663,6 +671,7 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
|
...mockRequest,
|
||||||
query: {
|
query: {
|
||||||
fields: "*product",
|
fields: "*product",
|
||||||
},
|
},
|
||||||
@@ -704,4 +713,47 @@ describe("validateAndTransformQuery", () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should throw when attempting to transform the input if restricted fields are requested", async () => {
|
||||||
|
const restrictedFields = new RestrictedFields()
|
||||||
|
let mockRequest = {
|
||||||
|
restrictedFields,
|
||||||
|
query: {
|
||||||
|
fields: "*product",
|
||||||
|
},
|
||||||
|
} as unknown as MedusaRequest
|
||||||
|
|
||||||
|
restrictedFields.add(["product"])
|
||||||
|
|
||||||
|
const mockResponse = {} as MedusaResponse
|
||||||
|
const nextFunction: MedusaNextFunction = jest.fn()
|
||||||
|
|
||||||
|
const queryConfig: QueryConfig<any> = {
|
||||||
|
defaults: [
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
"metadata.id",
|
||||||
|
"metadata.parent.id",
|
||||||
|
"metadata.children.id",
|
||||||
|
"metadata.product.id",
|
||||||
|
],
|
||||||
|
isList: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const middleware = validateAndTransformQuery(
|
||||||
|
createFindParams(),
|
||||||
|
queryConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
await middleware(mockRequest, mockResponse, nextFunction)
|
||||||
|
|
||||||
|
expect(nextFunction).toHaveBeenLastCalledWith(
|
||||||
|
new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`Requested fields [metadata.product.id, product] are not valid`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export * from "./utils/define-middlewares"
|
|||||||
export * from "./utils/maybe-apply-link-filter"
|
export * from "./utils/maybe-apply-link-filter"
|
||||||
export * from "./utils/refetch-entities"
|
export * from "./utils/refetch-entities"
|
||||||
export * from "./utils/unless-path"
|
export * from "./utils/unless-path"
|
||||||
|
export * from "./utils/restricted-fields"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ensurePublishableApiKeyMiddleware } from "./middlewares/ensure-publisha
|
|||||||
import {
|
import {
|
||||||
GlobalMiddlewareDescriptor,
|
GlobalMiddlewareDescriptor,
|
||||||
HTTP_METHODS,
|
HTTP_METHODS,
|
||||||
|
MedusaNextFunction,
|
||||||
MedusaRequest,
|
MedusaRequest,
|
||||||
MedusaResponse,
|
MedusaResponse,
|
||||||
MiddlewareFunction,
|
MiddlewareFunction,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
RouteHandler,
|
RouteHandler,
|
||||||
RouteVerb,
|
RouteVerb,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
import { RestrictedFields } from "./utils/restricted-fields"
|
||||||
|
|
||||||
const log = ({
|
const log = ({
|
||||||
activityId,
|
activityId,
|
||||||
@@ -244,7 +246,7 @@ export class ApiRoutesLoader {
|
|||||||
static traceMiddleware?: (
|
static traceMiddleware?: (
|
||||||
handler: RequestHandler | MiddlewareFunction,
|
handler: RequestHandler | MiddlewareFunction,
|
||||||
route: { route: string; method?: string }
|
route: { route: string; method?: string }
|
||||||
) => RequestHandler
|
) => RequestHandler | MiddlewareFunction
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
app,
|
app,
|
||||||
@@ -589,14 +591,15 @@ export class ApiRoutesLoader {
|
|||||||
* Applies middleware that checks if a valid publishable key is set on store request
|
* Applies middleware that checks if a valid publishable key is set on store request
|
||||||
*/
|
*/
|
||||||
applyStorePublishableKeyMiddleware(route: string) {
|
applyStorePublishableKeyMiddleware(route: string) {
|
||||||
let middleware =
|
let middleware = ensurePublishableApiKeyMiddleware as unknown as
|
||||||
ensurePublishableApiKeyMiddleware as unknown as RequestHandler
|
| RequestHandler
|
||||||
|
| MiddlewareFunction
|
||||||
|
|
||||||
if (ApiRoutesLoader.traceMiddleware) {
|
if (ApiRoutesLoader.traceMiddleware) {
|
||||||
middleware = ApiRoutesLoader.traceMiddleware(middleware, { route: route })
|
middleware = ApiRoutesLoader.traceMiddleware(middleware, { route: route })
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#router.use(route, middleware)
|
this.#router.use(route, middleware as RequestHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -609,7 +612,9 @@ export class ApiRoutesLoader {
|
|||||||
authType: AuthType | AuthType[],
|
authType: AuthType | AuthType[],
|
||||||
options?: { allowUnauthenticated?: boolean; allowUnregistered?: boolean }
|
options?: { allowUnauthenticated?: boolean; allowUnregistered?: boolean }
|
||||||
) {
|
) {
|
||||||
let authenticateMiddleware = authenticate(actorType, authType, options)
|
let authenticateMiddleware: RequestHandler | MiddlewareFunction =
|
||||||
|
authenticate(actorType, authType, options)
|
||||||
|
|
||||||
if (ApiRoutesLoader.traceMiddleware) {
|
if (ApiRoutesLoader.traceMiddleware) {
|
||||||
authenticateMiddleware = ApiRoutesLoader.traceMiddleware(
|
authenticateMiddleware = ApiRoutesLoader.traceMiddleware(
|
||||||
authenticateMiddleware,
|
authenticateMiddleware,
|
||||||
@@ -617,7 +622,7 @@ export class ApiRoutesLoader {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#router.use(route, authenticateMiddleware)
|
this.#router.use(route, authenticateMiddleware as RequestHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -933,14 +938,39 @@ export class RoutesLoader {
|
|||||||
app,
|
app,
|
||||||
activityId,
|
activityId,
|
||||||
sourceDir,
|
sourceDir,
|
||||||
|
baseRestrictedFields = [],
|
||||||
}: {
|
}: {
|
||||||
app: Express
|
app: Express
|
||||||
activityId?: string
|
activityId?: string
|
||||||
sourceDir: string | string[]
|
sourceDir: string | string[]
|
||||||
|
baseRestrictedFields?: string[]
|
||||||
}) {
|
}) {
|
||||||
this.#app = app
|
this.#app = app
|
||||||
this.#activityId = activityId
|
this.#activityId = activityId
|
||||||
this.#sourceDir = sourceDir
|
this.#sourceDir = sourceDir
|
||||||
|
|
||||||
|
this.#assignRestrictedFields(baseRestrictedFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
#assignRestrictedFields(baseRestrictedFields: string[]) {
|
||||||
|
this.#app.use("/store", ((
|
||||||
|
req: MedusaRequest,
|
||||||
|
_: MedusaResponse,
|
||||||
|
next: MedusaNextFunction
|
||||||
|
) => {
|
||||||
|
req.restrictedFields = new RestrictedFields()
|
||||||
|
req.restrictedFields.add(baseRestrictedFields)
|
||||||
|
next()
|
||||||
|
}) as unknown as RequestHandler)
|
||||||
|
|
||||||
|
this.#app.use("/admin", ((
|
||||||
|
req: MedusaRequest,
|
||||||
|
_: MedusaResponse,
|
||||||
|
next: MedusaNextFunction
|
||||||
|
) => {
|
||||||
|
req.restrictedFields = new RestrictedFields()
|
||||||
|
next()
|
||||||
|
}) as unknown as RequestHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RequestQueryFields,
|
RequestQueryFields,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import { MedusaContainer } from "../container"
|
import { MedusaContainer } from "../container"
|
||||||
|
import { RestrictedFields } from "./utils/restricted-fields"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all the supported HTTP methods
|
* List of all the supported HTTP methods
|
||||||
@@ -135,6 +136,9 @@ export interface MedusaRequest<Body = unknown>
|
|||||||
session?: any
|
session?: any
|
||||||
rawBody?: any
|
rawBody?: any
|
||||||
requestId?: string
|
requestId?: string
|
||||||
|
|
||||||
|
restrictedFields?: RestrictedFields
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An object that carries the context that is used to calculate prices for variants
|
* An object that carries the context that is used to calculate prices for variants
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pick } from "lodash"
|
import { pick } from "lodash"
|
||||||
import { RequestQueryFields, FindConfig, QueryConfig } from "@medusajs/types"
|
import { FindConfig, QueryConfig, RequestQueryFields } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
isDefined,
|
isDefined,
|
||||||
isPresent,
|
isPresent,
|
||||||
@@ -23,11 +23,83 @@ export function pickByConfig<TModel>(
|
|||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkRestrictedFields({
|
||||||
|
fields,
|
||||||
|
restricted,
|
||||||
|
}: {
|
||||||
|
fields: string[]
|
||||||
|
restricted: string[]
|
||||||
|
}): string[] {
|
||||||
|
const notAllowedFields: string[] = []
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const fieldSegments = field.split(".")
|
||||||
|
const hasRestrictedField = restricted.some((restrictedField) =>
|
||||||
|
fieldSegments.includes(restrictedField)
|
||||||
|
)
|
||||||
|
if (hasRestrictedField) {
|
||||||
|
notAllowedFields.push(field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
})
|
||||||
|
|
||||||
|
return notAllowedFields
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAllowedFields({
|
||||||
|
fields,
|
||||||
|
allowed,
|
||||||
|
starFields,
|
||||||
|
}: {
|
||||||
|
fields: string[]
|
||||||
|
starFields: Set<string>
|
||||||
|
allowed: string[]
|
||||||
|
}): string[] {
|
||||||
|
const notAllowedFields: string[] = []
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const hasAllowedField = allowed.includes(field)
|
||||||
|
|
||||||
|
if (hasAllowedField) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select full relation in that case it must match an allowed field fully
|
||||||
|
// e.g product.variants in that case we must have a product.variants in the allowedFields
|
||||||
|
if (starFields.has(field)) {
|
||||||
|
if (hasAllowedField) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notAllowedFields.push(field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldStartsWithAllowedField = allowed.some((allowedField) =>
|
||||||
|
field.startsWith(allowedField)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!fieldStartsWithAllowedField) {
|
||||||
|
notAllowedFields.push(field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return notAllowedFields
|
||||||
|
}
|
||||||
|
|
||||||
export function prepareListQuery<T extends RequestQueryFields, TEntity>(
|
export function prepareListQuery<T extends RequestQueryFields, TEntity>(
|
||||||
validated: T,
|
validated: T,
|
||||||
queryConfig: QueryConfig<TEntity> = {}
|
queryConfig: QueryConfig<TEntity> & { restricted?: string[] } = {}
|
||||||
) {
|
) {
|
||||||
let { allowed = [], defaults = [], defaultLimit = 50, isList } = queryConfig
|
let {
|
||||||
|
allowed = [],
|
||||||
|
restricted = [],
|
||||||
|
defaults = [],
|
||||||
|
defaultLimit = 50,
|
||||||
|
isList,
|
||||||
|
} = queryConfig
|
||||||
const { order, fields, limit = defaultLimit, offset = 0 } = validated
|
const { order, fields, limit = defaultLimit, offset = 0 } = validated
|
||||||
|
|
||||||
// e.g *product.variants meaning that we want all fields from the product.variants
|
// e.g *product.variants meaning that we want all fields from the product.variants
|
||||||
@@ -46,7 +118,8 @@ export function prepareListQuery<T extends RequestQueryFields, TEntity>(
|
|||||||
field.startsWith("-") ||
|
field.startsWith("-") ||
|
||||||
field.startsWith("+") ||
|
field.startsWith("+") ||
|
||||||
field.startsWith(" ") ||
|
field.startsWith(" ") ||
|
||||||
field.startsWith("*")
|
field.startsWith("*") ||
|
||||||
|
field.endsWith(".*")
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,44 +141,32 @@ export function prepareListQuery<T extends RequestQueryFields, TEntity>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
allFields.forEach((field) => {
|
allFields.forEach((field) => {
|
||||||
if (field.startsWith("*")) {
|
if (field.startsWith("*") || field.endsWith(".*")) {
|
||||||
starFields.add(field.replace(/^\*/, ""))
|
starFields.add(field.replace(/(^\*|\.\*$)/, ""))
|
||||||
allFields.delete(field)
|
allFields.delete(field)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const notAllowedFields: string[] = []
|
let notAllowedFields: string[] = []
|
||||||
|
|
||||||
if (allowed.length) {
|
if (allowed.length || restricted.length) {
|
||||||
;[...allFields, ...Array.from(starFields)].forEach((field) => {
|
const fieldsToCheck = [...allFields, ...Array.from(starFields)]
|
||||||
const hasAllowedField = allowed.includes(field)
|
|
||||||
|
|
||||||
if (hasAllowedField) {
|
if (allowed.length) {
|
||||||
return
|
notAllowedFields = checkAllowedFields({
|
||||||
}
|
fields: fieldsToCheck,
|
||||||
|
starFields,
|
||||||
// Select full relation in that case it must match an allowed field fully
|
allowed,
|
||||||
// e.g product.variants in that case we must have a product.variants in the allowedFields
|
})
|
||||||
if (starFields.has(field)) {
|
} else if (restricted.length) {
|
||||||
if (hasAllowedField) {
|
notAllowedFields = checkRestrictedFields({
|
||||||
return
|
fields: fieldsToCheck,
|
||||||
}
|
restricted,
|
||||||
notAllowedFields.push(field)
|
})
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const fieldStartsWithAllowedField = allowed.some((allowedField) =>
|
|
||||||
field.startsWith(allowedField)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!fieldStartsWithAllowedField) {
|
|
||||||
notAllowedFields.push(field)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allFields.size && notAllowedFields.length) {
|
if (notAllowedFields.length) {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_DATA,
|
MedusaError.Types.INVALID_DATA,
|
||||||
`Requested fields [${Array.from(notAllowedFields).join(
|
`Requested fields [${Array.from(notAllowedFields).join(
|
||||||
@@ -170,7 +231,7 @@ export function prepareListQuery<T extends RequestQueryFields, TEntity>(
|
|||||||
|
|
||||||
export function prepareRetrieveQuery<T extends RequestQueryFields, TEntity>(
|
export function prepareRetrieveQuery<T extends RequestQueryFields, TEntity>(
|
||||||
validated: T,
|
validated: T,
|
||||||
queryConfig?: QueryConfig<TEntity>
|
queryConfig?: QueryConfig<TEntity> & { restricted?: string[] }
|
||||||
) {
|
) {
|
||||||
const { listConfig, remoteQueryConfig } = prepareListQuery(
|
const { listConfig, remoteQueryConfig } = prepareListQuery(
|
||||||
validated,
|
validated,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export class RestrictedFields {
|
||||||
|
/**
|
||||||
|
* Fields that are restricted from being selected in the response.
|
||||||
|
* Those fields can be allowed if specified in the allowed configuration of the query config of an end point.
|
||||||
|
*
|
||||||
|
* @type {string[]}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#restrictedFields: Set<string> = new Set()
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return Array.from(this.#restrictedFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(fields: string[]) {
|
||||||
|
fields.map((field) => this.#restrictedFields.add(field))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,14 +68,25 @@ export function validateAndTransformQuery<TEntity extends BaseEntity>(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const allowed = (req.allowed ?? queryConfig.allowed ?? []) as string[]
|
const restricted = req.restrictedFields?.list()
|
||||||
|
const allowed = queryConfig.allowed ?? []
|
||||||
|
|
||||||
|
// If any custom allowed fields are set, we add them to the allowed list along side the one configured in the query config if any
|
||||||
|
if (req.allowed?.length) {
|
||||||
|
allowed.push(...req.allowed)
|
||||||
|
}
|
||||||
|
|
||||||
delete req.allowed
|
delete req.allowed
|
||||||
const query = normalizeQuery(req)
|
const query = normalizeQuery(req)
|
||||||
|
|
||||||
const validated = await zodValidator(zodSchema, query)
|
const validated = await zodValidator(zodSchema, query)
|
||||||
const cnf = queryConfig.isList
|
const cnf = queryConfig.isList
|
||||||
? prepareListQuery(validated, { ...queryConfig, allowed })
|
? prepareListQuery(validated, { ...queryConfig, allowed, restricted })
|
||||||
: prepareRetrieveQuery(validated, { ...queryConfig, allowed })
|
: prepareRetrieveQuery(validated, {
|
||||||
|
...queryConfig,
|
||||||
|
allowed,
|
||||||
|
restricted,
|
||||||
|
})
|
||||||
|
|
||||||
req.validatedQuery = validated
|
req.validatedQuery = validated
|
||||||
req.filterableFields = getFilterableFields(req.validatedQuery)
|
req.filterableFields = getFilterableFields(req.validatedQuery)
|
||||||
|
|||||||
@@ -300,14 +300,22 @@ export type RawRounding = {
|
|||||||
*/
|
*/
|
||||||
export type QueryConfig<TEntity> = {
|
export type QueryConfig<TEntity> = {
|
||||||
/**
|
/**
|
||||||
* Default fields and relations to return
|
* Default fields and relations to return.
|
||||||
|
* use `*` or `.*` to select all fields from a relations (e.g '*products' or 'products.*' will select all products properties)
|
||||||
*/
|
*/
|
||||||
defaults?: (keyof TEntity | string)[]
|
defaults?: (keyof TEntity | string)[]
|
||||||
/**
|
/**
|
||||||
* Fields and relations that are allowed to be requested
|
* Fields and relations that are allowed to be requested.
|
||||||
|
* Symbol such as `*`, `+` and `-` should be removed as they dont make sense for
|
||||||
|
* the authorization search.
|
||||||
*/
|
*/
|
||||||
allowed?: string[]
|
allowed?: string[]
|
||||||
defaultLimit?: number
|
defaultLimit?: number
|
||||||
|
/**
|
||||||
|
* If the route that will use that configuration is supposed to return a list of entities. This
|
||||||
|
* will change the configuration that will be created on req.listConfig and req.remoteQueryConfig (among
|
||||||
|
* other things it will include pagination and sorting)
|
||||||
|
*/
|
||||||
isList?: boolean
|
isList?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -724,6 +724,27 @@ export type ProjectConfigOptions = {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
authMethodsPerActor?: Record<string, string[]>
|
authMethodsPerActor?: Record<string, string[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the fields that can't be selected in the response unless specified in the allowed query config.
|
||||||
|
* This is useful to restrict sensitive fields from being exposed in the API.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```js title="medusa-config.js"
|
||||||
|
* module.exports = defineConfig({
|
||||||
|
* projectConfig: {
|
||||||
|
* http: {
|
||||||
|
* restrictedFields: {
|
||||||
|
* store: ["order", "orders"],
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
restrictedFields?: {
|
||||||
|
store?: string[]
|
||||||
|
/*admin?: string[]*/
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Modules } from "../../modules-sdk"
|
import { Modules } from "../../modules-sdk"
|
||||||
import { defineConfig } from "../define-config"
|
import { DEFAULT_STORE_RESTRICTED_FIELDS, defineConfig } from "../define-config"
|
||||||
|
|
||||||
describe("defineConfig", function () {
|
describe("defineConfig", function () {
|
||||||
it("should merge empty config with the defaults", function () {
|
it("should merge empty config with the defaults", function () {
|
||||||
@@ -133,6 +133,13 @@ describe("defineConfig", function () {
|
|||||||
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
||||||
"cookieSecret": "supersecret",
|
"cookieSecret": "supersecret",
|
||||||
"jwtSecret": "supersecret",
|
"jwtSecret": "supersecret",
|
||||||
|
"restrictedFields": {
|
||||||
|
"store": [
|
||||||
|
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
|
||||||
|
",\n "
|
||||||
|
)},
|
||||||
|
],
|
||||||
|
},
|
||||||
"storeCors": "http://localhost:8000",
|
"storeCors": "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -282,6 +289,13 @@ describe("defineConfig", function () {
|
|||||||
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
||||||
"cookieSecret": "supersecret",
|
"cookieSecret": "supersecret",
|
||||||
"jwtSecret": "supersecret",
|
"jwtSecret": "supersecret",
|
||||||
|
"restrictedFields": {
|
||||||
|
"store": [
|
||||||
|
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
|
||||||
|
",\n "
|
||||||
|
)},
|
||||||
|
],
|
||||||
|
},
|
||||||
"storeCors": "http://localhost:8000",
|
"storeCors": "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -439,6 +453,13 @@ describe("defineConfig", function () {
|
|||||||
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
||||||
"cookieSecret": "supersecret",
|
"cookieSecret": "supersecret",
|
||||||
"jwtSecret": "supersecret",
|
"jwtSecret": "supersecret",
|
||||||
|
"restrictedFields": {
|
||||||
|
"store": [
|
||||||
|
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
|
||||||
|
",\n "
|
||||||
|
)},
|
||||||
|
],
|
||||||
|
},
|
||||||
"storeCors": "http://localhost:8000",
|
"storeCors": "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -597,6 +618,13 @@ describe("defineConfig", function () {
|
|||||||
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
||||||
"cookieSecret": "supersecret",
|
"cookieSecret": "supersecret",
|
||||||
"jwtSecret": "supersecret",
|
"jwtSecret": "supersecret",
|
||||||
|
"restrictedFields": {
|
||||||
|
"store": [
|
||||||
|
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
|
||||||
|
",\n "
|
||||||
|
)},
|
||||||
|
],
|
||||||
|
},
|
||||||
"storeCors": "http://localhost:8000",
|
"storeCors": "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -743,6 +771,13 @@ describe("defineConfig", function () {
|
|||||||
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
||||||
"cookieSecret": "supersecret",
|
"cookieSecret": "supersecret",
|
||||||
"jwtSecret": "supersecret",
|
"jwtSecret": "supersecret",
|
||||||
|
"restrictedFields": {
|
||||||
|
"store": [
|
||||||
|
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
|
||||||
|
",\n "
|
||||||
|
)},
|
||||||
|
],
|
||||||
|
},
|
||||||
"storeCors": "http://localhost:8000",
|
"storeCors": "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -889,6 +924,13 @@ describe("defineConfig", function () {
|
|||||||
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
|
||||||
"cookieSecret": "supersecret",
|
"cookieSecret": "supersecret",
|
||||||
"jwtSecret": "supersecret",
|
"jwtSecret": "supersecret",
|
||||||
|
"restrictedFields": {
|
||||||
|
"store": [
|
||||||
|
${DEFAULT_STORE_RESTRICTED_FIELDS.map((v) => `"${v}"`).join(
|
||||||
|
",\n "
|
||||||
|
)},
|
||||||
|
],
|
||||||
|
},
|
||||||
"storeCors": "http://localhost:8000",
|
"storeCors": "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ const DEFAULT_DATABASE_URL = "postgres://localhost/medusa-starter-default"
|
|||||||
const DEFAULT_ADMIN_CORS =
|
const DEFAULT_ADMIN_CORS =
|
||||||
"http://localhost:7000,http://localhost:7001,http://localhost:5173"
|
"http://localhost:7000,http://localhost:7001,http://localhost:5173"
|
||||||
|
|
||||||
|
export const DEFAULT_STORE_RESTRICTED_FIELDS = [
|
||||||
|
"order",
|
||||||
|
"orders",
|
||||||
|
/*"customer",
|
||||||
|
"customers",
|
||||||
|
"payment_collection",
|
||||||
|
"payment_collections"*/
|
||||||
|
]
|
||||||
|
|
||||||
type InternalModuleDeclarationOverride = InternalModuleDeclaration & {
|
type InternalModuleDeclarationOverride = InternalModuleDeclaration & {
|
||||||
/**
|
/**
|
||||||
* Optional key to be used to identify the module, if not provided, it will be inferred from the module joiner config service name.
|
* Optional key to be used to identify the module, if not provided, it will be inferred from the module joiner config service name.
|
||||||
@@ -79,6 +88,9 @@ export function defineConfig(config: Config = {}): ConfigModule {
|
|||||||
authCors: process.env.AUTH_CORS || DEFAULT_ADMIN_CORS,
|
authCors: process.env.AUTH_CORS || DEFAULT_ADMIN_CORS,
|
||||||
jwtSecret: process.env.JWT_SECRET || DEFAULT_SECRET,
|
jwtSecret: process.env.JWT_SECRET || DEFAULT_SECRET,
|
||||||
cookieSecret: process.env.COOKIE_SECRET || DEFAULT_SECRET,
|
cookieSecret: process.env.COOKIE_SECRET || DEFAULT_SECRET,
|
||||||
|
restrictedFields: {
|
||||||
|
store: DEFAULT_STORE_RESTRICTED_FIELDS,
|
||||||
|
},
|
||||||
...http,
|
...http,
|
||||||
},
|
},
|
||||||
...restOfProjectConfig,
|
...restOfProjectConfig,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// TODO: Global todo, review all default fields to prevent over fetching by default
|
||||||
export const defaultStoreCartFields = [
|
export const defaultStoreCartFields = [
|
||||||
"id",
|
"id",
|
||||||
"currency_code",
|
"currency_code",
|
||||||
@@ -33,8 +34,13 @@ export const defaultStoreCartFields = [
|
|||||||
"promotions.application_method.value",
|
"promotions.application_method.value",
|
||||||
"promotions.application_method.type",
|
"promotions.application_method.type",
|
||||||
"promotions.application_method.currency_code",
|
"promotions.application_method.currency_code",
|
||||||
|
"items",
|
||||||
|
"items.thumbnail",
|
||||||
|
"region",
|
||||||
"items.id",
|
"items.id",
|
||||||
|
"items.product",
|
||||||
"items.product.id",
|
"items.product.id",
|
||||||
|
"items.variant",
|
||||||
"items.variant_id",
|
"items.variant_id",
|
||||||
"items.product_id",
|
"items.product_id",
|
||||||
"items.product.categories.id",
|
"items.product.categories.id",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const defaultStoreCustomersFields = [
|
|||||||
|
|
||||||
export const retrieveTransformQueryConfig = {
|
export const retrieveTransformQueryConfig = {
|
||||||
defaults: defaultStoreCustomersFields,
|
defaults: defaultStoreCustomersFields,
|
||||||
|
allowed: [
|
||||||
|
...defaultStoreCustomersFields.map((f) => f.replace("*", "")),
|
||||||
|
"orders",
|
||||||
|
],
|
||||||
isList: false,
|
isList: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export const defaultStoreRetrieveReturnReasonFields = [
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"deleted_at",
|
"deleted_at",
|
||||||
"*.parent_return_reason",
|
"*parent_return_reason",
|
||||||
"*.return_reason_children",
|
"*return_reason_children",
|
||||||
]
|
]
|
||||||
|
|
||||||
export const retrieveTransformQueryConfig = {
|
export const retrieveTransformQueryConfig = {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { snakeCase } from "lodash"
|
import { snakeCase } from "lodash"
|
||||||
import { Query } from "@medusajs/framework"
|
import {
|
||||||
|
MedusaNextFunction,
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
Query,
|
||||||
|
} from "@medusajs/framework"
|
||||||
import { ApiRoutesLoader } from "@medusajs/framework/http"
|
import { ApiRoutesLoader } from "@medusajs/framework/http"
|
||||||
import { Tracer } from "@medusajs/framework/telemetry"
|
import { Tracer } from "@medusajs/framework/telemetry"
|
||||||
import type { SpanExporter } from "@opentelemetry/sdk-trace-node"
|
import type { SpanExporter } from "@opentelemetry/sdk-trace-node"
|
||||||
@@ -84,7 +89,11 @@ export function instrumentHttpLayer() {
|
|||||||
* OpenTelemetry
|
* OpenTelemetry
|
||||||
*/
|
*/
|
||||||
ApiRoutesLoader.traceMiddleware = (handler) => {
|
ApiRoutesLoader.traceMiddleware = (handler) => {
|
||||||
return async (req, res, next) => {
|
return async (
|
||||||
|
req: MedusaRequest<any>,
|
||||||
|
res: MedusaResponse,
|
||||||
|
next: MedusaNextFunction
|
||||||
|
) => {
|
||||||
if (shouldExcludeResource(req.originalUrl)) {
|
if (shouldExcludeResource(req.originalUrl)) {
|
||||||
return handler(req, res, next)
|
return handler(req, res, next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "path"
|
|||||||
import qs from "qs"
|
import qs from "qs"
|
||||||
import { RoutesLoader } from "@medusajs/framework/http"
|
import { RoutesLoader } from "@medusajs/framework/http"
|
||||||
import { MedusaContainer, PluginDetails } from "@medusajs/framework/types"
|
import { MedusaContainer, PluginDetails } from "@medusajs/framework/types"
|
||||||
|
import { ConfigModule } from "@medusajs/framework/config"
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
app: Express
|
app: Express
|
||||||
@@ -47,6 +48,12 @@ export default async ({ app, container, plugins }: Options) => {
|
|||||||
join(__dirname, "../api")
|
join(__dirname, "../api")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
projectConfig: {
|
||||||
|
http: { restrictedFields },
|
||||||
|
},
|
||||||
|
} = container.resolve<ConfigModule>("configModule")
|
||||||
|
|
||||||
// TODO: Figure out why this is causing issues with test when placed inside ./api.ts
|
// TODO: Figure out why this is causing issues with test when placed inside ./api.ts
|
||||||
// Adding this here temporarily
|
// Adding this here temporarily
|
||||||
// Test: (packages/medusa/src/api/routes/admin/currencies/update-currency.ts)
|
// Test: (packages/medusa/src/api/routes/admin/currencies/update-currency.ts)
|
||||||
@@ -54,6 +61,7 @@ export default async ({ app, container, plugins }: Options) => {
|
|||||||
await new RoutesLoader({
|
await new RoutesLoader({
|
||||||
app: app,
|
app: app,
|
||||||
sourceDir: sourcePaths,
|
sourceDir: sourcePaths,
|
||||||
|
baseRestrictedFields: restrictedFields?.store,
|
||||||
}).load()
|
}).load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw Error(
|
throw Error(
|
||||||
|
|||||||
Reference in New Issue
Block a user