Skip to content

Commit 8432898

Browse files
authored
fix(alt-text): reject unmanaged collections in generate endpoints (#162)
1 parent e1c6a5a commit 8432898

4 files changed

Lines changed: 167 additions & 13 deletions

File tree

alt-text/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- fix: enforce collection access control in the generate and bulk-generate endpoints by running the Local API reads and writes under the requesting user (`overrideAccess: false`)
6+
- fix: reject requests to the generate and bulk-generate endpoints that target a collection the plugin does not manage with `403`, before any document read or write
67
- fix: filter the alt text health report (endpoint and dashboard widget) to the collections the requesting user may read, so the aggregate no longer discloses counts and document IDs for collections their role cannot access
78
- feat: `healthCheck` now accepts an access function that gates the health endpoint and hides the dashboard widget, letting the collection-wide report be restricted (e.g. to admins) separately from the generate endpoints
89
- fix: respect update access in the admin UI — render the alt text field read-only and hide the single-document and bulk generate buttons for users without update access

alt-text/src/endpoints/bulkGenerateAltTexts.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ export const bulkGenerateAltTextsEndpoint =
3636
return Response.json({ error: 'Plugin config not found' }, { status: 500 })
3737
}
3838

39+
// Treat the configured collections as an allowlist. Reject any other
40+
// collection before touching the Local API, so the endpoint can only ever
41+
// operate on the upload collections the plugin manages.
42+
const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)
43+
44+
if (!collectionConfig) {
45+
return Response.json(
46+
{ error: `Collection "${collection}" is not managed by the alt text plugin.` },
47+
{ status: 403 },
48+
)
49+
}
50+
3951
if (!pluginConfig.resolver) {
4052
return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })
4153
}
@@ -134,9 +146,11 @@ async function generateAndUpdateAltText({
134146
const mimeType =
135147
'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined
136148

137-
const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)
149+
// The handler validates `collection` against the configured collections before
150+
// reaching this helper, so a matching entry is guaranteed.
151+
const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)!
138152

