Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/drizzle/src/find/traverseFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getArrayRelationName } from '../utilities/getArrayRelationName.js'
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
import { jsonAggBuildObject } from '../utilities/json.js'
import { rawConstraint } from '../utilities/rawConstraint.js'
import { sanitizePathSegment } from '../utilities/sanitizePathSegment.js'
import {
InternalBlockTableNameIndex,
resolveBlockTableName,
Expand Down Expand Up @@ -63,6 +64,9 @@ const buildSQLWhere = (where: Where, alias: string) => {

const value = where[k][payloadOperator]
if (payloadOperator === '$raw') {
if (typeof value !== 'string') {
return undefined
}
return sql.raw(value)
}

Expand All @@ -75,7 +79,16 @@ const buildSQLWhere = (where: Where, alias: string) => {
payloadOperator = 'isNull'
}

return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value)
if (!(payloadOperator in operatorMap)) {
return undefined
}

const sanitizedColumnName = k
.split('.')
.map((s) => sanitizePathSegment(s))
.join('_')

return operatorMap[payloadOperator](sql.raw(`"${alias}"."${sanitizedColumnName}"`), value)
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/drizzle/src/postgres/createJSONQuery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { APIError } from 'payload'
import type { CreateJSONQueryArgs } from '../../types.js'

import { SAFE_STRING_REGEX } from '../../utilities/escapeSQLValue.js'
import { sanitizePathSegment } from '../../utilities/sanitizePathSegment.js'

const operatorMap: Record<string, string> = {
contains: '~',
Expand Down Expand Up @@ -43,7 +44,7 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
const jsonPaths = pathSegments
.slice(1)
.map((key) => {
return `${key}[*]`
return `${sanitizePathSegment(key)}[*]`
})
.join('.')

Expand Down
6 changes: 5 additions & 1 deletion packages/drizzle/src/queries/parseParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,11 @@ export function parseParams({
}

let formattedValue = val
if (adapter.name === 'sqlite' && operator === 'equals' && !isNaN(val)) {
if (
adapter.name === 'sqlite' &&
operator === 'equals' &&
(typeof val === 'number' || typeof val === 'boolean')
) {
formattedValue = val
} else if (['in', 'not_in'].includes(operator) && Array.isArray(val)) {
formattedValue = `(${val.map((v) => `${escapeSQLValue(v)}`).join(',')})`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { sanitizePathSegment } from '../../utilities/sanitizePathSegment.js'

export const convertPathToJSONTraversal = (incomingSegments: string[]): string => {
const segments = [...incomingSegments]
segments.shift()

return segments.reduce((res, segment) => {
const formattedSegment = Number.isNaN(parseInt(segment)) ? `'${segment}'` : segment
const formattedSegment = Number.isNaN(parseInt(segment))
? `'${sanitizePathSegment(segment)}'`
: segment
return `${res}->>${formattedSegment}`
}, '')
}
24 changes: 13 additions & 11 deletions packages/drizzle/src/sqlite/createJSONQuery/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CreateJSONQueryArgs } from '../../types.js'

import { escapeSQLValue } from '../../utilities/escapeSQLValue.js'
import { sanitizePathSegment } from '../../utilities/sanitizePathSegment.js'

type FromArrayArgs = {
isRoot?: true
Expand All @@ -20,11 +21,12 @@ const fromArray = ({
value,
}: FromArrayArgs) => {
const newPathSegments = pathSegments.slice(1)
const alias = `${pathSegments[isRoot ? 0 : 1]}_alias_${newPathSegments.length}`
const sanitizedSegment = sanitizePathSegment(pathSegments[isRoot ? 0 : 1])
const alias = `${sanitizedSegment}_alias_${newPathSegments.length}`

return `EXISTS (
SELECT 1
FROM json_each(${table}.${pathSegments[0]}) AS ${alias}
FROM json_each(${table}.${sanitizePathSegment(pathSegments[0])}) AS ${alias}
WHERE ${createJSONQuery({
operator,
pathSegments: newPathSegments,
Expand Down Expand Up @@ -61,25 +63,25 @@ const createConstraint = ({

if (operator === 'exists') {
if (pathSegments.length === 1) {
return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias})`
return `EXISTS (SELECT 1 FROM json_each("${sanitizePathSegment(pathSegments[0])}") AS ${newAlias})`
}

return `EXISTS (
SELECT 1
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
WHERE ${newAlias}.key = '${pathSegments[1]}'
FROM json_each(${alias}.value -> '${sanitizePathSegment(pathSegments[0])}') AS ${newAlias}
WHERE ${newAlias}.key = '${sanitizePathSegment(pathSegments[1])}'
)`
}

if (operator === 'not_exists') {
if (pathSegments.length === 1) {
return `NOT EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias})`
return `NOT EXISTS (SELECT 1 FROM json_each("${sanitizePathSegment(pathSegments[0])}") AS ${newAlias})`
}

return `NOT EXISTS (
SELECT 1
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
WHERE ${newAlias}.key = '${pathSegments[1]}'
FROM json_each(${alias}.value -> '${sanitizePathSegment(pathSegments[0])}') AS ${newAlias}
WHERE ${newAlias}.key = '${sanitizePathSegment(pathSegments[1])}'
)`
}

Expand All @@ -96,13 +98,13 @@ const createConstraint = ({
}

if (pathSegments.length === 1) {
return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')`
return `EXISTS (SELECT 1 FROM json_each("${sanitizePathSegment(pathSegments[0])}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')`
}

return `EXISTS (
SELECT 1
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
WHERE COALESCE(${newAlias}.value ->> '${pathSegments[1]}', '') ${formattedOperator} '${formattedValue}'
FROM json_each(${alias}.value -> '${sanitizePathSegment(pathSegments[0])}') AS ${newAlias}
WHERE COALESCE(${newAlias}.value ->> '${sanitizePathSegment(pathSegments[1])}', '') ${formattedOperator} '${formattedValue}'
)`
}

Expand Down
11 changes: 10 additions & 1 deletion packages/drizzle/src/utilities/json.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { Column, SQL } from 'drizzle-orm'

import { sql } from 'drizzle-orm'
import { APIError } from 'payload'

import type { DrizzleAdapter } from '../types.js'

const SAFE_KEY_REGEX = /^\w+$/

export function jsonAgg(adapter: DrizzleAdapter, expression: SQL) {
if (adapter.name === 'sqlite') {
return sql`coalesce(json_group_array(${expression}), '[]')`
Expand All @@ -13,7 +16,7 @@ export function jsonAgg(adapter: DrizzleAdapter, expression: SQL) {
}

/**
* @param shape Potential for SQL injections, so you shouldn't allow user-specified key names
* @param shape Keys are interpolated into raw SQL — only use trusted, internally-generated key names
*/
export function jsonBuildObject<T extends Record<string, Column | SQL>>(
adapter: DrizzleAdapter,
Expand All @@ -22,6 +25,12 @@ export function jsonBuildObject<T extends Record<string, Column | SQL>>(
const chunks: SQL[] = []

Object.entries(shape).forEach(([key, value]) => {
if (!SAFE_KEY_REGEX.test(key)) {
throw new APIError(
'Unsafe key passed to jsonBuildObject. Only alphanumeric characters and underscores are allowed.',
500,
)
}
if (chunks.length > 0) {
chunks.push(sql.raw(','))
}
Expand Down
18 changes: 18 additions & 0 deletions packages/drizzle/src/utilities/sanitizePathSegment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { APIError } from 'payload'

/**
* Validates that a path segment contains only allowed characters (word characters: [a-zA-Z0-9_]).
*
* @throws {APIError} if the segment contains characters outside /^[\w]+$/
*/
const SAFE_PATH_SEGMENT_REGEX = /^\w+$/

export const sanitizePathSegment = (segment: string): string => {
if (!SAFE_PATH_SEGMENT_REGEX.test(segment)) {
throw new APIError(
'Invalid path segment. Only alphanumeric characters and underscores are permitted.',
400,
)
}
return segment
}
6 changes: 2 additions & 4 deletions packages/next/src/views/Account/ResetPreferences/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@ export const ResetPreferences: React.FC<{
{
depth: 0,
where: {
user: {
id: {
equals: user.id,
},
'user.value': {
equals: user.id,
},
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/payload/src/database/getLocalizedPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type FlattenedField,
} from '../fields/config/types.js'
import { APIError, type Payload, type SanitizedCollectionConfig } from '../index.js'
import { SAFE_FIELD_PATH_REGEX } from '../types/constants.js'

export function getLocalizedPaths({
collectionSlug,
Expand Down Expand Up @@ -209,7 +210,7 @@ export function getLocalizedPaths({
case 'richText': {
const upcomingSegments = pathSegments.slice(i + 1).join('.')
pathSegments.forEach((path) => {
if (!/^\w+(?:\.\w+)*$/.test(path)) {
if (!SAFE_FIELD_PATH_REGEX.test(path)) {
lastIncompletePath.invalid = true
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export async function validateQueryPaths({
versionFields,
}),
)
} else if (typeof val !== 'object' || val === null) {
} else {
errors.push({ path: `${path}.${operator}` })
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { PayloadRequest, WhereField } from '../../types/index.js'
import type { EntityPolicies, PathToQuery } from './types.js'

import { fieldAffectsData } from '../../fields/config/types.js'
import { SAFE_FIELD_PATH_REGEX } from '../../types/constants.js'
import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEntityPermissions.js'
import { isolateObjectProperty } from '../../utilities/isolateObjectProperty.js'
import { getLocalizedPaths } from '../getLocalizedPaths.js'
Expand Down Expand Up @@ -99,7 +100,7 @@ export async function validateSearchParam({
promises.push(
...paths.map(async ({ collectionSlug, field, invalid, path }, i) => {
if (invalid) {
if (!polymorphicJoin) {
if (!polymorphicJoin || !SAFE_FIELD_PATH_REGEX.test(incomingPath)) {
errors.push({ path })
}

Expand Down
2 changes: 2 additions & 0 deletions packages/payload/src/exports/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export { reduceFieldsToValues } from '../utilities/reduceFieldsToValues.js'

export { sanitizeFilename } from '../utilities/sanitizeFilename.js'

export { sanitizeUrl } from '../utilities/sanitizeUrl.js'

export { sanitizeUserDataForEmail } from '../utilities/sanitizeUserDataForEmail.js'

export { setsAreEqual } from '../utilities/setsAreEqual.js'
Expand Down
6 changes: 6 additions & 0 deletions packages/payload/src/types/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ export const validOperators = [
export type Operator = (typeof validOperators)[number]

export const validOperatorSet = new Set<Operator>(validOperators)

/**
* Matches a dot-separated path where each segment is a word character (a-zA-Z0-9_).
* Used to validate field paths before they are processed by query builders.
*/
export const SAFE_FIELD_PATH_REGEX = /^\w+(?:\.\w+)*$/
10 changes: 9 additions & 1 deletion packages/payload/src/uploads/endpoints/getFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,16 @@ export const getFileHandler: PayloadHandler = async (req) => {
}
}

// Local filesystem fallback — cloud storage handlers return a Response above
// and have their own filename validation via sanitizeFilename.
const fileDir = collection.config.upload?.staticDir || collection.config.slug
const filePath = path.resolve(`${fileDir}/${filename}`)
const resolvedDir = path.resolve(fileDir)
const filePath = path.resolve(resolvedDir, filename)

if (!filePath.startsWith(resolvedDir + path.sep)) {
throw new APIError('Invalid filename.', httpStatus.BAD_REQUEST)
}

let stats: Stats

try {
Expand Down
Loading
Loading