diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index 01adbbddfc0..e7629109324 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -63,13 +63,27 @@ export const listCustomBlocksQuerySchema = z.object({ workspaceId: workspaceIdSchema, }) +/** + * Icon URLs are rendered as org-wide `` sources, so only https URLs and + * internal file-serve paths (what the icon upload UI stores) are accepted — + * never data:/blob:/other schemes an admin could smuggle into shared metadata. + * Shared with the copilot deploy_custom_block handler's pass-through branch. + */ +export function isAllowedCustomBlockIconUrl(value: string): boolean { + return value.startsWith('https://') || value.startsWith('/api/files/serve/') +} + +const iconUrlSchema = z.string().min(1).max(2048).refine(isAllowedCustomBlockIconUrl, { + message: 'iconUrl must be an https URL or an internal /api/files/serve/ path', +}) + export const publishCustomBlockBodySchema = z.object({ workspaceId: workspaceIdSchema, workflowId: workflowIdSchema, name: z.string().min(1, 'Name is required').max(60, 'Name must be 60 characters or fewer'), description: z.string().max(280, 'Description must be 280 characters or fewer').default(''), - /** Uploaded icon image URL; omit for the default icon. */ - iconUrl: z.string().min(1).max(2048).optional(), + /** Uploaded icon image URL (https or internal serve path); omit for the default icon. */ + iconUrl: iconUrlSchema.optional(), /** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */ inputs: z.array(inputPlaceholderSchema).max(50).optional(), /** Curated outputs; omit/empty to expose the child's whole result. */ @@ -87,8 +101,8 @@ export const updateCustomBlockBodySchema = z name: z.string().min(1).max(60).optional(), description: z.string().max(280).optional(), enabled: z.boolean().optional(), - /** A URL sets/replaces the icon; `null` clears it (default icon). */ - iconUrl: z.string().min(1).max(2048).nullable().optional(), + /** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */ + iconUrl: iconUrlSchema.nullable().optional(), inputs: z.array(inputPlaceholderSchema).max(50).optional(), exposedOutputs: z.array(exposedOutputSchema).max(50).optional(), }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index b5e8850c279..480d33a801e 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -388,6 +388,29 @@ describe('executeDeployCustomBlock', () => { expect(publishCustomBlockMock).not.toHaveBeenCalled() }) + it('rejects non-https icon URL schemes on pass-through', async () => { + const dataUri = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+' }, + context + ) + expect(dataUri.success).toBe(false) + expect(dataUri.error).toContain('https') + + const plainHttp = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: 'http://example.com/icon.png' }, + context + ) + expect(plainHttp.success).toBe(false) + expect(publishCustomBlockMock).not.toHaveBeenCalled() + + publishCustomBlockMock.mockResolvedValue(publishedBlock) + const servePath = await executeDeployCustomBlock( + { name: 'Enrich Lead', iconUrl: '/api/files/serve/workspace-logos%2Ficon.png' }, + context + ) + expect(servePath.success).toBe(true) + }) + it('fails when the icon workspace file is not an image', async () => { listWorkspaceFilesMock.mockResolvedValue([ { name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' }, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 65d2bed86d6..36268f6216f 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' +import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' @@ -42,7 +43,14 @@ async function resolveIconUrl( ): Promise { const value = raw?.trim() if (!value) return undefined - if (!value.startsWith('files/')) return value + if (!value.startsWith('files/')) { + if (!isAllowedCustomBlockIconUrl(value)) { + throw new CustomBlockValidationError( + 'iconUrl must be an https URL, an internal /api/files/serve/ path, or a workspace file path (files/...)' + ) + } + return value + } const canonical = canonicalizeVfsPath(value) const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true })