Skip to content

Commit 1b82b97

Browse files
fix(custom-blocks): restrict iconUrl to https or internal serve paths (#5613)
1 parent b629292 commit 1b82b97

3 files changed

Lines changed: 50 additions & 5 deletions

File tree

apps/sim/lib/api/contracts/custom-blocks.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,27 @@ export const listCustomBlocksQuerySchema = z.object({
6363
workspaceId: workspaceIdSchema,
6464
})
6565

66+
/**
67+
* Icon URLs are rendered as org-wide `<img>` sources, so only https URLs and
68+
* internal file-serve paths (what the icon upload UI stores) are accepted —
69+
* never data:/blob:/other schemes an admin could smuggle into shared metadata.
70+
* Shared with the copilot deploy_custom_block handler's pass-through branch.
71+
*/
72+
export function isAllowedCustomBlockIconUrl(value: string): boolean {
73+
return value.startsWith('https://') || value.startsWith('/api/files/serve/')
74+
}
75+
76+
const iconUrlSchema = z.string().min(1).max(2048).refine(isAllowedCustomBlockIconUrl, {
77+
message: 'iconUrl must be an https URL or an internal /api/files/serve/ path',
78+
})
79+
6680
export const publishCustomBlockBodySchema = z.object({
6781
workspaceId: workspaceIdSchema,
6882
workflowId: workflowIdSchema,
6983
name: z.string().min(1, 'Name is required').max(60, 'Name must be 60 characters or fewer'),
7084
description: z.string().max(280, 'Description must be 280 characters or fewer').default(''),
71-
/** Uploaded icon image URL; omit for the default icon. */
72-
iconUrl: z.string().min(1).max(2048).optional(),
85+
/** Uploaded icon image URL (https or internal serve path); omit for the default icon. */
86+
iconUrl: iconUrlSchema.optional(),
7387
/** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */
7488
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
7589
/** Curated outputs; omit/empty to expose the child's whole result. */
@@ -87,8 +101,8 @@ export const updateCustomBlockBodySchema = z
87101
name: z.string().min(1).max(60).optional(),
88102
description: z.string().max(280).optional(),
89103
enabled: z.boolean().optional(),
90-
/** A URL sets/replaces the icon; `null` clears it (default icon). */
91-
iconUrl: z.string().min(1).max(2048).nullable().optional(),
104+
/** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */
105+
iconUrl: iconUrlSchema.nullable().optional(),
92106
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
93107
exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
94108
})

apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,29 @@ describe('executeDeployCustomBlock', () => {
388388
expect(publishCustomBlockMock).not.toHaveBeenCalled()
389389
})
390390

391+
it('rejects non-https icon URL schemes on pass-through', async () => {
392+
const dataUri = await executeDeployCustomBlock(
393+
{ name: 'Enrich Lead', iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+' },
394+
context
395+
)
396+
expect(dataUri.success).toBe(false)
397+
expect(dataUri.error).toContain('https')
398+
399+
const plainHttp = await executeDeployCustomBlock(
400+
{ name: 'Enrich Lead', iconUrl: 'http://example.com/icon.png' },
401+
context
402+
)
403+
expect(plainHttp.success).toBe(false)
404+
expect(publishCustomBlockMock).not.toHaveBeenCalled()
405+
406+
publishCustomBlockMock.mockResolvedValue(publishedBlock)
407+
const servePath = await executeDeployCustomBlock(
408+
{ name: 'Enrich Lead', iconUrl: '/api/files/serve/workspace-logos%2Ficon.png' },
409+
context
410+
)
411+
expect(servePath.success).toBe(true)
412+
})
413+
391414
it('fails when the icon workspace file is not an image', async () => {
392415
listWorkspaceFilesMock.mockResolvedValue([
393416
{ name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' },

apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { toError } from '@sim/utils/errors'
33
import { generateShortId } from '@sim/utils/id'
4+
import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks'
45
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
56
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
67
import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
@@ -42,7 +43,14 @@ async function resolveIconUrl(
4243
): Promise<string | undefined> {
4344
const value = raw?.trim()
4445
if (!value) return undefined
45-
if (!value.startsWith('files/')) return value
46+
if (!value.startsWith('files/')) {
47+
if (!isAllowedCustomBlockIconUrl(value)) {
48+
throw new CustomBlockValidationError(
49+
'iconUrl must be an https URL, an internal /api/files/serve/ path, or a workspace file path (files/...)'
50+
)
51+
}
52+
return value
53+
}
4654

4755
const canonical = canonicalizeVfsPath(value)
4856
const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true })

0 commit comments

Comments
 (0)