139-
if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
153+
if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
140154
throw new Error(
141155
`Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,
142156
)

alt-text/src/endpoints/generateAltText.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ export const generateAltTextEndpoint =
2828

2929
const { id, collection, locale, update } = generateAltTextRequestSchema.parse(data)
3030

31+
const pluginConfig = req.payload.config.custom?.altTextPluginConfig as
32+
| AltTextPluginConfig
33+
| undefined
34+
35+
if (!pluginConfig) {
36+
return Response.json({ error: 'Plugin config not found' }, { status: 500 })
37+
}
38+
39+
// Treat the configured collections as an allowlist. Reject any other
40+
// collection before touching the Local API, so the endpoint can only ever
41+
// operate on the upload collections the plugin manages.
42+
const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)
43+
44+
if (!collectionConfig) {
45+
return Response.json(
46+
{ error: `Collection "${collection}" is not managed by the alt text plugin.` },
47+
{ status: 403 },
48+
)
49+
}
50+
3151
const imageDoc = await req.payload.findByID({
3252
id,
3353
collection,
@@ -42,14 +62,6 @@ export const generateAltTextEndpoint =
4262
return Response.json({ error: 'Image not found' }, { status: 404 })
4363
}
4464

45-
const pluginConfig = req.payload.config.custom?.altTextPluginConfig as
46-
| AltTextPluginConfig
47-
| undefined
48-
49-
if (!pluginConfig) {
50-
return Response.json({ error: 'Plugin config not found' }, { status: 500 })
51-
}
52-
5365
if (!pluginConfig.getImageThumbnail) {
5466
return Response.json(
5567
{ error: 'getImageThumbnail function not configured' },
@@ -66,9 +78,7 @@ export const generateAltTextEndpoint =
6678
? imageDoc.mimeType
6779
: undefined
6880

69-
const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)
70-
71-
if (mimeType && collectionConfig && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
81+
if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
7282
return Response.json(
7383
{
7484
error: `Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import assert from 'node:assert/strict'
2+
3+
import { describe, test } from 'vitest'
4+
5+
import type { PayloadRequest } from 'payload'
6+
7+
import type { AltTextPluginConfig } from '../src/types/AltTextPluginConfig.ts'
8+
9+
import { bulkGenerateAltTextsEndpoint } from '../src/endpoints/bulkGenerateAltTexts.ts'
10+
import { generateAltTextEndpoint } from '../src/endpoints/generateAltText.ts'
11+
12+
/**
13+
* The endpoints must treat the configured `collections` as an allowlist. A
14+
* request naming a collection the plugin does not manage (e.g. `users`) must be
15+
* rejected with `403` before any Local API call — otherwise the endpoints can be
16+
* pointed at any collection in the app, widening the blast radius of the
17+
* access-control surface to the entire data model.
18+
*/
19+
20+
const user = { id: 'low-priv-user', email: 'user@example.com', role: 'user' }
21+
22+
type LocalApiCall = Record<string, unknown>
23+
24+
function buildPluginConfig(): AltTextPluginConfig {
25+
return {
26+
access: ({ req }) => !!req.user,
27+
collections: [{ slug: 'media', mimeTypes: ['image/*'] }],
28+
enabled: true,
29+
getImageThumbnail: () => 'https://example.com/thumb.png',
30+
healthCheck: true,
31+
healthCheckAccess: ({ req }) => !!req.user,
32+
locale: 'en',
33+
locales: [],
34+
maxBulkGenerateConcurrency: 1,
35+
resolver: {
36+
key: 'mock',
37+
resolve: async () => ({
38+
success: true,
39+
result: { altText: 'generated alt', keywords: ['a', 'b'] },
40+
}),
41+
resolveBulk: async () => ({
42+
success: true,
43+
results: { en: { altText: 'generated alt', keywords: ['a', 'b'] } },
44+
}),
45+
},
46+
}
47+
}
48+
49+
function buildRequest(body: unknown): {
50+
findByIDCalls: LocalApiCall[]
51+
req: PayloadRequest
52+
updateCalls: LocalApiCall[]
53+
} {
54+
const findByIDCalls: LocalApiCall[] = []
55+
const updateCalls: LocalApiCall[] = []
56+
57+
const req = {
58+
json: async () => body,
59+
payload: {
60+
config: { custom: { altTextPluginConfig: buildPluginConfig() } },
61+
findByID: async (args: LocalApiCall) => {
62+
findByIDCalls.push(args)
63+
return { id: args.id, filename: 'photo.png', mimeType: 'image/png' }
64+
},
65+
update: async (args: LocalApiCall) => {
66+
updateCalls.push(args)
67+
return { id: args.id }
68+
},
69+
},
70+
user,
71+
} as unknown as PayloadRequest
72+
73+
return { findByIDCalls, req, updateCalls }
74+
}
75+
76+
describe('generate endpoint collection allowlist', () => {
77+
test('rejects a collection the plugin does not manage with 403 and no Local API call', async () => {
78+
const { findByIDCalls, req, updateCalls } = buildRequest({
79+
id: 'doc-1',
80+
collection: 'users',
81+
locale: 'en',
82+
update: true,
83+
})
84+
85+
const response = await generateAltTextEndpoint(buildPluginConfig().access)(req)
86+
87+
assert.equal(response.status, 403)
88+
assert.equal(findByIDCalls.length, 0)
89+
assert.equal(updateCalls.length, 0)
90+
})
91+
92+
test('allows a configured collection', async () => {
93+
const { findByIDCalls, req } = buildRequest({
94+
id: 'doc-1',
95+
collection: 'media',
96+
locale: 'en',
97+
update: false,
98+
})
99+
100+
const response = await generateAltTextEndpoint(buildPluginConfig().access)(req)
101+
102+
assert.equal(response.status, 200)
103+
assert.equal(findByIDCalls.length, 1)
104+
})
105+
})
106+
107+
describe('bulk generate endpoint collection allowlist', () => {
108+
test('rejects a collection the plugin does not manage with 403 and no Local API call', async () => {
109+
const { findByIDCalls, req, updateCalls } = buildRequest({
110+
collection: 'users',
111+
ids: ['doc-1', 'doc-2'],
112+
})
113+
114+
const response = await bulkGenerateAltTextsEndpoint(buildPluginConfig().access)(req)
115+
116+
assert.equal(response.status, 403)
117+
assert.equal(findByIDCalls.length, 0)
118+
assert.equal(updateCalls.length, 0)
119+
})
120+
121+
test('allows a configured collection', async () => {
122+
const { findByIDCalls, req } = buildRequest({ collection: 'media', ids: ['doc-1'] })
123+
124+
const response = await bulkGenerateAltTextsEndpoint(buildPluginConfig().access)(req)
125+
126+
assert.equal(response.status, 200)
127+
assert.equal(findByIDCalls.length, 1)
128+
})
129+
})

0 commit comments

Comments
 (0)