Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/sim/lib/internal/github/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: mocks.validateUrlWithDNS,
}))

import { GitHubOperationError } from '@/lib/internal/github/errors'
import { getGitHubLatestCommit } from '@/lib/internal/github/operations'

describe('getGitHubLatestCommit', () => {
Expand Down Expand Up @@ -61,3 +62,40 @@ describe('getGitHubLatestCommit', () => {
)
})
})

/**
* The path guards throw a plain `Error`, which `executeGitHubTool` maps to 500.
* Every value they reject is caller-supplied, so it must surface as a 400 with
* the guard's own named message rather than as a server failure.
*/
describe('getGitHubLatestCommit path validation', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.validateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.1' })
})

it.each([
['..', 'path traversal is not allowed'],
['../../orgs/secret', 'cannot contain a path separator'],
])('reports owner %j as a client error, not a server failure', async (owner, message) => {
const error = await getGitHubLatestCommit(
{ owner, repo: 'sim', apiKey: 'token' },
{ requestId: 'request-1' }
).catch((caught: unknown) => caught)

expect(error).toBeInstanceOf(GitHubOperationError)
expect((error as GitHubOperationError).status).toBe(400)
expect((error as GitHubOperationError).message).toContain(message)
expect(mocks.secureFetchWithPinnedIP).not.toHaveBeenCalled()
})

it('rejects a branch that is a bare dot segment', async () => {
const error = await getGitHubLatestCommit(
{ owner: 'simstudioai', repo: 'sim', branch: '..', apiKey: 'token' },
{ requestId: 'request-1' }
).catch((caught: unknown) => caught)

expect(error).toBeInstanceOf(GitHubOperationError)
expect((error as GitHubOperationError).status).toBe(400)
})
})
50 changes: 45 additions & 5 deletions apps/sim/lib/internal/github/operations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import {
secureFetchWithPinnedIP,
Expand All @@ -19,6 +20,11 @@ import type {
} from '@/tools/github/types'
import { secureGitHubRequest } from '@/tools/github/utils.server'
import type { ToolResponse } from '@/tools/types'
import {
safeEncodedUrlPathSegment,
safeUrlPathSegment,
strictUrlPathSegment,
} from '@/tools/url-path'

const logger = createLogger('GitHubLatestCommitOperation')
const MAX_COMMIT_RESPONSE_BYTES = 10 * 1024 * 1024
Expand Down Expand Up @@ -97,8 +103,40 @@ function githubHeaders(apiKey: string): Record<string, string> {
}
}

/**
* Runs the path guards for one provider URL, reporting a rejected value as a
* client error.
*
* The guards in `@/tools/url-path` throw a plain `Error`, and the catch in
* `executeGitHubTool` maps anything that is not a `GitHubOperationError` to
* 500. Every value they reject is caller-supplied — an `owner` of `..`, a
* `branch` carrying a separator — so reporting it as a server failure both
* misattributes the fault and hides the guard's message behind a generic
* status. 400 is the accurate answer, and it keeps the named
* "<param> cannot be ..." text reaching the caller who can act on it.
*/
function buildGuardedUrl(build: () => string): string {
try {
return build()
} catch (error) {
throw new GitHubOperationError(getErrorMessage(error, 'Invalid GitHub request path'), 400)
}
}

/**
* The pull-request URL, and the base for the comment and review URLs built from
* it.
*
* Uses the strict guards even though this same URL is also fetched with a GET
* to read the head SHA: every caller reaches it on the way to creating a
* comment or a review, so the operation as a whole changes state and must not
* have a padded identifier quietly resolved to a real pull request.
*/
function pullRequestUrl(params: CreateCommentParams): string {
return `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}`
return buildGuardedUrl(
() =>
`${GITHUB_API_BASE}/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/pulls/${strictUrlPathSegment(params.pullNumber, 'pullNumber')}`
)
}

