Skip to content

Commit 3fa67ab

Browse files
committed
fix: close PR allowlist mutation gaps
1 parent c14be78 commit 3fa67ab

10 files changed

Lines changed: 130 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1512,7 +1512,7 @@ GITHUB_ALLOWED_PR_AUTHORS='renovate[bot],github-actions[bot]' ./github-mcp-serve
15121512

15131513
When set, tools such as `merge_pull_request`, `update_pull_request`, review-write tools, and PR branch updates fetch the target PR and reject the call unless `pr.User.Login` is in the allowlist. Read-only PR tools and `create_pull_request` are not restricted. `actions_run_trigger` is not gated by this setting because it targets a ref rather than a PR number.
15141514

1515-
In HTTP mode, `GITHUB_PERSONAL_ACCESS_TOKEN` can also be used as a server-side default token for trusted local deployments. Requests with an `Authorization` header still use the request token; requests without one fall back to the configured server token.
1515+
In HTTP mode, `GITHUB_PERSONAL_ACCESS_TOKEN` can also be used as a server-side default token for trusted local deployments. Requests with an `Authorization` header still use the request token; requests without one fall back to the configured server token. This means the server's GitHub identity is used for any unauthenticated HTTP request, so only enable this when the HTTP endpoint is on a trusted network.
15161516

15171517
## i18n / Overriding Descriptions
15181518

docs/server-configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ When set, mutating pull request tools first fetch the target pull request and ch
413413
}
414414
```
415415

416-
Known limitations: `actions_run_trigger` operates on refs, not pull request numbers, so it is not gated by this setting. The allowlist checks `pr.User.Login`; PRs from forks authored by allowed bots still pass. Enabling the allowlist adds one API call before a mutating PR operation when the handler does not already have the pull request.
416+
Known limitations: `actions_run_trigger` operates on refs, not pull request numbers, so it is not gated by this setting. Review-thread resolve and unresolve tools take only opaque thread IDs and are not gated by the PR author allowlist. The allowlist checks `pr.User.Login`; PRs from forks authored by allowed bots still pass. Enabling the allowlist adds one API call before a mutating PR operation when the handler does not already have the pull request.
417417

418418
---
419419

docs/streamable-http.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,5 @@ GITHUB_PERSONAL_ACCESS_TOKEN=ghp_yourtokenhere github-mcp-server http
101101
```
102102

103103
If a request includes `Authorization: Bearer ...`, that request token takes precedence. If no request token is provided and no server-side token is configured, the server returns `401 Unauthorized`.
104+
105+
When this fallback is enabled, the server's GitHub identity is used for every HTTP request without an `Authorization` header. Only expose the endpoint on a trusted network.

internal/ghmcp/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ func RunStdioServer(cfg StdioServerConfig) error {
260260
}
261261
logger := slog.New(slogHandler)
262262
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
263+
if len(cfg.AllowedPRAuthors) > 0 {
264+
logger.Info("PR author allowlist enforced", "authors", cfg.AllowedPRAuthors)
265+
}
263266

264267
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
265268
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.

pkg/github/copilot.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,10 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server
507507
return utils.NewToolResultError(err.Error()), nil, nil
508508
}
509509

510+
if result, err := enforcePRAuthorAllowlist(ctx, deps, owner, repo, pullNumber, nil); result != nil || err != nil {
511+
return result, nil, err
512+
}
513+
510514
client, err := deps.GetClient(ctx)
511515
if err != nil {
512516
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil

pkg/github/copilot_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,3 +961,31 @@ func Test_RequestCopilotReview(t *testing.T) {
961961
})
962962
}
963963
}
964+
965+
func Test_RequestCopilotReview_PRAuthorDenied(t *testing.T) {
966+
serverTool := RequestCopilotReview(translations.NullTranslationHelper)
967+
client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
968+
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{
969+
User: &github.User{Login: github.Ptr("alice")},
970+
}),
971+
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
972+
t.Fatal("reviewer request endpoint should not be called when PR author is denied")
973+
},
974+
}))
975+
deps := BaseDeps{
976+
Client: client,
977+
allowedPRAuthors: buildPRAuthorAllowlist([]string{"renovate[bot]"}),
978+
}
979+
handler := serverTool.Handler(deps)
980+
request := createMCPRequest(map[string]any{
981+
"owner": "owner",
982+
"repo": "repo",
983+
"pullNumber": float64(42),
984+
})
985+
986+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
987+
988+
require.NoError(t, err)
989+
require.True(t, result.IsError)
990+
assert.Contains(t, getErrorResult(t, result).Text, `pull request author "alice" is not in --allowed-pr-authors`)
991+
}

