Skip to content

Commit a0ec852

Browse files
fix(repos): disclose dereferenced symlink reads
Detect internal symlink dereferences from Git blob identity mismatches, disclose explicit links and submodules, and preserve requested-path resource output. Use bounded exact-path tree inspection only when inline content is unavailable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent aab745a commit a0ec852

3 files changed

Lines changed: 1010 additions & 42 deletions

File tree

pkg/github/repositories.go

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,23 +1071,48 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
10711071
if fallbackUsed {
10721072
successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref)
10731073
}
1074+
const maxContentSize = 1024 * 1024 // 1MB
1075+
1076+
inspection, respInspect, err := inspectRepositoryFile(ctx, client, owner, repo, ref, path, fileContent)
1077+
if err != nil {
1078+
if respInspect != nil {
1079+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1080+
"failed to inspect repository file",
1081+
respInspect,
1082+
err,
1083+
), nil, nil
1084+
}
1085+
return utils.NewToolResultError(fmt.Sprintf("failed to inspect repository file: %s", err)), nil, nil
1086+
}
1087+
if inspection.Submodule != nil {
1088+
return attachIFC(utils.NewToolResultText(marshalRepositorySubmoduleMetadata(inspection.Submodule))), nil, nil
1089+
}
1090+
if inspection.Symlink != nil &&
1091+
!inspection.ContentAvailable &&
1092+
(inspection.Symlink.Explicit || fileSize < maxContentSize) {
1093+
return attachIFC(utils.NewToolResultText(marshalRepositorySymlinkMetadata(
1094+
inspection.Symlink,
1095+
unavailableSymlinkContents,
1096+
successNote,
1097+
))), nil, nil
1098+
}
10741099

1075-
// Empty files (0 bytes) have no content to decode; return
1076-
// them directly as empty text to avoid errors from
1077-
// GetContent when the API returns null content with a
1078-
// base64 encoding field, and to avoid DetectContentType
1079-
// misclassifying them as binary.
1080-
if fileSize == 0 {
1100+
// Empty files are returned as empty text to avoid
1101+
// DetectContentType misclassifying them as binary.
1102+
if fileSize == 0 && inspection.ContentAvailable {
10811103
result := &mcp.ResourceContents{
10821104
URI: resourceURI,
10831105
Text: "",
10841106
MIMEType: "text/plain",
10851107
}
1086-
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
1108+
message := fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote)
1109+
if inspection.Symlink != nil {
1110+
message = marshalRepositorySymlinkMetadata(inspection.Symlink, dereferencedContentLabel, successNote)
1111+
}
1112+
return attachIFC(utils.NewToolResultResource(message, result)), nil, nil
10871113
}
10881114

10891115
// For files >= 1MB, return a ResourceLink instead of content
1090-
const maxContentSize = 1024 * 1024 // 1MB
10911116
if fileSize >= maxContentSize {
10921117
size := int64(fileSize)
10931118
resourceLink := &mcp.ResourceLink{
@@ -1096,22 +1121,28 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
10961121
Title: fmt.Sprintf("File: %s", path),
10971122
Size: &size,
10981123
}
1124+
message := fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s",
1125+
path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote)
1126+
if inspection.Symlink != nil {
1127+
targetPath := inspection.Symlink.ResolvedTargetPath
1128+
if targetPath == "" {
1129+
targetPath = inspection.Symlink.Target
1130+
}
1131+
resourceLink.Title = fmt.Sprintf("Dereferenced target %s via symlink %s", targetPath, path)
1132+
message = marshalRepositorySymlinkMetadata(inspection.Symlink, dereferencedContentLabel, successNote)
1133+
}
10991134
return attachIFC(utils.NewToolResultResourceLink(
1100-
fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s",
1101-
path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote),
1135+
message,
11021136
resourceLink)), nil, nil
11031137
}
1104-
1105-
// For files < 1MB, get content directly from Contents API
1106-
content, err := fileContent.GetContent()
1107-
if err != nil {
1108-
return utils.NewToolResultError(fmt.Sprintf("failed to decode file content: %s", err)), nil, nil
1138+
if !inspection.ContentAvailable {
1139+
return utils.NewToolResultError(fmt.Sprintf("failed to inspect repository file: Contents API did not provide content for path %q", path)), nil, nil
11091140
}
11101141

11111142
// Detect content type from the actual content bytes,
11121143
// mirroring the original approach of using the Content-Type header
11131144
// from the raw API response.
1114-
contentBytes := []byte(content)
1145+
contentBytes := inspection.Content
11151146
contentType := http.DetectContentType(contentBytes)
11161147

11171148
// Determine if content is text or binary based on detected content type
@@ -1124,18 +1155,26 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
11241155
if isTextContent {
11251156
result := &mcp.ResourceContents{
11261157
URI: resourceURI,
1127-
Text: content,
1158+
Text: string(contentBytes),
11281159
MIMEType: contentType,
11291160
}
1130-
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
1161+
message := fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote)
1162+
if inspection.Symlink != nil {
1163+
message = marshalRepositorySymlinkMetadata(inspection.Symlink, dereferencedContentLabel, successNote)
1164+
}
1165+
return attachIFC(utils.NewToolResultResource(message, result)), nil, nil
11311166
}
11321167