function isFileCommentRequest(params: CreateCommentParams): boolean {
Expand Down Expand Up @@ -352,10 +390,12 @@ export async function getGitHubLatestCommit(
context: GitHubOperationContext
): Promise<LatestCommitResponse> {
context.signal?.throwIfAborted()
const owner = encodeURIComponent(input.owner)
const repo = encodeURIComponent(input.repo)
const revision = encodeURIComponent(input.branch || 'HEAD')
const commitUrl = `https://api.github.com/repos/${owner}/${repo}/commits/${revision}`
const commitUrl = buildGuardedUrl(() => {
const owner = safeUrlPathSegment(input.owner, 'owner')
const repo = safeUrlPathSegment(input.repo, 'repo')
const revision = safeEncodedUrlPathSegment(input.branch || 'HEAD', 'branch')
return `https://api.github.com/repos/${owner}/${repo}/commits/${revision}`
})
const validation = await validateUrlWithDNS(commitUrl, 'commitUrl')
context.signal?.throwIfAborted()
if (!validation.isValid || !validation.resolvedIP) {
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/add_assignees.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AddAssigneesParams, IssueResponse } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const addAssigneesTool: ToolConfig<AddAssigneesParams, IssueResponse> = {
id: 'github_add_assignees',
Expand Down Expand Up @@ -42,7 +43,7 @@ export const addAssigneesTool: ToolConfig<AddAssigneesParams, IssueResponse> = {

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/issues/${params.issue_number}/assignees`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/issues/${strictUrlPathSegment(params.issue_number, 'issue_number')}/assignees`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/add_labels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AddLabelsParams, LabelsResponse } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const addLabelsTool: ToolConfig<AddLabelsParams, LabelsResponse> = {
id: 'github_add_labels',
Expand Down Expand Up @@ -42,7 +43,7 @@ export const addLabelsTool: ToolConfig<AddLabelsParams, LabelsResponse> = {

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/issues/${params.issue_number}/labels`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/issues/${strictUrlPathSegment(params.issue_number, 'issue_number')}/labels`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/cancel_workflow_run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CancelWorkflowRunParams, CancelWorkflowRunResponse } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const cancelWorkflowRunTool: ToolConfig<CancelWorkflowRunParams, CancelWorkflowRunResponse> =
{
Expand Down Expand Up @@ -38,7 +39,7 @@ export const cancelWorkflowRunTool: ToolConfig<CancelWorkflowRunParams, CancelWo

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/actions/runs/${params.run_id}/cancel`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/actions/runs/${strictUrlPathSegment(params.run_id, 'run_id')}/cancel`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github+json',
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/tools/github/check_star.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ToolConfig } from '@/tools/types'
import { safeUrlPathSegment } from '@/tools/url-path'

interface CheckStarParams {
owner: string
Expand Down Expand Up @@ -46,7 +47,8 @@ export const checkStarTool: ToolConfig<CheckStarParams, CheckStarResponse> = {
},

request: {
url: (params) => `https://api.github.com/user/starred/${params.owner}/${params.repo}`,
url: (params) =>
`https://api.github.com/user/starred/${safeUrlPathSegment(params.owner, 'owner')}/${safeUrlPathSegment(params.repo, 'repo')}`,
method: 'GET',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/close_issue.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CloseIssueParams, IssueResponse } from '@/tools/github/types'
import { ISSUE_OUTPUT_PROPERTIES, LABEL_OUTPUT, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const closeIssueTool: ToolConfig<CloseIssueParams, IssueResponse> = {
id: 'github_close_issue',
Expand Down Expand Up @@ -43,7 +44,7 @@ export const closeIssueTool: ToolConfig<CloseIssueParams, IssueResponse> = {

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/issues/${params.issue_number}`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/issues/${strictUrlPathSegment(params.issue_number, 'issue_number')}`,
method: 'PATCH',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/close_pr.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ClosePRParams, PRResponse } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const closePRTool: ToolConfig<ClosePRParams, PRResponse> = {
id: 'github_close_pr',
Expand Down Expand Up @@ -36,7 +37,7 @@ export const closePRTool: ToolConfig<ClosePRParams, PRResponse> = {

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/pulls/${strictUrlPathSegment(params.pullNumber, 'pullNumber')}`,
method: 'PATCH',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/compare_commits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
USER_FULL_OUTPUT,
} from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { safeUrlPath, safeUrlPathSegment } from '@/tools/url-path'

interface CompareCommitsParams {
owner: string
Expand Down Expand Up @@ -103,7 +104,7 @@ export const compareCommitsTool: ToolConfig<CompareCommitsParams, CompareCommits
request: {
url: (params) => {
const url = new URL(
`https://api.github.com/repos/${params.owner}/${params.repo}/compare/${params.base}...${params.head}`
`https://api.github.com/repos/${safeUrlPathSegment(params.owner, 'owner')}/${safeUrlPathSegment(params.repo, 'repo')}/compare/${safeUrlPath(params.base, 'base')}...${safeUrlPath(params.head, 'head')}`
)
if (params.per_page) url.searchParams.append('per_page', String(params.per_page))
if (params.page) url.searchParams.append('page', String(params.page))
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/tools/github/create_branch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CreateBranchParams, RefResponse } from '@/tools/github/types'
import { GIT_REF_OUTPUT_PROPERTIES } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const createBranchTool: ToolConfig<CreateBranchParams, RefResponse> = {
id: 'github_create_branch',
Expand Down Expand Up @@ -43,7 +44,8 @@ export const createBranchTool: ToolConfig<CreateBranchParams, RefResponse> = {
},

request: {
url: (params) => `https://api.github.com/repos/${params.owner}/${params.repo}/git/refs`,
url: (params) =>
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/git/refs`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/create_comment_reaction.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { REACTION_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

interface CreateCommentReactionParams {
owner: string
Expand Down Expand Up @@ -67,7 +68,7 @@ export const createCommentReactionTool: ToolConfig<

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/issues/comments/${params.comment_id}/reactions`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/issues/comments/${strictUrlPathSegment(params.comment_id, 'comment_id')}/reactions`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github.squirrel-girl-preview+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/create_file.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CreateFileParams, FileOperationResponse } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { safeUrlPath, strictUrlPathSegment } from '@/tools/url-path'

export const createFileTool: ToolConfig<CreateFileParams, FileOperationResponse> = {
id: 'github_create_file',
Expand Down Expand Up @@ -55,7 +56,7 @@ export const createFileTool: ToolConfig<CreateFileParams, FileOperationResponse>

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/contents/${params.path}`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/contents/${safeUrlPath(params.path, 'path')}`,
method: 'PUT',
headers: (params) => ({
Accept: 'application/vnd.github+json',
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/tools/github/create_issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
USER_OUTPUT,
} from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const createIssueTool: ToolConfig<CreateIssueParams, IssueResponse> = {
id: 'github_create_issue',
Expand Down Expand Up @@ -65,7 +66,8 @@ export const createIssueTool: ToolConfig<CreateIssueParams, IssueResponse> = {
},

request: {
url: (params) => `https://api.github.com/repos/${params.owner}/${params.repo}/issues`,
url: (params) =>
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/issues`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/create_issue_reaction.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { REACTION_OUTPUT_PROPERTIES, USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

interface CreateIssueReactionParams {
owner: string
Expand Down Expand Up @@ -67,7 +68,7 @@ export const createIssueReactionTool: ToolConfig<

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/issues/${params.issue_number}/reactions`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/issues/${strictUrlPathSegment(params.issue_number, 'issue_number')}/reactions`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github.squirrel-girl-preview+json',
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/tools/github/create_milestone.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MILESTONE_CREATOR_OUTPUT, MILESTONE_V2_OUTPUT_PROPERTIES } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

interface CreateMilestoneParams {
owner: string
Expand Down Expand Up @@ -83,7 +84,8 @@ export const createMilestoneTool: ToolConfig<CreateMilestoneParams, CreateMilest
},

request: {
url: (params) => `https://api.github.com/repos/${params.owner}/${params.repo}/milestones`,
url: (params) =>
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/milestones`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/tools/github/create_pr.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CreatePRParams, PRResponse } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const createPRTool: ToolConfig<CreatePRParams, PRResponse> = {
id: 'github_create_pr',
Expand Down Expand Up @@ -59,7 +60,8 @@ export const createPRTool: ToolConfig<CreatePRParams, PRResponse> = {
},

request: {
url: (params) => `https://api.github.com/repos/${params.owner}/${params.repo}/pulls`,
url: (params) =>
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/pulls`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github.v3+json',
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/tools/github/create_pr_review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from '@/tools/github/types'
import { USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig, ToolResponse } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i

Expand Down Expand Up @@ -159,7 +160,7 @@ export const createPRReviewTool: ToolConfig<CreatePRReviewParams, PRReviewRespon

request: {
url: (params) =>
`https://api.github.com/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}/reviews`,
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/pulls/${strictUrlPathSegment(params.pullNumber, 'pullNumber')}/reviews`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github+json',
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/tools/github/create_release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
USER_OUTPUT,
} from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
import { strictUrlPathSegment } from '@/tools/url-path'

export const createReleaseTool: ToolConfig<CreateReleaseParams, ReleaseResponse> = {
id: 'github_create_release',
Expand Down Expand Up @@ -75,7 +76,8 @@ export const createReleaseTool: ToolConfig<CreateReleaseParams, ReleaseResponse>
},

request: {
url: (params) => `https://api.github.com/repos/${params.owner}/${params.repo}/releases`,
url: (params) =>
`https://api.github.com/repos/${strictUrlPathSegment(params.owner, 'owner')}/${strictUrlPathSegment(params.repo, 'repo')}/releases`,
method: 'POST',
headers: (params) => ({
Accept: 'application/vnd.github+json',
Expand Down
Loading
Loading