diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 59de55f8c26..1cfe24a9e25 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -57,6 +57,15 @@ export const GitLabBlock: BlockConfig = { password: true, required: true, }, + // Self-managed GitLab host (defaults to gitlab.com) + { + id: 'host', + title: 'GitLab Host', + type: 'short-input', + placeholder: 'gitlab.com', + mode: 'advanced', + description: 'Self-managed GitLab host. Leave blank for gitlab.com.', + }, // Project ID (required for most operations) { id: 'projectId', @@ -474,6 +483,7 @@ Return ONLY the commit message - no explanations, no extra text.`, params: (params) => { const baseParams: Record = { accessToken: params.accessToken, + host: params.host?.trim() || undefined, } switch (params.operation) { @@ -709,6 +719,7 @@ Return ONLY the commit message - no explanations, no extra text.`, inputs: { operation: { type: 'string', description: 'Operation to perform' }, credential: { type: 'string', description: 'GitLab access token' }, + host: { type: 'string', description: 'Self-managed GitLab host (defaults to gitlab.com)' }, projectId: { type: 'string', description: 'Project ID or URL-encoded path' }, issueIid: { type: 'number', description: 'Issue internal ID' }, mergeRequestIid: { type: 'number', description: 'Merge request internal ID' }, diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index 18247214006..99586321f48 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -14,10 +14,10 @@ import { parseTagDate, sizeLimitSkipReason, } from '@/connectors/utils' +import { normalizeGitLabHost, UnsafeGitLabHostError } from '@/tools/gitlab/utils' const logger = createLogger('GitLabConnector') -const DEFAULT_HOST = 'gitlab.com' const PAGE_SIZE = 100 /** Max repository file size to index. Larger blobs are skipped. */ const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES @@ -175,16 +175,16 @@ interface GitLabProject { } /** - * Normalizes the host config value: trims whitespace, strips any protocol - * prefix and trailing slashes, and falls back to gitlab.com when empty. + * Normalizes the host config value via the shared GitLab host normalizer: + * trims, strips any protocol prefix and trailing slashes, rejects structurally + * unsafe hosts (userinfo, whitespace, embedded path), and falls back to + * gitlab.com when empty. Shared with the GitLab tools and webhook provider so + * every surface resolves and validates hosts identically. + * + * @throws {UnsafeGitLabHostError} when a non-empty host is structurally unsafe. */ function normalizeHost(rawHost: unknown): string { - const host = typeof rawHost === 'string' ? rawHost.trim() : '' - if (!host) return DEFAULT_HOST - return host - .replace(/^https?:\/\//i, '') - .replace(/\/+$/, '') - .trim() + return normalizeGitLabHost(rawHost) } /** @@ -941,7 +941,18 @@ export const gitlabConnector: ConnectorConfig = { return { valid: false, error: 'Max items must be a positive number' } } - const host = normalizeHost(sourceConfig.host) + let host: string + try { + host = normalizeHost(sourceConfig.host) + } catch (error) { + if (error instanceof UnsafeGitLabHostError) { + return { + valid: false, + error: 'Host must be a valid GitLab domain (e.g. gitlab.example.com)', + } + } + throw error + } const apiBase = buildApiBase(host) const encodedProject = encodeProjectId(project) const choice = getContentTypeChoice(sourceConfig) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index a9a71270042..8f6b17100fe 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -711,7 +711,7 @@ export const OAUTH_PROVIDERS: Record = { 'groups:write', 'chat:write', 'chat:write.public', - 'assistant:write', + // TODO: Add 'assistant:write' once Slack app review is approved 'im:write', 'im:read', 'users:read', diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts index 3f6ffcbf12e..d812ee19d92 100644 --- a/apps/sim/lib/webhooks/providers/gitlab.ts +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' import { NextResponse } from 'next/server' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { AuthContext, @@ -13,17 +14,16 @@ import type { SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { getGitLabApiBase, UnsafeGitLabHostError } from '@/tools/gitlab/utils' const logger = createLogger('WebhookProvider:GitLab') -const GITLAB_API_BASE = 'https://gitlab.com/api/v4' - function asRecord(value: unknown): Record { return (value as Record) || {} } -function gitlabProjectHooksUrl(projectId: string): string { - return `${GITLAB_API_BASE}/projects/${encodeURIComponent(projectId)}/hooks` +function gitlabProjectHooksUrl(projectId: string, host: unknown): string { + return `${getGitLabApiBase(host)}/projects/${encodeURIComponent(projectId)}/hooks` } /** @@ -33,9 +33,10 @@ function gitlabProjectHooksUrl(projectId: string): string { async function cleanupGitLabHookByUrl( projectId: string, accessToken: string, - url: string + url: string, + host: unknown ): Promise { - const res = await fetch(gitlabProjectHooksUrl(projectId), { + const res = await secureFetchWithValidation(gitlabProjectHooksUrl(projectId, host), { headers: { 'PRIVATE-TOKEN': accessToken }, }).catch(() => null) if (!res || !res.ok) return @@ -47,7 +48,7 @@ async function cleanupGitLabHookByUrl( hooks .filter((hook) => hook.url === url && hook.id != null) .map((hook) => - fetch(`${gitlabProjectHooksUrl(projectId)}/${hook.id}`, { + secureFetchWithValidation(`${gitlabProjectHooksUrl(projectId, host)}/${hook.id}`, { method: 'DELETE', headers: { 'PRIVATE-TOKEN': accessToken }, }).catch(() => null) @@ -113,14 +114,28 @@ export const gitlabHandler: WebhookProviderHandler = { const accessToken = config.accessToken as string | undefined const projectId = config.projectId as string | undefined const triggerId = config.triggerId as string | undefined + const host = config.host as string | undefined if (!accessToken) throw new Error('GitLab Personal Access Token is required to create the webhook.') if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.') + // Validate the optional self-managed host up front so a structurally unsafe + // value surfaces as a clear error instead of an unhandled UnsafeGitLabHostError. + try { + getGitLabApiBase(host) + } catch (error) { + if (error instanceof UnsafeGitLabHostError) { + throw new Error( + 'GitLab host is invalid. Provide a domain like gitlab.example.com (no protocol, path, or credentials).' + ) + } + throw error + } + const { getGitLabEventFlags } = await import('@/triggers/gitlab/utils') const secretToken = generateId() - const res = await fetch(gitlabProjectHooksUrl(projectId), { + const res = await secureFetchWithValidation(gitlabProjectHooksUrl(projectId, host), { method: 'POST', headers: { 'PRIVATE-TOKEN': accessToken, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -150,7 +165,7 @@ export const gitlabHandler: WebhookProviderHandler = { if (created.id === undefined || created.id === null) { // The hook was created but we can't read its id — delete it by URL so it // is not orphaned in GitLab. - await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook)) + await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook), host) throw new Error('GitLab webhook created but no hook ID was returned.') } @@ -163,6 +178,7 @@ export const gitlabHandler: WebhookProviderHandler = { const accessToken = config.accessToken as string | undefined const projectId = config.projectId as string | undefined const externalId = config.externalId as string | undefined + const host = config.host as string | undefined if (!accessToken || !projectId || !externalId) { if (ctx.strict) throw new Error('Missing GitLab credentials or hook ID for webhook deletion.') @@ -172,10 +188,30 @@ export const gitlabHandler: WebhookProviderHandler = { return } - const res = await fetch(`${gitlabProjectHooksUrl(projectId)}/${externalId}`, { - method: 'DELETE', - headers: { 'PRIVATE-TOKEN': accessToken }, - }) + // A structurally unsafe host must not abort cleanup in non-strict mode — mirror + // the graceful skip used for missing credentials above. + try { + getGitLabApiBase(host) + } catch (error) { + if (error instanceof UnsafeGitLabHostError) { + if (ctx.strict) { + throw new Error('Cannot delete GitLab webhook: the configured host is invalid.') + } + logger.warn( + `[${ctx.requestId}] Skipping GitLab webhook cleanup — configured host is invalid` + ) + return + } + throw error + } + + const res = await secureFetchWithValidation( + `${gitlabProjectHooksUrl(projectId, host)}/${externalId}`, + { + method: 'DELETE', + headers: { 'PRIVATE-TOKEN': accessToken }, + } + ) if (!res.ok && res.status !== 404) { if (ctx.strict) throw new Error(`Failed to delete GitLab webhook: ${res.status}`) diff --git a/apps/sim/tools/gitlab/cancel_pipeline.ts b/apps/sim/tools/gitlab/cancel_pipeline.ts index 62b9e096b95..9707f758a0e 100644 --- a/apps/sim/tools/gitlab/cancel_pipeline.ts +++ b/apps/sim/tools/gitlab/cancel_pipeline.ts @@ -1,4 +1,5 @@ import type { GitLabCancelPipelineParams, GitLabCancelPipelineResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabCancelPipelineTool: ToolConfig< @@ -17,6 +18,12 @@ export const gitlabCancelPipelineTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -34,7 +41,7 @@ export const gitlabCancelPipelineTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/pipelines/${params.pipelineId}/cancel` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/pipelines/${params.pipelineId}/cancel` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/create_issue.ts b/apps/sim/tools/gitlab/create_issue.ts index 6a03e9ff970..cc4475831da 100644 --- a/apps/sim/tools/gitlab/create_issue.ts +++ b/apps/sim/tools/gitlab/create_issue.ts @@ -1,4 +1,5 @@ import type { GitLabCreateIssueParams, GitLabCreateIssueResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabCreateIssueTool: ToolConfig = @@ -15,6 +16,12 @@ export const gitlabCreateIssueTool: ToolConfig { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/issues` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/issues` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/create_issue_note.ts b/apps/sim/tools/gitlab/create_issue_note.ts index 150bf729d7e..0ad5c218bee 100644 --- a/apps/sim/tools/gitlab/create_issue_note.ts +++ b/apps/sim/tools/gitlab/create_issue_note.ts @@ -1,4 +1,5 @@ import type { GitLabCreateIssueNoteParams, GitLabCreateNoteResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabCreateIssueNoteTool: ToolConfig< @@ -17,6 +18,12 @@ export const gitlabCreateIssueNoteTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -40,7 +47,7 @@ export const gitlabCreateIssueNoteTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/issues/${params.issueIid}/notes` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/issues/${params.issueIid}/notes` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/create_merge_request.ts b/apps/sim/tools/gitlab/create_merge_request.ts index fdaebbf842a..2c02c2dd0f4 100644 --- a/apps/sim/tools/gitlab/create_merge_request.ts +++ b/apps/sim/tools/gitlab/create_merge_request.ts @@ -2,6 +2,7 @@ import type { GitLabCreateMergeRequestParams, GitLabCreateMergeRequestResponse, } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabCreateMergeRequestTool: ToolConfig< @@ -20,6 +21,12 @@ export const gitlabCreateMergeRequestTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -91,7 +98,7 @@ export const gitlabCreateMergeRequestTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/merge_requests` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/merge_requests` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/create_merge_request_note.ts b/apps/sim/tools/gitlab/create_merge_request_note.ts index 599ec569719..f02f5fa35fb 100644 --- a/apps/sim/tools/gitlab/create_merge_request_note.ts +++ b/apps/sim/tools/gitlab/create_merge_request_note.ts @@ -2,6 +2,7 @@ import type { GitLabCreateMergeRequestNoteParams, GitLabCreateNoteResponse, } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabCreateMergeRequestNoteTool: ToolConfig< @@ -20,6 +21,12 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -43,7 +50,7 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/merge_requests/${params.mergeRequestIid}/notes` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/merge_requests/${params.mergeRequestIid}/notes` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/create_pipeline.ts b/apps/sim/tools/gitlab/create_pipeline.ts index 38a15df006b..a27ed7ba372 100644 --- a/apps/sim/tools/gitlab/create_pipeline.ts +++ b/apps/sim/tools/gitlab/create_pipeline.ts @@ -1,4 +1,5 @@ import type { GitLabCreatePipelineParams, GitLabCreatePipelineResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabCreatePipelineTool: ToolConfig< @@ -17,6 +18,12 @@ export const gitlabCreatePipelineTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -41,7 +48,7 @@ export const gitlabCreatePipelineTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/pipeline` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/pipeline` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/delete_issue.ts b/apps/sim/tools/gitlab/delete_issue.ts index 64fbe881e53..475e52d77a8 100644 --- a/apps/sim/tools/gitlab/delete_issue.ts +++ b/apps/sim/tools/gitlab/delete_issue.ts @@ -1,4 +1,5 @@ import type { GitLabDeleteIssueParams, GitLabDeleteIssueResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabDeleteIssueTool: ToolConfig = @@ -15,6 +16,12 @@ export const gitlabDeleteIssueTool: ToolConfig { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/issues/${params.issueIid}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/issues/${params.issueIid}` }, method: 'DELETE', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/get_issue.ts b/apps/sim/tools/gitlab/get_issue.ts index 9a3c5821edb..aa87136e552 100644 --- a/apps/sim/tools/gitlab/get_issue.ts +++ b/apps/sim/tools/gitlab/get_issue.ts @@ -1,4 +1,5 @@ import type { GitLabGetIssueParams, GitLabGetIssueResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabGetIssueTool: ToolConfig = { @@ -14,6 +15,12 @@ export const gitlabGetIssueTool: ToolConfig { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/issues/${params.issueIid}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/issues/${params.issueIid}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/get_merge_request.ts b/apps/sim/tools/gitlab/get_merge_request.ts index 6e99d3cfbd3..f228cfba2eb 100644 --- a/apps/sim/tools/gitlab/get_merge_request.ts +++ b/apps/sim/tools/gitlab/get_merge_request.ts @@ -2,6 +2,7 @@ import type { GitLabGetMergeRequestParams, GitLabGetMergeRequestResponse, } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabGetMergeRequestTool: ToolConfig< @@ -20,6 +21,12 @@ export const gitlabGetMergeRequestTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -37,7 +44,7 @@ export const gitlabGetMergeRequestTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/merge_requests/${params.mergeRequestIid}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/merge_requests/${params.mergeRequestIid}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/get_pipeline.ts b/apps/sim/tools/gitlab/get_pipeline.ts index 5f4f25a0eaa..1494e65e4bb 100644 --- a/apps/sim/tools/gitlab/get_pipeline.ts +++ b/apps/sim/tools/gitlab/get_pipeline.ts @@ -1,4 +1,5 @@ import type { GitLabGetPipelineParams, GitLabGetPipelineResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabGetPipelineTool: ToolConfig = @@ -15,6 +16,12 @@ export const gitlabGetPipelineTool: ToolConfig { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/pipelines/${params.pipelineId}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/pipelines/${params.pipelineId}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/get_project.ts b/apps/sim/tools/gitlab/get_project.ts index c49369084f5..5ea42920584 100644 --- a/apps/sim/tools/gitlab/get_project.ts +++ b/apps/sim/tools/gitlab/get_project.ts @@ -1,4 +1,5 @@ import type { GitLabGetProjectParams, GitLabGetProjectResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabGetProjectTool: ToolConfig = { @@ -14,6 +15,12 @@ export const gitlabGetProjectTool: ToolConfig { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/list_issues.ts b/apps/sim/tools/gitlab/list_issues.ts index 1607920571e..40a016f3b34 100644 --- a/apps/sim/tools/gitlab/list_issues.ts +++ b/apps/sim/tools/gitlab/list_issues.ts @@ -1,4 +1,5 @@ import type { GitLabListIssuesParams, GitLabListIssuesResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabListIssuesTool: ToolConfig = { @@ -14,6 +15,12 @@ export const gitlabListIssuesTool: ToolConfig ({ diff --git a/apps/sim/tools/gitlab/list_merge_requests.ts b/apps/sim/tools/gitlab/list_merge_requests.ts index 0296bc3a24f..2cdae3301c4 100644 --- a/apps/sim/tools/gitlab/list_merge_requests.ts +++ b/apps/sim/tools/gitlab/list_merge_requests.ts @@ -2,6 +2,7 @@ import type { GitLabListMergeRequestsParams, GitLabListMergeRequestsResponse, } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabListMergeRequestsTool: ToolConfig< @@ -20,6 +21,12 @@ export const gitlabListMergeRequestsTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -91,7 +98,7 @@ export const gitlabListMergeRequestsTool: ToolConfig< if (params.page) queryParams.append('page', String(params.page)) const query = queryParams.toString() - return `https://gitlab.com/api/v4/projects/${encodedId}/merge_requests${query ? `?${query}` : ''}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/merge_requests${query ? `?${query}` : ''}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/list_pipelines.ts b/apps/sim/tools/gitlab/list_pipelines.ts index d4aed464736..80294e85f73 100644 --- a/apps/sim/tools/gitlab/list_pipelines.ts +++ b/apps/sim/tools/gitlab/list_pipelines.ts @@ -1,4 +1,5 @@ import type { GitLabListPipelinesParams, GitLabListPipelinesResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabListPipelinesTool: ToolConfig< @@ -17,6 +18,12 @@ export const gitlabListPipelinesTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -75,7 +82,7 @@ export const gitlabListPipelinesTool: ToolConfig< if (params.page) queryParams.append('page', String(params.page)) const query = queryParams.toString() - return `https://gitlab.com/api/v4/projects/${encodedId}/pipelines${query ? `?${query}` : ''}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/pipelines${query ? `?${query}` : ''}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/list_projects.ts b/apps/sim/tools/gitlab/list_projects.ts index ec8018215a4..b6d6dd4c4a7 100644 --- a/apps/sim/tools/gitlab/list_projects.ts +++ b/apps/sim/tools/gitlab/list_projects.ts @@ -1,4 +1,5 @@ import type { GitLabListProjectsParams, GitLabListProjectsResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabListProjectsTool: ToolConfig< @@ -17,6 +18,12 @@ export const gitlabListProjectsTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, owned: { type: 'boolean', required: false, @@ -80,7 +87,7 @@ export const gitlabListProjectsTool: ToolConfig< if (params.page) queryParams.append('page', String(params.page)) const query = queryParams.toString() - return `https://gitlab.com/api/v4/projects${query ? `?${query}` : ''}` + return `${getGitLabApiBase(params.host)}/projects${query ? `?${query}` : ''}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/merge_merge_request.ts b/apps/sim/tools/gitlab/merge_merge_request.ts index d63686a16f4..500e6ebfd07 100644 --- a/apps/sim/tools/gitlab/merge_merge_request.ts +++ b/apps/sim/tools/gitlab/merge_merge_request.ts @@ -2,6 +2,7 @@ import type { GitLabMergeMergeRequestParams, GitLabMergeMergeRequestResponse, } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabMergeMergeRequestTool: ToolConfig< @@ -20,6 +21,12 @@ export const gitlabMergeMergeRequestTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -67,7 +74,7 @@ export const gitlabMergeMergeRequestTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/merge_requests/${params.mergeRequestIid}/merge` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/merge_requests/${params.mergeRequestIid}/merge` }, method: 'PUT', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/retry_pipeline.ts b/apps/sim/tools/gitlab/retry_pipeline.ts index 3c0fe6f2b4d..48143109c97 100644 --- a/apps/sim/tools/gitlab/retry_pipeline.ts +++ b/apps/sim/tools/gitlab/retry_pipeline.ts @@ -1,4 +1,5 @@ import type { GitLabRetryPipelineParams, GitLabRetryPipelineResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabRetryPipelineTool: ToolConfig< @@ -17,6 +18,12 @@ export const gitlabRetryPipelineTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -34,7 +41,7 @@ export const gitlabRetryPipelineTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/pipelines/${params.pipelineId}/retry` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/pipelines/${params.pipelineId}/retry` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index 9722c16422b..af865ed3ef8 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -194,6 +194,11 @@ interface GitLabMilestone { interface GitLabBaseParams { accessToken: string + /** + * Self-managed GitLab host (e.g. `gitlab.example.com`). Optional — defaults to + * `gitlab.com` so existing workflows keep working. + */ + host?: string } // ===== Project Parameters ===== diff --git a/apps/sim/tools/gitlab/update_issue.ts b/apps/sim/tools/gitlab/update_issue.ts index 27c1fb70164..acf7ca25402 100644 --- a/apps/sim/tools/gitlab/update_issue.ts +++ b/apps/sim/tools/gitlab/update_issue.ts @@ -1,4 +1,5 @@ import type { GitLabUpdateIssueParams, GitLabUpdateIssueResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabUpdateIssueTool: ToolConfig = @@ -15,6 +16,12 @@ export const gitlabUpdateIssueTool: ToolConfig { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/issues/${params.issueIid}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/issues/${params.issueIid}` }, method: 'PUT', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/update_merge_request.ts b/apps/sim/tools/gitlab/update_merge_request.ts index c02d4f13b08..69632a637d5 100644 --- a/apps/sim/tools/gitlab/update_merge_request.ts +++ b/apps/sim/tools/gitlab/update_merge_request.ts @@ -2,6 +2,7 @@ import type { GitLabUpdateMergeRequestParams, GitLabUpdateMergeRequestResponse, } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabUpdateMergeRequestTool: ToolConfig< @@ -20,6 +21,12 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< visibility: 'user-only', description: 'GitLab Personal Access Token', }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, projectId: { type: 'string', required: true, @@ -97,7 +104,7 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< request: { url: (params) => { const encodedId = encodeURIComponent(String(params.projectId)) - return `https://gitlab.com/api/v4/projects/${encodedId}/merge_requests/${params.mergeRequestIid}` + return `${getGitLabApiBase(params.host)}/projects/${encodedId}/merge_requests/${params.mergeRequestIid}` }, method: 'PUT', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts new file mode 100644 index 00000000000..f7eca36aef8 --- /dev/null +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getGitLabApiBase, normalizeGitLabHost, UnsafeGitLabHostError } from '@/tools/gitlab/utils' + +describe('normalizeGitLabHost', () => { + it('defaults to gitlab.com when the host is empty, blank, or not a string', () => { + expect(normalizeGitLabHost(undefined)).toBe('gitlab.com') + expect(normalizeGitLabHost(null)).toBe('gitlab.com') + expect(normalizeGitLabHost('')).toBe('gitlab.com') + expect(normalizeGitLabHost(' ')).toBe('gitlab.com') + expect(normalizeGitLabHost(42)).toBe('gitlab.com') + }) + + it('strips protocol and trailing slashes from a self-managed host', () => { + expect(normalizeGitLabHost('gitlab.example.com')).toBe('gitlab.example.com') + expect(normalizeGitLabHost('https://gitlab.example.com')).toBe('gitlab.example.com') + expect(normalizeGitLabHost('http://gitlab.example.com/')).toBe('gitlab.example.com') + expect(normalizeGitLabHost(' https://gitlab.example.com// ')).toBe('gitlab.example.com') + }) + + it('preserves an explicit port and IDN punycode labels', () => { + expect(normalizeGitLabHost('gitlab.example.com:8443')).toBe('gitlab.example.com:8443') + expect(normalizeGitLabHost('xn--80ak6aa92e.com')).toBe('xn--80ak6aa92e.com') + }) + + it('rejects hosts that could redirect the request authority (SSRF / token exfiltration)', () => { + const unsafe = [ + 'legit.com@evil.com', + 'user:pass@evil.com', + 'gitlab.com#@evil.com', + 'gitlab.com /api', + 'line\nbreak.com', + 'evil.com/path', + 'evil.com?x=1', + '[::1]', + 'a..b.com', + '.gitlab.com', + 'gitlab.com.', + ] + for (const host of unsafe) { + expect(() => normalizeGitLabHost(host), host).toThrow(UnsafeGitLabHostError) + } + }) + + it('accepts bare IP literals at the STRUCTURAL layer by design (private/metadata IPs are rejected later by the fetch-layer DNS guard)', () => { + // This guard is structural only — it prevents authority confusion (userinfo, + // path, whitespace). SSRF to private/loopback/metadata addresses is the + // responsibility of validateUrlWithDNS / secureFetchWithValidation at fetch + // time, the single SSRF chokepoint shared by tools, webhooks, and connectors. + // These hosts are therefore structurally valid here, then blocked at fetch. + expect(normalizeGitLabHost('127.0.0.1')).toBe('127.0.0.1') + expect(normalizeGitLabHost('169.254.169.254')).toBe('169.254.169.254') + expect(normalizeGitLabHost('localhost')).toBe('localhost') + }) +}) + +describe('getGitLabApiBase', () => { + it('builds the v4 REST base for the default and self-managed hosts', () => { + expect(getGitLabApiBase(undefined)).toBe('https://gitlab.com/api/v4') + expect(getGitLabApiBase('gitlab.example.com')).toBe('https://gitlab.example.com/api/v4') + expect(getGitLabApiBase('https://gitlab.example.com:8443/')).toBe( + 'https://gitlab.example.com:8443/api/v4' + ) + }) + + it('propagates rejection of unsafe hosts', () => { + expect(() => getGitLabApiBase('legit.com@evil.com')).toThrow(UnsafeGitLabHostError) + }) +}) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts new file mode 100644 index 00000000000..6334a7030ee --- /dev/null +++ b/apps/sim/tools/gitlab/utils.ts @@ -0,0 +1,68 @@ +const DEFAULT_GITLAB_HOST = 'gitlab.com' + +/** + * Error thrown when a user-supplied GitLab host is structurally unsafe to use + * as the target of a server-side request that carries the user's access token. + */ +export class UnsafeGitLabHostError extends Error { + constructor(rawHost: string) { + super(`Invalid GitLab host: ${rawHost}`) + this.name = 'UnsafeGitLabHostError' + } +} + +/** + * Rejects a host that is structurally unsafe to fetch with the caller's token. + * + * The host is later interpolated into `https:///api/v4`, so anything that + * could change the request's authority (userinfo `@`, an embedded path/query/ + * fragment, whitespace, or control characters) must be rejected to prevent the + * `PRIVATE-TOKEN` header from being sent to an attacker-controlled origin. The + * allowed alphabet is hostname labels plus an optional `:port`, so self-managed + * hosts such as `gitlab.example.com` or `gitlab.example.com:8443` keep working. + * This is a structural guard only; DNS-based private-IP/SSRF checks remain the + * responsibility of the fetch layer. + */ +function assertSafeGitLabHostString(host: string, rawHost: string): void { + const hostnameWithoutPort = host.replace(/:\d+$/, '') + const allowedHostChars = /^[A-Za-z0-9.-]+$/ + if (!allowedHostChars.test(hostnameWithoutPort)) { + throw new UnsafeGitLabHostError(rawHost) + } + if (hostnameWithoutPort.startsWith('.') || hostnameWithoutPort.endsWith('.')) { + throw new UnsafeGitLabHostError(rawHost) + } + if (hostnameWithoutPort.split('.').some((label) => label.length === 0)) { + throw new UnsafeGitLabHostError(rawHost) + } +} + +/** + * Normalizes a GitLab host value: trims whitespace, strips any protocol prefix + * and trailing slashes, validates that the result is a bare host (optionally + * with a port), and falls back to gitlab.com when empty. Mirrors the GitLab + * connector so tools, triggers, and connectors resolve hosts identically. + * + * @throws {UnsafeGitLabHostError} when a non-empty host is structurally unsafe. + */ +export function normalizeGitLabHost(rawHost: unknown): string { + const raw = typeof rawHost === 'string' ? rawHost.trim() : '' + if (!raw) return DEFAULT_GITLAB_HOST + const host = raw + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '') + .trim() + if (!host) return DEFAULT_GITLAB_HOST + assertSafeGitLabHostString(host, String(rawHost)) + return host +} + +/** + * Builds the REST API v4 base URL for the configured host. Defaults to + * gitlab.com so existing workflows that never set a host keep working. + * + * @throws {UnsafeGitLabHostError} when a non-empty host is structurally unsafe. + */ +export function getGitLabApiBase(rawHost: unknown): string { + return `https://${normalizeGitLabHost(rawHost)}/api/v4` +} diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts index 6f7848ad2fe..a25d09e3447 100644 --- a/apps/sim/triggers/gitlab/utils.ts +++ b/apps/sim/triggers/gitlab/utils.ts @@ -103,6 +103,15 @@ export function buildGitLabExtraFields(triggerId: string): SubBlockConfig[] { mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, + { + id: 'host', + title: 'GitLab Host', + type: 'short-input', + placeholder: 'gitlab.com', + description: 'Self-managed GitLab host. Leave blank for gitlab.com.', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, ] } diff --git a/apps/sim/triggers/slack/capabilities.ts b/apps/sim/triggers/slack/capabilities.ts index 1a532e4ebf2..317c240621f 100644 --- a/apps/sim/triggers/slack/capabilities.ts +++ b/apps/sim/triggers/slack/capabilities.ts @@ -105,16 +105,7 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ scopes: ['channels:history', 'groups:history', 'im:history', 'mpim:history'], events: [], }, - { - id: 'action_assistant', - label: 'Manage assistant threads', - description: - "Let the bot set the status indicator (the 'is thinking…' shimmer), title, and suggested prompts on AI app threads.", - defaultChecked: true, - group: 'action', - scopes: ['assistant:write'], - events: [], - }, + // TODO: Restore the 'action_assistant' capability (scope 'assistant:write') once Slack app review is approved { id: 'action_read_files', label: 'Read file attachments',