From fe386b2cd47638b802c4904a0c20ce93fe5cca9b Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Fri, 7 Aug 2026 14:21:42 +0700 Subject: [PATCH 1/4] fix(github): sanitize issue comment and sub-issue bodies on read paths Issue and PR title/body responses are passed through sanitize.Sanitize (invisible-glyph, BiDi, HTML-tag and code-fence-metadata stripping), but the two remaining body-bearing read paths were not: - convertToMinimalIssueComment returned comment bodies verbatim, so every comment read delivered raw attacker-controlled content. - GetSubIssues marshalled sub-issues (title+body) verbatim. A hostile comment could therefore carry hidden prompt-injection content (invisible Unicode tag block, BiDi overrides) straight into the model context, bypassing the control applied on every sibling path. Apply the same sanitize.Sanitize call in both places. --- pkg/github/issues.go | 9 +++++++++ pkg/github/minimal_types.go | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index dfb823e26b..1c27e15d82 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -946,6 +946,15 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc subIssues = filteredSubIssues } + for _, subIssue := range subIssues { + if subIssue.Title != nil { + subIssue.Title = github.Ptr(sanitize.Sanitize(*subIssue.Title)) + } + if subIssue.Body != nil { + subIssue.Body = github.Ptr(sanitize.Sanitize(*subIssue.Body)) + } + } + r, err := json.Marshal(subIssues) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index e2bf8b684b..127e2e4b25 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -820,8 +820,10 @@ func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesRe func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { m := MinimalIssueComment{ - ID: comment.GetID(), - Body: comment.GetBody(), + ID: comment.GetID(), + // Bodies carry the same invisible-glyph / HTML injection surface as + // issue and PR bodies, which the read paths already sanitize. + Body: sanitize.Sanitize(comment.GetBody()), HTMLURL: comment.GetHTMLURL(), User: convertToMinimalUser(comment.GetUser()), AuthorAssociation: comment.GetAuthorAssociation(), From 57d48752c3eada32020ea503fa95426400acd496 Mon Sep 17 00:00:00 2001 From: Iryna Kulakova Date: Tue, 18 Aug 2026 14:41:37 +0200 Subject: [PATCH 2/4] fix(sanitize): preserve code in sanitized bodies and cover remaining read paths Full Sanitize runs bluemonday, which escapes entities and silently truncates a fenced code block at the first '<'. Comment and review bodies are the most code-dense fields the server returns, so applying it there corrupts content delivered to the model. Add sanitize.FilterBody (invisible characters + code fence metadata, no HTML filtering) and use it for issue comment, PR review, PR review comment and sub-issue bodies. Titles keep the full Sanitize treatment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8dd0f23-2c6e-47c1-bad5-c1d0c9361f13 --- pkg/github/issues.go | 2 +- pkg/github/issues_test.go | 50 +++++++++++++++++++++ pkg/github/minimal_types.go | 8 ++-- pkg/github/minimal_types_test.go | 76 ++++++++++++++++++++++++++++++++ pkg/sanitize/sanitize.go | 9 ++++ pkg/sanitize/sanitize_test.go | 50 +++++++++++++++++++++ 6 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 pkg/github/minimal_types_test.go diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1c27e15d82..b66a582a39 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -951,7 +951,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc subIssue.Title = github.Ptr(sanitize.Sanitize(*subIssue.Title)) } if subIssue.Body != nil { - subIssue.Body = github.Ptr(sanitize.Sanitize(*subIssue.Body)) + subIssue.Body = github.Ptr(sanitize.FilterBody(*subIssue.Body)) } } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 77380e5e21..4ec8e41260 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -4738,6 +4738,56 @@ func Test_GetSubIssues(t *testing.T) { } } +func Test_GetSubIssues_Sanitization(t *testing.T) { + serverTool := IssueRead(translations.NullTranslationHelper) + + hiddenPayload := "Sub-issue\U000E0001\U000E0049\U000E0067\U000E006E\U000E006F\U000E0072\U000E0065" + bodyWithCode := "Repro:\n```go\nif a in JSX." + +func TestConvertToMinimalIssueCommentSanitizesBody(t *testing.T) { + t.Run("strips hidden characters", func(t *testing.T) { + m := convertToMinimalIssueComment(&github.IssueComment{ + ID: github.Ptr(int64(1)), + Body: github.Ptr(bodyWithHiddenPayload), + }) + assert.Equal(t, "Looks good", m.Body) + }) + + t.Run("preserves code content", func(t *testing.T) { + m := convertToMinimalIssueComment(&github.IssueComment{ + ID: github.Ptr(int64(1)), + Body: github.Ptr(bodyWithCode), + }) + assert.Equal(t, bodyWithCode, m.Body) + }) +} + +func TestConvertToMinimalPullRequestReviewSanitizesBody(t *testing.T) { + t.Run("strips hidden characters", func(t *testing.T) { + m := convertToMinimalPullRequestReview(&github.PullRequestReview{ + ID: github.Ptr(int64(1)), + Body: github.Ptr(bodyWithHiddenPayload), + }) + assert.Equal(t, "Looks good", m.Body) + }) + + t.Run("preserves code content", func(t *testing.T) { + m := convertToMinimalPullRequestReview(&github.PullRequestReview{ + ID: github.Ptr(int64(1)), + Body: github.Ptr(bodyWithCode), + }) + assert.Equal(t, bodyWithCode, m.Body) + }) +} + +func TestConvertToMinimalReviewCommentSanitizesBody(t *testing.T) { + commentURL, err := url.Parse("https://github.com/owner/repo/pull/1#discussion_r1") + require.NoError(t, err) + + t.Run("strips hidden characters", func(t *testing.T) { + m := convertToMinimalReviewComment(reviewCommentNode{ + Body: githubv4.String(bodyWithHiddenPayload), + Path: githubv4.String("main.go"), + URL: githubv4.URI{URL: commentURL}, + }) + assert.Equal(t, "Looks good", m.Body) + }) + + t.Run("preserves code content", func(t *testing.T) { + m := convertToMinimalReviewComment(reviewCommentNode{ + Body: githubv4.String(bodyWithCode), + Path: githubv4.String("main.go"), + URL: githubv4.URI{URL: commentURL}, + }) + assert.Equal(t, bodyWithCode, m.Body) + }) +} diff --git a/pkg/sanitize/sanitize.go b/pkg/sanitize/sanitize.go index e6401e4fb3..8cf55a6bde 100644 --- a/pkg/sanitize/sanitize.go +++ b/pkg/sanitize/sanitize.go @@ -15,6 +15,15 @@ func Sanitize(input string) string { return FilterHTMLTags(FilterCodeFenceMetadata(FilterInvisibleCharacters(input))) } +// FilterBody strips the injection surface that matters for markdown bodies — +// invisible glyphs and hidden code-fence info strings — without running the +// HTML filter. Bodies routinely contain code (generics, JSX, shell redirects), +// and HTML filtering silently truncates a fenced block at the first '<', which +// would corrupt the content delivered to the model. +func FilterBody(input string) string { + return FilterCodeFenceMetadata(FilterInvisibleCharacters(input)) +} + // FilterInvisibleCharacters removes invisible or control characters that should not appear // in user-facing titles or bodies. This includes: // - Unicode tag characters: U+E0001, U+E0020–U+E007F diff --git a/pkg/sanitize/sanitize_test.go b/pkg/sanitize/sanitize_test.go index 35b23e6abe..2531ca2550 100644 --- a/pkg/sanitize/sanitize_test.go +++ b/pkg/sanitize/sanitize_test.go @@ -300,3 +300,53 @@ func TestSanitizeRemovesInvisibleCodeFenceMetadata(t *testing.T) { result := Sanitize(input) assert.Equal(t, expected, result) } + +func TestFilterBody(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes unicode tag characters", + input: "hello\U000E0001\U000E0068\U000E0069world", + expected: "helloworld", + }, + { + name: "removes bidi overrides", + input: "safe\u202Ereversed\u202C", + expected: "safereversed", + }, + { + name: "strips hidden code fence metadata", + input: "```steal secrets\nfmt.Println(42)\n```", + expected: "```\nfmt.Println(42)\n```", + }, + { + name: "preserves angle brackets in prose", + input: "a < b && c > d", + expected: "a < b && c > d", + }, + { + name: "preserves code fences containing angle brackets", + input: "```go\nif a component", + expected: "use component", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, FilterBody(tt.input)) + }) + } +} From 9915997b57a60200879a2fb8a17c13ef98ea4108 Mon Sep 17 00:00:00 2001 From: Iryna Kulakova Date: Tue, 18 Aug 2026 14:44:03 +0200 Subject: [PATCH 3/4] test: scope sanitization fixture names to avoid package-wide collisions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8dd0f23-2c6e-47c1-bad5-c1d0c9361f13 --- pkg/github/minimal_types_test.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/github/minimal_types_test.go b/pkg/github/minimal_types_test.go index e495a05df2..63896c09f2 100644 --- a/pkg/github/minimal_types_test.go +++ b/pkg/github/minimal_types_test.go @@ -10,17 +10,17 @@ import ( "github.com/stretchr/testify/require" ) -// bodyWithHiddenPayload embeds Unicode tag characters, which are invisible to a +// sanitizedBodyWithHiddenChars embeds Unicode tag characters, which are invisible to a // human reviewer but legible to a model. -const bodyWithHiddenPayload = "Looks good\U000E0001\U000E0049\U000E0067\U000E006E\U000E006F\U000E0072\U000E0065" +const sanitizedBodyWithHiddenChars = "Looks good\U000E0001\U000E0049\U000E0067\U000E006E\U000E006F\U000E0072\U000E0065" -const bodyWithCode = "Compare with:\n```go\nif a in JSX." +const sanitizedBodyWithCode = "Compare with:\n```go\nif a in JSX." func TestConvertToMinimalIssueCommentSanitizesBody(t *testing.T) { t.Run("strips hidden characters", func(t *testing.T) { m := convertToMinimalIssueComment(&github.IssueComment{ ID: github.Ptr(int64(1)), - Body: github.Ptr(bodyWithHiddenPayload), + Body: github.Ptr(sanitizedBodyWithHiddenChars), }) assert.Equal(t, "Looks good", m.Body) }) @@ -28,9 +28,9 @@ func TestConvertToMinimalIssueCommentSanitizesBody(t *testing.T) { t.Run("preserves code content", func(t *testing.T) { m := convertToMinimalIssueComment(&github.IssueComment{ ID: github.Ptr(int64(1)), - Body: github.Ptr(bodyWithCode), + Body: github.Ptr(sanitizedBodyWithCode), }) - assert.Equal(t, bodyWithCode, m.Body) + assert.Equal(t, sanitizedBodyWithCode, m.Body) }) } @@ -38,7 +38,7 @@ func TestConvertToMinimalPullRequestReviewSanitizesBody(t *testing.T) { t.Run("strips hidden characters", func(t *testing.T) { m := convertToMinimalPullRequestReview(&github.PullRequestReview{ ID: github.Ptr(int64(1)), - Body: github.Ptr(bodyWithHiddenPayload), + Body: github.Ptr(sanitizedBodyWithHiddenChars), }) assert.Equal(t, "Looks good", m.Body) }) @@ -46,9 +46,9 @@ func TestConvertToMinimalPullRequestReviewSanitizesBody(t *testing.T) { t.Run("preserves code content", func(t *testing.T) { m := convertToMinimalPullRequestReview(&github.PullRequestReview{ ID: github.Ptr(int64(1)), - Body: github.Ptr(bodyWithCode), + Body: github.Ptr(sanitizedBodyWithCode), }) - assert.Equal(t, bodyWithCode, m.Body) + assert.Equal(t, sanitizedBodyWithCode, m.Body) }) } @@ -58,7 +58,7 @@ func TestConvertToMinimalReviewCommentSanitizesBody(t *testing.T) { t.Run("strips hidden characters", func(t *testing.T) { m := convertToMinimalReviewComment(reviewCommentNode{ - Body: githubv4.String(bodyWithHiddenPayload), + Body: githubv4.String(sanitizedBodyWithHiddenChars), Path: githubv4.String("main.go"), URL: githubv4.URI{URL: commentURL}, }) @@ -67,10 +67,10 @@ func TestConvertToMinimalReviewCommentSanitizesBody(t *testing.T) { t.Run("preserves code content", func(t *testing.T) { m := convertToMinimalReviewComment(reviewCommentNode{ - Body: githubv4.String(bodyWithCode), + Body: githubv4.String(sanitizedBodyWithCode), Path: githubv4.String("main.go"), URL: githubv4.URI{URL: commentURL}, }) - assert.Equal(t, bodyWithCode, m.Body) + assert.Equal(t, sanitizedBodyWithCode, m.Body) }) } From 585519e6a2f22b83b231d4267c6b14c8712c0b50 Mon Sep 17 00:00:00 2001 From: Iryna Kulakova Date: Tue, 18 Aug 2026 14:48:29 +0200 Subject: [PATCH 4/4] fix(sanitize): use FilterBody for issue and pull request bodies The remaining read paths still ran bodies through the HTML filter, which escapes entities and truncates a fenced code block at the first '<'. Bodies on issue_read and pull_request_read are as code-dense as comment bodies, so they get the same treatment. Titles keep the full Sanitize pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8dd0f23-2c6e-47c1-bad5-c1d0c9361f13 --- pkg/github/issues.go | 2 +- pkg/github/minimal_types.go | 2 +- pkg/github/minimal_types_test.go | 11 +++++++++++ pkg/github/pullrequests.go | 4 ++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index b66a582a39..15185be3c7 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -745,7 +745,7 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) } if issue.Body != nil { - issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body)) + issue.Body = github.Ptr(sanitize.FilterBody(*issue.Body)) } } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index c26beba3f8..53188f96cf 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -749,7 +749,7 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { m := MinimalIssue{ Number: int(fragment.Number), Title: sanitize.Sanitize(string(fragment.Title)), - Body: sanitize.Sanitize(string(fragment.Body)), + Body: sanitize.FilterBody(string(fragment.Body)), State: string(fragment.State), Comments: int(fragment.Comments.TotalCount), CreatedAt: fragment.CreatedAt.Format(time.RFC3339), diff --git a/pkg/github/minimal_types_test.go b/pkg/github/minimal_types_test.go index 63896c09f2..a3a2ca9b7a 100644 --- a/pkg/github/minimal_types_test.go +++ b/pkg/github/minimal_types_test.go @@ -74,3 +74,14 @@ func TestConvertToMinimalReviewCommentSanitizesBody(t *testing.T) { assert.Equal(t, sanitizedBodyWithCode, m.Body) }) } + +func TestFragmentToMinimalIssueSanitization(t *testing.T) { + m := fragmentToMinimalIssue(IssueFragment{ + Number: 1, + Title: githubv4.String(sanitizedBodyWithHiddenChars), + Body: githubv4.String(sanitizedBodyWithCode), + }) + + assert.Equal(t, "Looks good", m.Title, "hidden characters must be stripped from titles") + assert.Equal(t, sanitizedBodyWithCode, m.Body, "code content must survive sanitization") +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9825ba8845..75bb63b51e 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -191,7 +191,7 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) } if pr.Body != nil { - pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) + pr.Body = github.Ptr(sanitize.FilterBody(*pr.Body)) } } @@ -1459,7 +1459,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) } if pr.Body != nil { - pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) + pr.Body = github.Ptr(sanitize.FilterBody(*pr.Body)) } }