Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
428 changes: 428 additions & 0 deletions apps/sim/blocks/blocks/netlify.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/sim/blocks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ import { MongoDBBlock } from '@/blocks/blocks/mongodb'
import { MothershipBlock } from '@/blocks/blocks/mothership'
import { MySQLBlock } from '@/blocks/blocks/mysql'
import { Neo4jBlock } from '@/blocks/blocks/neo4j'
import { NetlifyBlock } from '@/blocks/blocks/netlify'
import { NoteBlock } from '@/blocks/blocks/note'
import { NotionBlock, NotionV2Block } from '@/blocks/blocks/notion'
import { ObsidianBlock } from '@/blocks/blocks/obsidian'
Expand Down Expand Up @@ -388,6 +389,7 @@ export const registry: Record<string, BlockConfig> = {
mothership: MothershipBlock,
mysql: MySQLBlock,
neo4j: Neo4jBlock,
netlify: NetlifyBlock,
note: NoteBlock,
notion: NotionBlock,
notion_v2: NotionV2Block,
Expand Down
24 changes: 24 additions & 0 deletions apps/sim/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6727,6 +6727,30 @@ export function VercelIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function NetlifyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 256 226'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid'
>
<path
fill='#fff'
d='M69.181 188.087h-2.417l-12.065-12.065v-2.417l18.444-18.444h12.778l1.704 1.704v12.778zM54.699 51.628v-2.417l12.065-12.065h2.417L87.625 55.59v12.778l-1.704 1.704H73.143z'
/>
<path
fill='#014847'
d='M160.906 149.198h-17.552l-1.466-1.466v-41.089c0-7.31-2.873-12.976-11.689-13.174-4.537-.119-9.727 0-15.274.218l-.833.852v53.173l-1.466 1.466H95.074l-1.466-1.466v-70.19l1.466-1.467h39.503c15.354 0 27.795 12.441 27.795 27.795v43.882l-1.466 1.466Z'
/>
<path
fill='#fff'
d='M71.677 122.889H1.466L0 121.423V103.83l1.466-1.466h70.211l1.466 1.466v17.593zM254.534 122.889h-70.211l-1.466-1.466V103.83l1.466-1.466h70.211L256 103.83v17.593zM117.876 54.124V1.466L119.342 0h17.593l1.466 1.466v52.658l-1.466 1.466h-17.593zM117.876 223.787v-52.658l1.466-1.466h17.593l1.466 1.466v52.658l-1.466 1.465h-17.593z'
/>
</svg>
)
}

export function CloudflareIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'>
Expand Down
132 changes: 132 additions & 0 deletions apps/sim/lib/webhooks/providers/netlify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'

const logger = createLogger('WebhookProvider:Netlify')

/**
* Verifies a Netlify outgoing webhook JWT signature (HS256, iss=netlify).
* The token's `sha256` claim must equal the SHA-256 hex digest of the raw body.
*/
function verifyNetlifyJwt(token: string, secret: string, rawBody: string): boolean {
const parts = token.split('.')
if (parts.length !== 3) return false
const [headerB64, payloadB64, signatureB64] = parts

const expectedSignature = crypto
.createHmac('sha256', secret)
.update(`${headerB64}.${payloadB64}`)
.digest('base64url')

if (!safeCompare(expectedSignature, signatureB64)) {
return false
}

let payload: Record<string, unknown>
try {
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')) as Record<
string,
unknown
>
} catch {
return false
}

if (payload.iss !== 'netlify') return false

const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex')
if (typeof payload.sha256 !== 'string') return false
return safeCompare(payload.sha256, bodyHash)
}

export const netlifyHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
const secret = (providerConfig.signatureSecret as string | undefined)?.trim()
Comment on lines +23 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Missing JWT exp claim validation

verifyNetlifyJwt validates the HMAC signature and the sha256 body-hash but never checks the exp claim. Netlify JWTs are short-lived (typically valid for ~30 seconds after delivery). Without an expiry check, a previously-captured valid token can be replayed indefinitely — the signature and body-hash will still pass because neither changes between replays. The extractIdempotencyId guard limits duplicate workflow executions for the same deploy ID, but a replay with the same JWT + body would bypass signature freshness entirely.

if (!secret) {
logger.warn(`[${requestId}] Netlify signature secret missing; rejecting delivery`)
return new NextResponse(
'Unauthorized - Netlify signature secret is not configured. Set the JWS secret token on this trigger.',
{ status: 401 }
)
}

const signature = request.headers.get('x-webhook-signature')
if (!signature) {
logger.warn(`[${requestId}] Netlify webhook missing X-Webhook-Signature header`)
return new NextResponse('Unauthorized - Missing Netlify signature', { status: 401 })
}

if (!verifyNetlifyJwt(signature, secret, rawBody)) {
logger.warn(`[${requestId}] Netlify signature verification failed`)
return new NextResponse('Unauthorized - Invalid Netlify signature', { status: 401 })
}

return null
},

async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId) return true

const { isNetlifyEventMatch } = await import('@/triggers/netlify/utils')
const obj = body as Record<string, unknown>
const state = typeof obj.state === 'string' ? obj.state : undefined

if (!isNetlifyEventMatch(triggerId, state)) {
logger.debug(`[${requestId}] Netlify event mismatch for trigger ${triggerId}. Skipping.`, {
webhookId: webhook.id,
workflowId: workflow.id,
triggerId,
state,
})
return false
}