11331168
result := &mcp.ResourceContents{
11341169
URI: resourceURI,
11351170
Blob: contentBytes,
11361171
MIMEType: contentType,
11371172
}
1138-
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
1173+
message := fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote)
1174+
if inspection.Symlink != nil {
1175+
message = marshalRepositorySymlinkMetadata(inspection.Symlink, dereferencedContentLabel, successNote)
1176+
}
1177+
return attachIFC(utils.NewToolResultResource(message, result)), nil, nil
11391178
} else if dirContent != nil {
11401179
// file content or file SHA is nil which means it's a directory
11411180
filtered := false

pkg/github/repositories_helper.go

Lines changed: 204 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package github
22

33
import (
4+
"bytes"
45
"context"
6+
"crypto/sha1" //nolint:gosec // Git object IDs are defined using SHA-1.
7+
"encoding/hex"
58
"encoding/json"
69
"fmt"
710
"net/http"
811
"net/url"
912
pathpkg "path"
1013
"strings"
14+
"unicode/utf8"
1115

1216
ghErrors "github.com/github/github-mcp-server/pkg/errors"
1317
"github.com/github/github-mcp-server/pkg/raw"
@@ -92,7 +96,45 @@ func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client
9296
return createdRef, nil
9397
}
9498

95-
const gitSymlinkMode = "120000"
99+
const (
100+
gitSymlinkMode = "120000"
101+
gitSubmoduleMode = "160000"
102+
maxGitTreeTraversalDepth = 64
103+
dereferencedContentLabel = "dereferenced_target"
104+
unavailableSymlinkContents = "not_returned"
105+
)
106+
107+
type repositorySymlink struct {
108+
Path string
109+
SHA string
110+
Target string
111+
ResolvedTargetPath string
112+
Explicit bool
113+
}
114+
115+
type repositoryFileInspection struct {
116+
Content []byte
117+
ContentAvailable bool
118+
Symlink *repositorySymlink
119+
Submodule *repositorySubmoduleReadMetadata
120+
}
121+
122+
type repositorySymlinkReadMetadata struct {
123+
Type string `json:"type"`
124+
Path string `json:"path"`
125+
SHA string `json:"sha,omitempty"`
126+
Target string `json:"target"`
127+
ResolvedTargetPath string `json:"resolved_path,omitempty"`
128+
Content string `json:"content"`
129+
Note string `json:"note,omitempty"`
130+
}
131+
132+
type repositorySubmoduleReadMetadata struct {
133+
Type string `json:"type"`
134+
Path string `json:"path"`
135+
SHA string `json:"sha,omitempty"`
136+
GitURL string `json:"git_url,omitempty"`
137+
}
96138

97139
type symlinkWriteBlockedError struct {
98140
Error string `json:"error"`
@@ -129,6 +171,163 @@ func newSymlinkWriteBlockedResult(path, target string) *mcp.CallToolResult {
129171
}
130172
}
131173

174+
func inspectRepositoryFile(ctx context.Context, client *github.Client, owner, repo, treeish, path string, file *github.RepositoryContent) (*repositoryFileInspection, *github.Response, error) {
175+
if file.GetType() == "symlink" {
176+
content, available, err := suppliedRepositoryContent(file)
177+
if err != nil {
178+
return nil, nil, err
179+
}
180+
return &repositoryFileInspection{
181+
Content: content,
182+
ContentAvailable: available,
183+
Symlink: newRepositorySymlink(path, file.GetSHA(), file.GetTarget(), true),
184+
}, nil, nil
185+
}
186+
187+
if file.GetType() == "submodule" || file.GetSubmoduleGitURL() != "" {
188+
return &repositoryFileInspection{
189+
Submodule: &repositorySubmoduleReadMetadata{
190+
Type: "submodule",
191+
Path: path,
192+
SHA: file.GetSHA(),
193+
GitURL: file.GetSubmoduleGitURL(),
194+
},
195+
}, nil, nil
196+
}
197+
198+
content, available, err := suppliedRepositoryContent(file)
199+
if err != nil {
200+
return nil, nil, err
201+
}
202+
if available {
203+
if !looksLikeSHA(file.GetSHA()) {
204+
return nil, nil, fmt.Errorf("contents API returned malformed Git blob SHA %q for path %q", file.GetSHA(), path)
205+
}
206+
if strings.EqualFold(gitBlobSHA1(content), file.GetSHA()) {
207+
return &repositoryFileInspection{Content: content, ContentAvailable: true}, nil, nil
208+
}
209+
210+
target, resp, err := symlinkTargetFromBlob(ctx, client, owner, repo, file.GetSHA())
211+
if err != nil {
212+
return nil, resp, fmt.Errorf("contents API bytes did not match the reported Git blob and the path blob was not a valid symbolic link target: %w", err)
213+
}
214+
return &repositoryFileInspection{
215+
Content: content,
216+
ContentAvailable: true,
217+
Symlink: newRepositorySymlink(path, file.GetSHA(), target, false),
218+
}, nil, nil
219+
}
220+
221+
entry, resp, err := getTreeEntry(ctx, client, owner, repo, treeish, path)
222+
if err != nil {
223+
return nil, resp, err
224+
}
225+
if entry == nil {
226+
return nil, nil, fmt.Errorf("path %q exists according to the Contents API but was not found in the Git tree", path)
227+
}
228+
if !looksLikeSHA(file.GetSHA()) || !strings.EqualFold(file.GetSHA(), entry.GetSHA()) {
229+
return nil, nil, fmt.Errorf("contents API blob SHA %q does not match Git tree blob SHA %q for path %q", file.GetSHA(), entry.GetSHA(), path)
230+
}
231+
232+
switch entry.GetMode() {
233+
case gitSymlinkMode:
234+
target, resp, err := symlinkTargetFromBlob(ctx, client, owner, repo, entry.GetSHA())
235+
if err != nil {
236+
return nil, resp, err
237+
}
238+
return &repositoryFileInspection{
239+
Symlink: newRepositorySymlink(path, entry.GetSHA(), target, false),
240+
}, nil, nil
241+
case gitSubmoduleMode:
242+
return &repositoryFileInspection{
243+
Submodule: &repositorySubmoduleReadMetadata{
244+
Type: "submodule",
245+
Path: path,
246+
SHA: entry.GetSHA(),
247+
},
248+
}, nil, nil
249+
default:
250+
return &repositoryFileInspection{}, nil, nil
251+
}
252+
}
253+
254+
func suppliedRepositoryContent(file *github.RepositoryContent) ([]byte, bool, error) {
255+
if file.Content != nil {
256+
content, err := file.GetContent()
257+
if err != nil {
258+
return nil, false, fmt.Errorf("failed to decode file content: %w", err)
259+
}
260+
return []byte(content), true, nil
261+
}
262+
if file.GetType() != "symlink" && file.GetSize() == 0 {
263+
return []byte{}, true, nil
264+
}
265+
return nil, false, nil
266+
}
267+
268+
func gitBlobSHA1(content []byte) string {
269+
hasher := sha1.New() //nolint:gosec // SHA-1 is required by the Git object ID format.
270+
_, _ = fmt.Fprintf(hasher, "blob %d\x00", len(content))
271+
_, _ = hasher.Write(content)
272+
return hex.EncodeToString(hasher.Sum(nil))
273+
}
274+
275+
func symlinkTargetFromBlob(ctx context.Context, client *github.Client, owner, repo, sha string) (string, *github.Response, error) {
276+
target, resp, err := gitBlobBytes(ctx, client, owner, repo, sha)
277+
if err != nil {
278+
return "", resp, err
279+
}
280+
if len(target) == 0 || !utf8.Valid(target) || bytes.IndexByte(target, 0) >= 0 {
281+
return "", nil, fmt.Errorf("git blob %q is not a valid symbolic link target", sha)
282+
}
283+
return string(target), nil, nil
284+
}
285+
286+
func gitBlobBytes(ctx context.Context, client *github.Client, owner, repo, sha string) ([]byte, *github.Response, error) {
287+
if !looksLikeSHA(sha) {
288+
return nil, nil, fmt.Errorf("malformed Git blob SHA %q", sha)
289+
}
290+
content, resp, err := client.Git.GetBlobRaw(ctx, owner, repo, sha)
291+
if err != nil {
292+
return nil, resp, err
293+
}
294+
if resp != nil && resp.Body != nil {
295+
_ = resp.Body.Close()
296+
}
297+
if !strings.EqualFold(gitBlobSHA1(content), sha) {
298+
return nil, nil, fmt.Errorf("blob bytes returned by the Git Blobs API do not match SHA %q", sha)
299+
}
300+
return content, nil, nil
301+
}
302+
303+
func newRepositorySymlink(path, sha, target string, explicit bool) *repositorySymlink {
304+
return &repositorySymlink{
305+
Path: path,
306+
SHA: sha,
307+
Target: target,
308+
ResolvedTargetPath: resolveRepositorySymlinkTarget(path, target),
309+
Explicit: explicit,
310+
}
311+
}
312+
313+
func marshalRepositorySymlinkMetadata(link *repositorySymlink, content, note string) string {
314+
payload, _ := json.Marshal(repositorySymlinkReadMetadata{
315+
Type: "symlink",
316+
Path: link.Path,
317+
SHA: link.SHA,
318+
Target: link.Target,
319+
ResolvedTargetPath: link.ResolvedTargetPath,
320+
Content: content,
321+
Note: strings.TrimSpace(note),
322+
})
323+
return string(payload)
324+
}
325+
326+
func marshalRepositorySubmoduleMetadata(submodule *repositorySubmoduleReadMetadata) string {
327+
payload, _ := json.Marshal(submodule)
328+
return string(payload)
329+
}
330+
132331
func symlinkTargetAtPath(ctx context.Context, client *github.Client, owner, repo, treeish, path string) (string, bool, *github.Response, error) {
133332
entry, resp, err := getTreeEntry(ctx, client, owner, repo, treeish, path)
134333
if err != nil {
@@ -141,18 +340,18 @@ func symlinkTargetAtPath(ctx context.Context, client *github.Client, owner, repo
141340
return "", false, nil, nil
142341
}
143342

144-
target, resp, err := client.Git.GetBlobRaw(ctx, owner, repo, entry.GetSHA())
343+
target, resp, err := gitBlobBytes(ctx, client, owner, repo, entry.GetSHA())
145344
if err != nil {
146345
return "", false, resp, err
147346
}
148-
if resp != nil && resp.Body != nil {
149-
_ = resp.Body.Close()
150-
}
151347
return string(target), true, nil, nil
152348
}
153349

154350
func getTreeEntry(ctx context.Context, client *github.Client, owner, repo, treeish, path string) (*github.TreeEntry, *github.Response, error) {
155351
segments := strings.Split(pathpkg.Clean(strings.TrimPrefix(path, "/")), "/")
352+
if len(segments) > maxGitTreeTraversalDepth {
353+
return nil, nil, fmt.Errorf("path %q exceeds the maximum Git tree traversal depth of %d", path, maxGitTreeTraversalDepth)
354+
}
156355
treeish = escapeGitTreeish(treeish)
157356
for i, segment := range segments {
158357
tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeish, false)

0 commit comments

Comments
 (0)