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 })