Skip to content

Commit dde00d0

Browse files
feat(auth): challenge workflow file writes for scope
Add per-call OAuth scope resolution for workflow paths and reject unsafe repository-relative paths before file writes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 8ec6249 commit dde00d0

13 files changed

Lines changed: 484 additions & 50 deletions

pkg/github/header_params_test.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import (
1010
)
1111

1212
// TestAllToolsRoutingParamsGetHeaders enforces that every tool exposing a
13-
// routing-relevant param (owner/repo, per inventory.HeaderParams) has it
13+
// routing-relevant param (owner/repo/path, per inventory.HeaderParams) has it
1414
// projected to an Mcp-Param-* header. This guards the per-request header
1515
// optimization used by the remote proxy: a future tool must not silently ship
16-
// without its owner/repo header, so it can never fall back to body re-parsing.
16+
// without its top-level routing headers, so it can never fall back to body re-parsing.
1717
func TestAllToolsRoutingParamsGetHeaders(t *testing.T) {
1818
inv, err := NewInventory(stubTranslator).WithToolsets([]string{"all"}).Build()
1919
require.NoError(t, err)
@@ -40,5 +40,13 @@ func TestAllToolsRoutingParamsGetHeaders(t *testing.T) {
4040
checked++
4141
}
4242
}
43-
require.Positive(t, checked, "expected at least one owner/repo param across all toolsets")
43+
require.Positive(t, checked, "expected at least one routing param across all toolsets")
44+
}
45+
46+
func TestPushFilesNestedPathsRemainBodyParsed(t *testing.T) {
47+
tool := PushFiles(stubTranslator).Tool
48+
inventory.AnnotateHeaderParams(&tool)
49+
50+
schema := tool.InputSchema.(*jsonschema.Schema)
51+
require.Nil(t, schema.Properties["files"].Items.Properties["path"].Extra)
4452
}