pkg/github/issues.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,10 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
663663
if err != nil {
664664
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
665665
}
666+
if result, err := enforceIssueCommentPRAuthorAllowlist(ctx, deps, client, owner, repo, issueNumber); result != nil || err != nil {
667+
return result, nil, err
668+
}
669+
666670
createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment)
667671
if err != nil {
668672
return utils.NewToolResultErrorFromErr("failed to create comment", err), nil, nil

pkg/github/issues_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,41 @@ func Test_AddIssueComment(t *testing.T) {
386386
}
387387
}
388388

389+
func Test_AddIssueComment_PRAuthorDenied(t *testing.T) {
390+
serverTool := AddIssueComment(translations.NullTranslationHelper)
391+
client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
392+
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &github.Issue{
393+
Number: github.Ptr(42),
394+
PullRequestLinks: &github.PullRequestLinks{
395+
URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"),
396+
},
397+
}),
398+
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{
399+
User: &github.User{Login: github.Ptr("alice")},
400+
}),
401+
PostReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
402+
t.Fatal("issue comment endpoint should not be called when PR author is denied")
403+
},
404+
}))
405+
deps := BaseDeps{
406+
Client: client,
407+
allowedPRAuthors: buildPRAuthorAllowlist([]string{"renovate[bot]"}),
408+
}
409+
handler := serverTool.Handler(deps)
410+
request := createMCPRequest(map[string]any{
411+
"owner": "owner",
412+
"repo": "repo",
413+
"issue_number": float64(42),
414+
"body": "comment",
415+
})
416+
417+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
418+
419+
require.NoError(t, err)
420+
require.True(t, result.IsError)
421+
assert.Contains(t, getErrorResult(t, result).Text, `pull request author "alice" is not in --allowed-pr-authors`)
422+
}
423+
389424
func Test_SearchIssues(t *testing.T) {
390425
// Verify tool definition once
391426
serverTool := SearchIssues(translations.NullTranslationHelper)

pkg/github/pr_author_allowlist.go

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,7 @@ func enforcePRAuthorAllowlist(
5555
pullNumber int,
5656
pr *gogithub.PullRequest,
5757
) (*mcp.CallToolResult, error) {
58-
if allowed, enforced := deps.IsPRAuthorAllowed(""); !enforced {
59-
return nil, nil
60-
} else if allowed {
58+
if _, enforced := deps.IsPRAuthorAllowed(""); !enforced {
6159
return nil, nil
6260
}
6361

@@ -82,5 +80,50 @@ func enforcePRAuthorAllowlist(
8280
return nil, nil
8381
}
8482

83+
logPRAuthorAllowlistDenied(ctx, deps, owner, repo, pullNumber, login)
8584
return utils.NewToolResultError(fmt.Sprintf("pull request author %q is not in --allowed-pr-authors", login)), nil
8685
}
86+
87+
// enforceIssueCommentPRAuthorAllowlist applies the PR author allowlist when
88+
// an issue-comment target is actually a pull request.
89+
func enforceIssueCommentPRAuthorAllowlist(
90+
ctx context.Context,
91+
deps ToolDependencies,
92+
client *gogithub.Client,
93+
owner, repo string,
94+
issueNumber int,
95+
) (*mcp.CallToolResult, error) {
96+
if _, enforced := deps.IsPRAuthorAllowed(""); !enforced {
97+
return nil, nil
98+
}
99+
100+
issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
101+
if err != nil {
102+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get issue", resp, err), nil
103+
}
104+
if resp != nil && resp.Body != nil {
105+
defer func() { _ = resp.Body.Close() }()
106+
}
107+
108+
if issue.GetPullRequestLinks() == nil {
109+
return nil, nil
110+
}
111+
112+
return enforcePRAuthorAllowlist(ctx, deps, owner, repo, issueNumber, nil)
113+
}
114+
115+
func logPRAuthorAllowlistDenied(ctx context.Context, deps ToolDependencies, owner, repo string, pullNumber int, login string) {
116+
defer func() {
117+
_ = recover()
118+
}()
119+
120+
if logger := deps.Logger(ctx); logger != nil {
121+
logger.Warn(
122+
"PR mutation denied by allowlist",
123+
"owner", owner,
124+
"repo", repo,
125+
"pr", pullNumber,
126+
"author", login,
127+
)
128+
}
129+
}

pkg/http/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ func RunHTTPServer(cfg ServerConfig) error {
119119
}
120120
logger := slog.New(slogHandler)
121121
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "lockdownEnabled", cfg.LockdownMode, "readOnly", cfg.ReadOnly, "insidersMode", cfg.InsidersMode)
122+
if len(cfg.AllowedPRAuthors) > 0 {
123+
logger.Info("PR author allowlist enforced", "authors", cfg.AllowedPRAuthors)
124+
}
125+
if cfg.Token != "" {
126+
logger.Warn("HTTP default token fallback enabled; requests without Authorization use the server token. Only expose this endpoint on a trusted network.")
127+
}
122128

123129
apiHost, err := utils.NewAPIHost(cfg.Host)
124130
if err != nil {

0 commit comments

Comments
 (0)