Skip to content

Commit c0ca279

Browse files
authored
fix(comment): prevent markdown injection from IaC tag values (#22)
* fix(comment): prevent markdown injection from IaC tag values escapeAndFormatCode wrapped values in backticks without escaping. Tag keys/values and attribute names come from user-controlled IaC and are rendered as Markdown in PR comments, so a crafted value (e.g. a backtick followed by a [link](...)) could close the code span and inject live Markdown. All 29 tag/governance render sites route through this helper (directly or via joinEscapedCode), so make it emit an injection-safe code span: fence longer than any backtick run, newlines stripped. Output is unchanged for ordinary values. * fix(comment): escape user-controlled template leaf values text/template applies no escaping, so template interpolations of user-controlled values bypassed escapeAndFormatCode entirely: project name/module path/workspace (cost table), project error name/output, and the fixed-issues tag label were emitted raw into the HTML-flavoured Markdown comment. Escape them at the template boundary: values inside raw-HTML blocks (<td>, <b>, <pre>) use the built-in `html` function (Markdown is not processed there, so HTML escaping is the correct and sufficient guard); the fixed-issues label, shown as inline code in a Markdown context, uses the new mdCode template function (escapeAndFormatCode). Error output stays in its <pre> code block with escaped content.
1 parent 96f514d commit c0ca279

6 files changed

Lines changed: 88 additions & 9 deletions

File tree

pkg/vcs/comment/escape_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package comment
2+
3+
import "testing"
4+
5+
func TestEscapeAndFormatCode(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
in string
9+
want string
10+
}{
11+
{name: "ordinary value", in: "environment", want: "`environment`"},
12+
{name: "ordinary tag value", in: "prod", want: "`prod`"},
13+
{name: "empty", in: "", want: "``"},
14+
{
15+
// A crafted tag value must stay inside the code span rather than
16+
// closing it and injecting a link into the PR comment.
17+
name: "injection payload is contained",
18+
in: "` - go [here](https://evil.example) to fix`",
19+
want: "`` ` - go [here](https://evil.example) to fix` ``",
20+
},
21+
{name: "internal backtick widens the fence", in: "a`b", want: "``a`b``"},
22+
{name: "newline collapsed to space", in: "a\nb", want: "`a b`"},
23+
}
24+
25+
for _, tt := range tests {
26+
t.Run(tt.name, func(t *testing.T) {
27+
if got := escapeAndFormatCode(tt.in); got != tt.want {
28+
t.Errorf("escapeAndFormatCode(%q) = %q, want %q", tt.in, got, tt.want)
29+
}
30+
})
31+
}
32+
}
33+
34+
func TestJoinEscapedCode(t *testing.T) {
35+
got := joinEscapedCode([]string{"a", "b`c"})
36+
want := "`a`, ``b`c``"
37+
if got != want {
38+
t.Errorf("joinEscapedCode = %q, want %q", got, want)
39+
}
40+
}

pkg/vcs/comment/governance.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -720,9 +720,40 @@ func formatProjectNamesLabel(names []string) string {
720720
return fmt.Sprintf("%s %s", noun, strings.Join(escaped, ", "))
721721
}
722722

723-
// escapeAndFormatCode wraps a string in backticks for inline code display.
723+
// escapeAndFormatCode renders s as an inline code span that is safe against
724+
// Markdown injection. Values passed here originate from user-controlled IaC
725+
// (tag keys and values, attribute names, etc.) and are rendered into PR
726+
// comments, so a crafted value must not be able to break out of the code span
727+
// and inject links, images, or other live Markdown.
728+
//
729+
// It picks a backtick fence one longer than the longest run of backticks in s
730+
// and strips newlines (a code span cannot span lines). For ordinary values
731+
// with no backticks the output is the usual "`" + s + "`".
724732
func escapeAndFormatCode(s string) string {
725-
return "`" + s + "`"
733+
// Code spans cannot contain line endings.
734+
s = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(s)
735+
736+
longest, current := 0, 0
737+
for _, r := range s {
738+
if r == '`' {
739+
current++
740+
if current > longest {
741+
longest = current
742+
}
743+
} else {
744+
current = 0
745+
}
746+
}
747+
fence := strings.Repeat("`", longest+1)
748+
749+
// If the content starts or ends with a backtick, pad with a space so the
750+
// boundary is not read as part of the fence; the renderer strips a single
751+
// leading/trailing space.
752+
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
753+
s = " " + s + " "
754+
}
755+
756+
return fence + s + fence
726757
}
727758

728759
const (

pkg/vcs/comment/template.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ var templateFS embed.FS
4343
// or replace with their own. It renders HTML-flavoured Markdown suitable for
4444
// most platforms (GitHub, GitLab, Azure Repos).
4545
var DefaultTemplate = template.Must(
46-
template.New("comment.tmpl").ParseFS(templateFS, "templates/*.tmpl"),
46+
template.New("comment.tmpl").
47+
Funcs(template.FuncMap{
48+
// mdCode renders a user-controlled value as an injection-safe inline
49+
// code span, for values shown as code in a Markdown context. Values
50+
// rendered inside raw-HTML blocks (<td>, <pre>, <b>) use the built-in
51+
// `html` function instead, which is the correct escaping there.
52+
"mdCode": escapeAndFormatCode,
53+
}).
54+
ParseFS(templateFS, "templates/*.tmpl"),
4755
)
4856

4957
// truncationBuffer is reserved inside maxCommentSize to cover both the

pkg/vcs/comment/templates/errors.tmpl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
<ul>
77
{{- range .ProjectErrors }}
8-
<li><b>{{ .Name }}</b></li>
9-
<pre>{{ .Error }}</pre>
8+
<li><b>{{ .Name | html }}</b></li>
9+
<pre>{{ .Error | html }}</pre>
1010
{{- end }}
1111
</ul>
1212
<hr/>

pkg/vcs/comment/templates/fixed_issues.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<details><summary>🟢 {{ .FixedIssuesSentence }}</summary>
33

44
{{ range .FixedIssueCounts -}}
5-
- {{ .PolicyName }} `{{ .FixedIssuesLabel }}`
5+
- {{ .PolicyName | html }} {{ .FixedIssuesLabel | mdCode }}
66
{{ end -}}
77
{{ if .FixedIssuesTruncated -}}
88
- ...and {{ .FixedIssuesTruncated }} more {{ if eq .FixedIssuesTruncated 1 }}issue{{ else }}issues{{ end }}

pkg/vcs/comment/templates/project_costs.tmpl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@
1818
<tbody>
1919
{{- range .CostTableEntries }}
2020
<tr>
21-
<td>{{ .ProjectName }}</td>
21+
<td>{{ .ProjectName | html }}</td>
2222
{{- if $.ShowModulePath }}
23-
<td>{{ .ModulePath }}</td>
23+
<td>{{ .ModulePath | html }}</td>
2424
{{- end }}
2525
{{- if $.ShowWorkspace }}
26-
<td>{{ .Workspace }}</td>
26+
<td>{{ .Workspace | html }}</td>
2727
{{- end }}
2828
<td align="right">{{ .BaselineCostChange }}</td>
2929
<td align="right">{{ .UsageCostChange }}</td>

0 commit comments

Comments
 (0)