Skip to content
Open
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
99 changes: 99 additions & 0 deletions packages/payload/src/uploads/checkFileAccess.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import type { Collection } from '../collections/config/types.js'
import type { PayloadRequest } from '../types/index.js'

vi.mock('../auth/executeAccess.js', () => ({
executeAccess: vi.fn(),
}))

import { executeAccess } from '../auth/executeAccess.js'

import { checkFileAccess } from './checkFileAccess.js'

const makeFindOne = (result: unknown = { id: '1', filename: 'logo.png' }) =>
vi.fn().mockResolvedValue(result)

const makeCollection = (): Collection =>
({
config: {
slug: 'test-media',
access: { read: vi.fn() },
upload: {},
},
}) as unknown as Collection

const makeReq = (findOne: ReturnType<typeof vi.fn>): PayloadRequest =>
({
t: vi.fn(),
payload: {
db: { findOne },
},
}) as unknown as PayloadRequest

describe('checkFileAccess', () => {
beforeEach(() => {
vi.mocked(executeAccess).mockResolvedValue({})
})

describe('prefix filtering', () => {
it('should add prefix clause to where query when prefix is provided', async () => {
const findOne = makeFindOne()
const req = makeReq(findOne)
const collection = makeCollection()

await checkFileAccess({ collection, filename: 'logo.png', prefix: 'abc123', req })

const whereArg = findOne.mock.calls[0]?.[0]?.where
expect(whereArg?.and).toEqual(expect.arrayContaining([{ prefix: { equals: 'abc123' } }]))
})

it('should not add prefix clause to where query when prefix is omitted', async () => {
const findOne = makeFindOne()
const req = makeReq(findOne)
const collection = makeCollection()

await checkFileAccess({ collection, filename: 'logo.png', req })

const whereArg = findOne.mock.calls[0]?.[0]?.where
const hasPrefixCondition = whereArg?.and?.some(
(clause: Record<string, unknown>) => 'prefix' in clause,
)
expect(hasPrefixCondition).toBeFalsy()
})

it('should still include filename in where query when prefix is provided', async () => {
const findOne = makeFindOne()
const req = makeReq(findOne)
const collection = makeCollection()

await checkFileAccess({ collection, filename: 'logo.png', prefix: 'abc123', req })

const whereArg = findOne.mock.calls[0]?.[0]?.where
const filenameCondition = whereArg?.and?.[0]
expect(filenameCondition?.or).toEqual(
expect.arrayContaining([{ filename: { equals: 'logo.png' } }]),
)
})

it('should throw when no doc matches the given prefix', async () => {
const findOne = makeFindOne(null)
const req = makeReq(findOne)
const collection = makeCollection()

await expect(
checkFileAccess({ collection, filename: 'logo.png', prefix: 'nonexistent', req }),
).rejects.toThrow()
})

it('should throw when filename contains path traversal sequence', async () => {
const findOne = makeFindOne()
const req = makeReq(findOne)
const collection = makeCollection()

await expect(
checkFileAccess({ collection, filename: '../etc/passwd', req }),
).rejects.toThrow()
})
})
})
8 changes: 8 additions & 0 deletions packages/payload/src/uploads/checkFileAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { Forbidden } from '../errors/Forbidden.js'
export const checkFileAccess = async ({
collection,
filename,
prefix,
req,
}: {
collection: Collection
filename: string
prefix?: string
req: PayloadRequest
}): Promise<TypeWithID | undefined> => {
if (filename.includes('../') || filename.includes('..\\')) {
Expand Down Expand Up @@ -49,6 +51,12 @@ export const checkFileAccess = async ({
})
}

if (typeof prefix === 'string') {
queryToBuild.and!.push({
prefix: { equals: prefix },
})
}

const doc = await req.payload.db.findOne({
collection: config.slug,
req,
Expand Down
3 changes: 3 additions & 0 deletions packages/payload/src/uploads/endpoints/getFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const getFileHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req)

const filename = req.routeParams?.filename as string
const prefix = req.searchParams?.get('prefix') ?? undefined

if (!collection.config.upload) {
throw new APIError(
Expand All @@ -30,6 +31,7 @@ export const getFileHandler: PayloadHandler = async (req) => {
const accessResult = (await checkFileAccess({
collection,
filename,
prefix,
req,
}))!

Expand All @@ -48,6 +50,7 @@ export const getFileHandler: PayloadHandler = async (req) => {
params: {
collection: collection.config.slug,
filename,
prefix,
},
})
if (customResponse && customResponse instanceof Response) {
Expand Down
7 changes: 6 additions & 1 deletion packages/payload/src/uploads/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,12 @@ export type UploadConfig = {
args: {
doc: TypeWithID
headers?: Headers
params: { clientUploadContext?: unknown; collection: string; filename: string }
params: {
clientUploadContext?: unknown
collection: string
filename: string
prefix?: string
}
},
) => Promise<Response> | Promise<void> | Response | void)[]
/**
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-cloud-storage/src/hooks/afterRead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export const getAfterReadHook =
filename,
prefix,
})
} else if (url && prefix) {
const separator = url.includes('?') ? '&' : '?'
url = `${url}${separator}prefix=${encodeURIComponent(prefix)}`
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-cloud-storage/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export type StaticHandler = (
args: {
doc?: TypeWithID
headers?: Headers
params: { clientUploadContext?: unknown; collection: string; filename: string }
params: { clientUploadContext?: unknown; collection: string; filename: string; prefix?: string }
},
) => Promise<Response> | Response

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'

import type { CollectionConfig, PayloadRequest } from 'payload'

import { getFilePrefix } from './getFilePrefix.js'

const makeReq = (docs: unknown[] = []) =>
({
payload: {
find: vi.fn().mockResolvedValue({ docs }),
},
}) as unknown as PayloadRequest

const makeCollection = (): CollectionConfig =>
({ slug: 'media', upload: {} }) as unknown as CollectionConfig

describe('getFilePrefix', () => {
describe('explicitPrefix shortcut', () => {
it('should return explicitPrefix immediately without querying the database', async () => {
const req = makeReq()

const result = await getFilePrefix({
collection: makeCollection(),
explicitPrefix: 'uuid-prefix',
filename: 'logo.png',
req,
})

expect(result).toBe('uuid-prefix')
expect(req.payload.find).not.toHaveBeenCalled()
})

it('should return an empty string when explicitPrefix is an empty string', async () => {
const req = makeReq()

const result = await getFilePrefix({
collection: makeCollection(),
explicitPrefix: '',
filename: 'logo.png',
req,
})

expect(result).toBe('')
expect(req.payload.find).not.toHaveBeenCalled()
})
})

describe('database fallback', () => {
it('should query the database when explicitPrefix is not provided', async () => {
const req = makeReq([{ prefix: 'db-prefix' }])

const result = await getFilePrefix({
collection: makeCollection(),
filename: 'logo.png',
req,
})

expect(result).toBe('db-prefix')
expect(req.payload.find).toHaveBeenCalledOnce()
})

it('should return empty string when no document is found', async () => {
const req = makeReq([])

const result = await getFilePrefix({
collection: makeCollection(),
filename: 'logo.png',
req,
})

expect(result).toBe('')
})

it('should prioritize clientUploadContext prefix over database query', async () => {
const req = makeReq([{ prefix: 'db-prefix' }])

const result = await getFilePrefix({
clientUploadContext: { prefix: 'context-prefix' },
collection: makeCollection(),
filename: 'logo.png',
req,
})

expect(result).toBe('context-prefix')
expect(req.payload.find).not.toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import type { CollectionConfig, PayloadRequest, UploadConfig } from 'payload'
export async function getFilePrefix({
clientUploadContext,
collection,
explicitPrefix,
filename,
req,
}: {
clientUploadContext?: unknown
collection: CollectionConfig
explicitPrefix?: string
filename: string
req: PayloadRequest
}): Promise<string> {
if (typeof explicitPrefix === 'string') {
return explicitPrefix
}

// Prioritize from clientUploadContext if there is:
if (
clientUploadContext &&
Expand Down
13 changes: 11 additions & 2 deletions packages/storage-s3/src/staticHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export const getHandler = ({
getStorageClient,
signedDownloads,
}: Args): StaticHandler => {
return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => {
return async (
req,
{ headers: incomingHeaders, params: { clientUploadContext, filename, prefix: explicitPrefix } },
) => {
let object: AWS.GetObjectOutput | undefined = undefined
let streamed = false

Expand All @@ -74,7 +77,13 @@ export const getHandler = ({
}

try {
const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req })
const prefix = await getFilePrefix({
clientUploadContext,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would have been nice to have added the prefix to the clientUploadContext but the unknown type would require a bunch of checks here in addition to all the checks already in the getFilePrefix function.

collection,
explicitPrefix,
filename,
req,
})

const key = path.posix.join(prefix, filename)

Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/elements/EditUpload/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ export const EditUpload: React.FC<EditUploadProps> = ({
setFocalPosition({ x: xCenter, y: yCenter })
}

const fileSrcToUse = imageCacheTag ? `${fileSrc}?${encodeURIComponent(imageCacheTag)}` : fileSrc
const queryChar = fileSrc?.includes('?') ? '&' : '?'
const fileSrcToUse = imageCacheTag ? `${fileSrc}${queryChar}${encodeURIComponent(imageCacheTag)}` : fileSrc

return (
<div className={baseClass}>
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/elements/PreviewSizes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ export const PreviewSizes: React.FC<PreviewSizesProps> = ({ doc, imageCacheTag,
return null
}
if (doc.url) {
return `${doc.url}${imageCacheTag ? `?${encodeURIComponent(imageCacheTag)}` : ''}`
const queryChar = doc.url.includes('?') ? '&' : '?'
return `${doc.url}${imageCacheTag ? `${queryChar}${encodeURIComponent(imageCacheTag)}` : ''}`
}
}
useEffect(() => {
Expand Down
13 changes: 13 additions & 0 deletions test/uploads/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
noRestrictFileMimeTypesSlug,
noRestrictFileTypesSlug,
pdfOnlySlug,
prefixMediaSlug,
reduceSlug,
relationPreviewSlug,
relationSlug,
Expand Down Expand Up @@ -1061,6 +1062,18 @@ export default buildConfigWithDefaults({
],
},
},
{
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its this correct? Really unsure about this particular bit.

slug: prefixMediaSlug,
fields: [
{
name: 'prefix',
type: 'text',
},
],
upload: {
staticDir: path.resolve(dirname, './prefix-media'),
},
},
],
onInit: async (payload) => {
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
Expand Down
Loading
Loading