return true
},

extractIdempotencyId(body: unknown) {
const id = (body as Record<string, unknown>)?.id
if (id === undefined || id === null || id === '') {
return null
}
return `netlify:${String(id)}`
},

async formatInput(ctx: FormatInputContext): Promise<FormatInputResult> {
const body = ctx.body as Record<string, unknown>

const str = (v: unknown): string => (v == null ? '' : String(v))

return {
input: {
id: str(body.id),
siteId: str(body.site_id),
state: str(body.state),
name: str(body.name),
url: str(body.url),
deployUrl: str(body.deploy_url),
deploySslUrl: str(body.deploy_ssl_url),
adminUrl: str(body.admin_url),
branch: str(body.branch),
context: str(body.context),
commitRef: str(body.commit_ref),
commitUrl: str(body.commit_url),
title: str(body.title),
errorMessage: str(body.error_message),
createdAt: str(body.created_at),
updatedAt: str(body.updated_at),
publishedAt: str(body.published_at),
payload: body,
},
}
},
}
2 changes: 2 additions & 0 deletions apps/sim/lib/webhooks/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { lemlistHandler } from '@/lib/webhooks/providers/lemlist'
import { linearHandler } from '@/lib/webhooks/providers/linear'
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
import { mondayHandler } from '@/lib/webhooks/providers/monday'
import { netlifyHandler } from '@/lib/webhooks/providers/netlify'
import { notionHandler } from '@/lib/webhooks/providers/notion'
import { outlookHandler } from '@/lib/webhooks/providers/outlook'
import { resendHandler } from '@/lib/webhooks/providers/resend'
Expand Down Expand Up @@ -88,6 +89,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
twilio: twilioHandler,
twilio_voice: twilioVoiceHandler,
typeform: typeformHandler,
netlify: netlifyHandler,
vercel: vercelHandler,
webflow: webflowHandler,
whatsapp: whatsappHandler,
Expand Down
101 changes: 101 additions & 0 deletions apps/sim/tools/netlify/cancel_deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { NetlifyCancelDeployParams, NetlifyCancelDeployResponse } from '@/tools/netlify/types'
import type { ToolConfig } from '@/tools/types'

interface NetlifyApiDeploy {
id?: string
site_id?: string
state?: string
name?: string
url?: string
deploy_url?: string
deploy_ssl_url?: string
admin_url?: string
branch?: string
context?: string
commit_ref?: string
commit_url?: string
error_message?: string
created_at?: string
updated_at?: string
published_at?: string
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated NetlifyApiDeploy interface across three tool files

Low Severity

The identical NetlifyApiDeploy interface (18 lines) is copy-pasted across cancel_deploy.ts, get_deploy.ts, and list_deploys.ts. This triples the maintenance surface — a field change or addition needs updating in all three files. It naturally belongs in utils.ts alongside the already-shared NetlifyApiEnvVar interface.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 36c8db9. Configure here.


export const netlifyCancelDeployTool: ToolConfig<
NetlifyCancelDeployParams,
NetlifyCancelDeployResponse
> = {
id: 'netlify_cancel_deploy',
name: 'Netlify Cancel Deploy',
description: 'Cancel an in-progress Netlify deploy',
version: '1.0.0',

params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Netlify Personal Access Token',
},
deployId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Deploy ID to cancel',
},
},

request: {
url: (params: NetlifyCancelDeployParams) =>
`https://api.netlify.com/api/v1/deploys/${encodeURIComponent(params.deployId.trim())}/cancel`,
method: 'POST',
headers: (params: NetlifyCancelDeployParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},

transformResponse: async (response: Response) => {
const d = (await response.json()) as NetlifyApiDeploy

return {
success: true,
output: {
id: d.id ?? '',
siteId: d.site_id ?? null,
state: d.state ?? 'error',
name: d.name ?? null,
url: d.url ?? null,
deployUrl: d.deploy_url ?? null,
deploySslUrl: d.deploy_ssl_url ?? null,
adminUrl: d.admin_url ?? null,
branch: d.branch ?? null,
context: d.context ?? null,
commitRef: d.commit_ref ?? null,
commitUrl: d.commit_url ?? null,
errorMessage: d.error_message ?? null,
createdAt: d.created_at ?? null,
updatedAt: d.updated_at ?? null,
publishedAt: d.published_at ?? null,
},
}
},

outputs: {
id: { type: 'string', description: 'Deploy ID' },
siteId: { type: 'string', description: 'Site ID', optional: true },
state: { type: 'string', description: 'Deploy state after cancellation' },
name: { type: 'string', description: 'Site name', optional: true },
url: { type: 'string', description: 'Site URL', optional: true },
deployUrl: { type: 'string', description: 'Unique deploy URL', optional: true },
deploySslUrl: { type: 'string', description: 'Unique deploy HTTPS URL', optional: true },
adminUrl: { type: 'string', description: 'Netlify admin URL', optional: true },
branch: { type: 'string', description: 'Git branch', optional: true },
context: { type: 'string', description: 'Deploy context', optional: true },
commitRef: { type: 'string', description: 'Git commit SHA', optional: true },
commitUrl: { type: 'string', description: 'Git commit URL', optional: true },
errorMessage: { type: 'string', description: 'Error message if failed', optional: true },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
publishedAt: { type: 'string', description: 'Publish timestamp', optional: true },
},
}
Loading
Loading