mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
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:
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user