feat(gitlab): support self-managed GitLab host across tools, block, triggers, webhook, and connector (#5200)

* feat(gitlab): support self-managed GitLab host across tools, block, triggers, webhook, and connector

Add an optional `host` so the GitLab integration can target a self-managed
instance (e.g. gitlab.example.com) instead of gitlab.com. Defaults to
gitlab.com everywhere, so existing workflows, blocks, triggers, and stored
webhooks are unchanged.

- Shared host helper (normalizeGitLabHost/getGitLabApiBase) used by all 19
  tools, the block, triggers, the webhook provider, and the connector
- SSRF hardening: reject structurally unsafe hosts (userinfo `@`, whitespace,
  control chars, embedded path/query, empty labels) before the token-bearing
  request is built; allow self-managed hosts, ports, and IDN punycode
- Route the webhook provider's previously-raw fetches through
  secureFetchWithValidation (DNS + private-IP rejection + IP pinning), matching
  the tool and connector paths
- Add regression tests for the host validator

* fix(gitlab): handle unsafe-host errors gracefully in webhook provider

Address review feedback:
- Validate the optional self-managed host up front in createSubscription and
  deleteSubscription so a structurally unsafe value surfaces as a clear error
  (create) or a graceful non-strict skip (delete) instead of an unhandled
  UnsafeGitLabHostError, mirroring the connector's handling
- Document the layered SSRF defense: bare IP literals pass the structural host
  guard by design and are rejected at the fetch layer (validateUrlWithDNS); add
  a confirming test group making that intent explicit

* fix(slack): drop assistant:write scope pending app review approval

Requesting assistant:write before Slack approved it fails the OAuth/install
flow for users. Remove it from both request paths until approval lands:
- Remove from the user OAuth scope list (oauth.ts), matching the existing
  users:read.email TODO pattern
- Remove the action_assistant capability from the bot manifest generator
  (capabilities.ts), leaving a TODO to restore it after approval

The set_status/set_title/set_suggested_prompts tools remain and surface their
existing graceful "reconnect with assistant:write" message until re-enabled.
This commit is contained in:
Waleed
2026-06-24 11:37:06 -07:00
committed by GitHub
parent 038e8f0d84
commit 067f9e9dc9
28 changed files with 388 additions and 53 deletions
+11
View File
@@ -57,6 +57,15 @@ export const GitLabBlock: BlockConfig<GitLabResponse> = {
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<string, any> = {
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' },
+21 -10
View File
@@ -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)
+1 -1
View File
@@ -711,7 +711,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'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',
+49 -13
View File
@@ -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<string, unknown> {
return (value as Record<string, unknown>) || {}
}
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<void> {
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}`)
+8 -1
View File
@@ -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) => ({
+8 -1
View File
@@ -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<GitLabCreateIssueParams, GitLabCreateIssueResponse> =
@@ -15,6 +16,12 @@ export const gitlabCreateIssueTool: ToolConfig<GitLabCreateIssueParams, GitLabCr
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,
@@ -68,7 +75,7 @@ export const gitlabCreateIssueTool: ToolConfig<GitLabCreateIssueParams, GitLabCr
request: {
url: (params) => {
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) => ({
+8 -1
View File
@@ -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) => ({
@@ -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) => ({
@@ -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) => ({
+8 -1
View File
@@ -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) => ({
+8 -1
View File
@@ -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<GitLabDeleteIssueParams, GitLabDeleteIssueResponse> =
@@ -15,6 +16,12 @@ export const gitlabDeleteIssueTool: ToolConfig<GitLabDeleteIssueParams, GitLabDe
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,
@@ -32,7 +39,7 @@ export const gitlabDeleteIssueTool: ToolConfig<GitLabDeleteIssueParams, GitLabDe
request: {
url: (params) => {
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) => ({
+8 -1
View File
@@ -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<GitLabGetIssueParams, GitLabGetIssueResponse> = {
@@ -14,6 +15,12 @@ export const gitlabGetIssueTool: ToolConfig<GitLabGetIssueParams, GitLabGetIssue
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,
@@ -31,7 +38,7 @@ export const gitlabGetIssueTool: ToolConfig<GitLabGetIssueParams, GitLabGetIssue
request: {
url: (params) => {
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) => ({
+8 -1
View File
@@ -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) => ({
+8 -1
View File
@@ -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<GitLabGetPipelineParams, GitLabGetPipelineResponse> =
@@ -15,6 +16,12 @@ export const gitlabGetPipelineTool: ToolConfig<GitLabGetPipelineParams, GitLabGe
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,
@@ -32,7 +39,7 @@ export const gitlabGetPipelineTool: ToolConfig<GitLabGetPipelineParams, GitLabGe
request: {
url: (params) => {
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) => ({
+8 -1
View File
@@ -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<GitLabGetProjectParams, GitLabGetProjectResponse> = {
@@ -14,6 +15,12 @@ export const gitlabGetProjectTool: ToolConfig<GitLabGetProjectParams, GitLabGetP
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,
@@ -25,7 +32,7 @@ export const gitlabGetProjectTool: ToolConfig<GitLabGetProjectParams, GitLabGetP
request: {
url: (params) => {
const encodedId = encodeURIComponent(String(params.projectId))
return `https://gitlab.com/api/v4/projects/${encodedId}`
return `${getGitLabApiBase(params.host)}/projects/${encodedId}`
},
method: 'GET',
headers: (params) => ({
+8 -1
View File
@@ -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<GitLabListIssuesParams, GitLabListIssuesResponse> = {
@@ -14,6 +15,12 @@ export const gitlabListIssuesTool: ToolConfig<GitLabListIssuesParams, GitLabList
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,
@@ -92,7 +99,7 @@ export const gitlabListIssuesTool: ToolConfig<GitLabListIssuesParams, GitLabList
if (params.page) queryParams.append('page', String(params.page))
const query = queryParams.toString()
return `https://gitlab.com/api/v4/projects/${encodedId}/issues${query ? `?${query}` : ''}`
return `${getGitLabApiBase(params.host)}/projects/${encodedId}/issues${query ? `?${query}` : ''}`
},
method: 'GET',
headers: (params) => ({
+8 -1
View File
@@ -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) => ({
+8 -1
View File
@@ -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) => ({
+8 -1
View File
@@ -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) => ({
+8 -1
View File
@@ -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) => ({
+8 -1
View File
@@ -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) => ({
+5
View File
@@ -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 =====
+8 -1
View File
@@ -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<GitLabUpdateIssueParams, GitLabUpdateIssueResponse> =
@@ -15,6 +16,12 @@ export const gitlabUpdateIssueTool: ToolConfig<GitLabUpdateIssueParams, GitLabUp
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,
@@ -80,7 +87,7 @@ export const gitlabUpdateIssueTool: ToolConfig<GitLabUpdateIssueParams, GitLabUp
request: {
url: (params) => {
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) => ({
@@ -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) => ({
+71
View File
@@ -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)
})
})
+68
View File
@@ -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://<host>/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`
}
+9
View File
@@ -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 },
},
]
}
+1 -10
View File
@@ -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',