Skip to content
Merged
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
22 changes: 18 additions & 4 deletions apps/sim/lib/api/contracts/custom-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,27 @@ export const listCustomBlocksQuerySchema = z.object({
workspaceId: workspaceIdSchema,
})

/**
* Icon URLs are rendered as org-wide `<img>` 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. */
Expand All @@ -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(),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -42,7 +43,14 @@ async function resolveIconUrl(
): Promise<string | undefined> {
const value = raw?.trim()
if (!value) return undefined
if (!value.startsWith('files/')) return value
if (!value.startsWith('files/')) {
if (!isAllowedCustomBlockIconUrl(value)) {
Comment thread
TheodoreSpeaks marked this conversation as resolved.
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 })
Expand Down
Loading