pkg/github/repositories.go

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool {
404404

405405
// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
406406
func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTool {
407-
return NewTool(
407+
tool := NewTool(
408408
ToolsetMetadataRepos,
409409
mcp.Tool{
410410
Name: "create_or_update_file",
@@ -469,6 +469,10 @@ SHA MUST be provided for existing file updates.
469469
if err != nil {
470470
return utils.NewToolResultError(err.Error()), nil, nil
471471
}
472+
path, err = validateRelativePath(path)
473+
if err != nil {
474+
return utils.NewToolResultError(fmt.Sprintf("invalid path: %s", err)), nil, nil
475+
}
472476
content, err := RequiredParam[string](args, "content")
473477
if err != nil {
474478
return utils.NewToolResultError(err.Error()), nil, nil
@@ -507,8 +511,6 @@ SHA MUST be provided for existing file updates.
507511
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
508512
}
509513

510-
path = strings.TrimPrefix(path, "/")
511-
512514
// SHA validation using Contents API to fetch current file metadata (blob SHA)
513515
getOpts := &github.RepositoryContentGetOptions{Ref: branch}
514516

@@ -596,6 +598,8 @@ SHA MUST be provided for existing file updates.
596598
return MarshalledTextResult(minimalResponse), nil, nil
597599
},
598600
)
601+
tool.ScopeResolver = workflowScopeForPath
602+
return tool
599603
}
600604

601605
// CreateRepository creates a tool to create a new GitHub repository.
@@ -1244,7 +1248,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool {
12441248
// The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app,
12451249
// both of which suit an LLM well.
12461250
func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool {
1247-
return NewTool(
1251+
tool := NewTool(
12481252
ToolsetMetadataRepos,
12491253
mcp.Tool{
12501254
Name: "delete_file",
@@ -1295,6 +1299,10 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool {
12951299
if err != nil {
12961300
return utils.NewToolResultError(err.Error()), nil, nil
12971301
}
1302+
path, err = validateRelativePath(path)
1303+
if err != nil {
1304+
return utils.NewToolResultError(fmt.Sprintf("invalid path: %s", err)), nil, nil
1305+
}
12981306
message, err := RequiredParam[string](args, "message")
12991307
if err != nil {
13001308
return utils.NewToolResultError(err.Error()), nil, nil
@@ -1425,6 +1433,8 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool {
14251433
return utils.NewToolResultText(string(r)), nil, nil
14261434
},
14271435
)
1436+
tool.ScopeResolver = workflowScopeForPath
1437+
return tool
14281438
}
14291439

14301440
// CreateBranch creates a tool to create a new branch.
@@ -1542,7 +1552,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool {
15421552

15431553
// PushFiles creates a tool to push multiple files in a single commit to a GitHub repository.
15441554
func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
1545-
return NewTool(
1555+
tool := NewTool(
15461556
ToolsetMetadataRepos,
15471557
mcp.Tool{
15481558
Name: "push_files",
@@ -1618,6 +1628,35 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
16181628
return utils.NewToolResultError("files parameter must be an array of objects with path and content"), nil, nil
16191629
}
16201630

1631+
entries := make([]*github.TreeEntry, 0, len(filesObj))
1632+
for _, file := range filesObj {
1633+
fileMap, ok := file.(map[string]any)
1634+
if !ok {
1635+
return utils.NewToolResultError("each file must be an object with path and content"), nil, nil
1636+
}
1637+
1638+
filePath, ok := fileMap["path"].(string)
1639+
if !ok || filePath == "" {
1640+
return utils.NewToolResultError("each file must have a path"), nil, nil
1641+
}
1642+
filePath, err = validateRelativePath(filePath)
1643+
if err != nil {
1644+
return utils.NewToolResultError(fmt.Sprintf("invalid file path: %s", err)), nil, nil
1645+
}
1646+
1647+
content, ok := fileMap["content"].(string)
1648+
if !ok {
1649+
return utils.NewToolResultError("each file must have content"), nil, nil
1650+
}
1651+
1652+
entries = append(entries, &github.TreeEntry{
1653+
Path: github.Ptr(filePath),
1654+
Mode: github.Ptr("100644"),
1655+
Type: github.Ptr("blob"),
1656+
Content: github.Ptr(content),
1657+
})
1658+
}
1659+
16211660
client, err := deps.GetClient(ctx)
16221661
if err != nil {
16231662
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
@@ -1691,34 +1730,6 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
16911730
baseCommit = base
16921731
}
16931732

1694-
// Create tree entries for all files (or remaining files if empty repo)
1695-
var entries []*github.TreeEntry
1696-
1697-
for _, file := range filesObj {
1698-
fileMap, ok := file.(map[string]any)
1699-
if !ok {
1700-
return utils.NewToolResultError("each file must be an object with path and content"), nil, nil
1701-
}
1702-
1703-
path, ok := fileMap["path"].(string)
1704-
if !ok || path == "" {
1705-
return utils.NewToolResultError("each file must have a path"), nil, nil
1706-
}
1707-
1708-
content, ok := fileMap["content"].(string)
1709-
if !ok {
1710-
return utils.NewToolResultError("each file must have content"), nil, nil
1711-
}
1712-
1713-
// Create a tree entry for the file
1714-
entries = append(entries, &github.TreeEntry{
1715-
Path: github.Ptr(path),
1716-
Mode: github.Ptr("100644"), // Regular file mode
1717-
Type: github.Ptr("blob"),
1718-
Content: github.Ptr(content),
1719-
})
1720-
}
1721-
17221733
// Create a new tree with the file entries (baseCommit is now guaranteed to exist)
17231734
newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries)
17241735
if err != nil {
@@ -1773,6 +1784,8 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
17731784
return utils.NewToolResultText(string(r)), nil, nil
17741785
},
17751786
)
1787+
tool.ScopeResolver = workflowScopeForFiles
1788+
return tool
17761789
}
17771790

17781791
// ListTags creates a tool to list tags in a GitHub repository.

pkg/github/repository_path.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package github
2+
3+
import (
4+
"fmt"
5+
"path"
6+
"slices"
7+
"strings"
8+
9+
"github.com/github/github-mcp-server/pkg/scopes"
10+
)
11+
12+
const workflowPathPrefix = ".github/workflows/"
13+
14+
func validateRelativePath(value string) (string, error) {
15+
if value == "" {
16+
return "", fmt.Errorf("path must not be empty")
17+
}
18+
if path.IsAbs(value) {
19+
return "", fmt.Errorf("path must be relative")
20+
}
21+
if strings.Contains(value, `\`) {
22+
return "", fmt.Errorf("path must use forward slashes")
23+
}
24+
if slices.Contains(strings.Split(value, "/"), "..") {
25+
return "", fmt.Errorf("path must not contain parent directory traversal")
26+
}
27+
28+
cleaned := path.Clean(value)
29+
if cleaned == "." {
30+
return "", fmt.Errorf("path must identify a file")
31+
}
32+
return cleaned, nil
33+
}
34+
35+
func isWorkflowPath(value string) bool {
36+
return strings.HasPrefix(value, workflowPathPrefix) && len(value) > len(workflowPathPrefix)
37+
}
38+
39+
func workflowScopeForPath(arguments map[string]any) []string {
40+
value, ok := arguments["path"].(string)
41+
if !ok {
42+
return nil
43+
}
44+
cleaned, err := validateRelativePath(value)
45+
if err != nil || !isWorkflowPath(cleaned) {
46+
return nil
47+
}
48+
return []string{string(scopes.Workflow)}
49+
}
50+
51+
func workflowScopeForFiles(arguments map[string]any) []string {
52+
files, ok := arguments["files"].([]any)
53+
if !ok {
54+
return nil
55+
}
56+
for _, file := range files {
57+
fileMap, ok := file.(map[string]any)
58+
if !ok {
59+
continue
60+
}
61+
if len(workflowScopeForPath(fileMap)) > 0 {
62+
return []string{string(scopes.Workflow)}
63+
}
64+
}
65+
return nil
66+
}

pkg/github/repository_path_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/github/github-mcp-server/pkg/inventory"
8+
"github.com/github/github-mcp-server/pkg/translations"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestValidateRelativePath(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
value string
17+
want string
18+
wantErr string
19+
}{
20+
{name: "file", value: "docs/readme.md", want: "docs/readme.md"},
21+
{name: "normalizes dot segment", value: "./.github/workflows/ci.yml", want: ".github/workflows/ci.yml"},
22+
{name: "normalizes duplicate separator", value: ".github//workflows/ci.yml", want: ".github/workflows/ci.yml"},
23+
{name: "empty", value: "", wantErr: "must not be empty"},
24+
{name: "current directory", value: ".", wantErr: "must identify a file"},
25+
{name: "absolute", value: "/.github/workflows/ci.yml", wantErr: "must be relative"},
26+
{name: "parent traversal", value: "docs/../.github/workflows/ci.yml", wantErr: "parent directory traversal"},
27+
{name: "leading traversal", value: "../.github/workflows/ci.yml", wantErr: "parent directory traversal"},
28+
{name: "backslash traversal", value: `docs\..\.github\workflows\ci.yml`, wantErr: "forward slashes"},
29+
}
30+
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
got, err := validateRelativePath(tt.value)
34+
if tt.wantErr != "" {
35+
require.Error(t, err)
36+
assert.Contains(t, err.Error(), tt.wantErr)
37+
return
38+
}
39+
require.NoError(t, err)
40+
assert.Equal(t, tt.want, got)
41+
})
42+
}
43+
}
44+
45+
func TestFileWriteWorkflowScopeResolvers(t *testing.T) {
46+
tests := []struct {
47+
name string
48+
tool inventory.ServerTool
49+
args map[string]any
50+
want []string
51+
}{
52+
{
53+
name: "create regular file",
54+
tool: CreateOrUpdateFile(translations.NullTranslationHelper),
55+
args: map[string]any{"path": "docs/readme.md"},
56+
},
57+
{
58+
name: "create workflow",
59+
tool: CreateOrUpdateFile(translations.NullTranslationHelper),
60+
args: map[string]any{"path": ".github/workflows/ci.yml"},
61+
want: []string{"workflow"},
62+
},
63+
{
64+
name: "delete normalized workflow",
65+
tool: DeleteFile(translations.NullTranslationHelper),
66+
args: map[string]any{"path": "./.github/workflows/ci.yml"},
67+
want: []string{"workflow"},
68+
},
69+
{
70+
name: "reject traversal instead of resolving it",
71+
tool: DeleteFile(translations.NullTranslationHelper),
72+
args: map[string]any{"path": "docs/../.github/workflows/ci.yml"},
73+
},
74+
{
75+
name: "push regular files",
76+
tool: PushFiles(translations.NullTranslationHelper),
77+
args: map[string]any{"files": []any{map[string]any{"path": "README.md"}}},
78+
},
79+
{
80+
name: "push includes workflow",
81+
tool: PushFiles(translations.NullTranslationHelper),
82+
args: map[string]any{"files": []any{
83+
map[string]any{"path": "README.md"},
84+
map[string]any{"path": ".github/workflows/ci.yml"},
85+
}},
86+
want: []string{"workflow"},
87+
},
88+
}
89+
90+
for _, tt := range tests {
91+
t.Run(tt.name, func(t *testing.T) {
92+
require.NotNil(t, tt.tool.ScopeResolver)
93+
assert.Equal(t, tt.want, tt.tool.ScopeResolver(tt.args))
94+
})
95+
}
96+
}
97+
98+
func TestFileWriteToolsRejectUnsafePathsBeforeAPICalls(t *testing.T) {
99+
tests := []struct {
100+
name string
101+
tool inventory.ServerTool
102+
args map[string]any
103+
}{
104+
{
105+
name: "create or update",
106+
tool: CreateOrUpdateFile(translations.NullTranslationHelper),
107+
args: map[string]any{
108+
"owner": "owner", "repo": "repo", "path": "../workflow.yml",
109+
"content": "content", "message": "message", "branch": "main",
110+
},
111+
},
112+
{
113+
name: "delete",
114+
tool: DeleteFile(translations.NullTranslationHelper),
115+
args: map[string]any{
116+
"owner": "owner", "repo": "repo", "path": "/.github/workflows/ci.yml",
117+
"message": "message", "branch": "main",
118+
},
119+
},
120+
{
121+
name: "push",
122+
tool: PushFiles(translations.NullTranslationHelper),
123+
args: map[string]any{
124+
"owner": "owner", "repo": "repo", "branch": "main", "message": "message",
125+
"files": []any{map[string]any{"path": `..\workflow.yml`, "content": "content"}},
126+
},
127+
},
128+
}
129+
130+
for _, tt := range tests {
131+
t.Run(tt.name, func(t *testing.T) {
132+
deps := BaseDeps{}
133+
request := createMCPRequest(tt.args)
134+
result, err := tt.tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request)
135+
require.NoError(t, err)
136+
require.True(t, result.IsError)
137+
assert.Contains(t, getErrorResult(t, result).Text, "path")
138+
})
139+
}
140+
}

0 commit comments

Comments
 (0)