Skip to content

Commit c173f53

Browse files
committed
fix(notify): neutralize markdown injection in slack/discord code blocks
slack and discord both wrap rendered findings in a triple-backtick fence, but a finding's title is attacker-controlled (a scanned target's page title, a crawled url, a cms name). a title embedding a closing fence broke out of the block and injected live markdown (mentions, masked links) into the channel. break up any backtick run in the body before wrapping, and for slack additionally entity-escape &, <, > per slack's api docs, since its parser resolves link/mention syntax ahead of code-block boundaries.
1 parent 4e49d79 commit c173f53

2 files changed

Lines changed: 112 additions & 4 deletions

File tree

internal/notify/notify_test.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ func deadURL(t *testing.T) string {
230230
t.Helper()
231231
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
232232
u := srv.URL
233-
srv.Close() // now nothing listens on that port
233+
srv.Close()
234234
return u
235235
}
236236

@@ -275,3 +275,87 @@ func TestNotifyErrorRedactsTelegramToken(t *testing.T) {
275275
}
276276
}
277277

278+
// attacker-controlled finding content (a scanned target's page title, a
279+
// crawled url, a cms name) reaches the slack/discord code block verbatim. a
280+
// title that embeds a closing fence used to break out of our wrapping block
281+
// and inject live markdown (mentions, masked links) into the channel.
282+
func TestNotifyCodeBlockBreakoutNeutralized(t *testing.T) {
283+
var c capture
284+
srv := captureServer(t, &c)
285+
286+
evil := []finding.Finding{{
287+
Target: "https://evil.test",
288+
Module: "probe",
289+
Severity: finding.SeverityHigh,
290+
Key: "probe:x",
291+
Title: "```\n@everyone pwned <https://evil.test|click>\n```",
292+
}}
293+
p := &discordProvider{webhook: srv.URL}
294+
if err := p.send(context.Background(), srv.Client(), evil); err != nil {
295+
t.Fatalf("send: %v", err)
296+
}
297+
var payload discordPayload
298+
if err := json.Unmarshal(c.body, &payload); err != nil {
299+
t.Fatalf("unmarshal: %v", err)
300+
}
301+
// a clean payload has exactly the 2 fences we added (open+close); any more
302+
// means attacker content broke out.
303+
if fences := strings.Count(payload.Content, "```"); fences > 2 {
304+
t.Fatalf("INJECTION: attacker content added %d extra code fences, breaking out: %q", fences-2, payload.Content)
305+
}
306+
}
307+
308+
// slack resolves a bare "<...|...>" as a link/mention independent of code-block
309+
// boundaries, so the fence fix alone isn't enough for slack: the control
310+
// characters (&, <, >) must be entity-escaped too.
311+
func TestSlackEscapesControlChars(t *testing.T) {
312+
var c capture
313+
srv := captureServer(t, &c)
314+
315+
evil := []finding.Finding{{
316+
Target: "https://evil.test",
317+
Module: "probe",
318+
Severity: finding.SeverityHigh,
319+
Key: "probe:x",
320+
Title: "<https://evil.test|click> & <!everyone>",
321+
}}
322+
p := &slackProvider{webhook: srv.URL}
323+
if err := p.send(context.Background(), srv.Client(), evil); err != nil {
324+
t.Fatalf("send: %v", err)
325+
}
326+
var payload slackPayload
327+
if err := json.Unmarshal(c.body, &payload); err != nil {
328+
t.Fatalf("unmarshal: %v", err)
329+
}
330+
if strings.Contains(payload.Text, "<https://evil.test|click>") {
331+
t.Fatalf("INJECTION: unescaped slack link syntax reached the payload: %q", payload.Text)
332+
}
333+
if !strings.Contains(payload.Text, "&lt;https://evil.test|click&gt;") || !strings.Contains(payload.Text, "&amp;") {
334+
t.Fatalf("expected slack control chars entity-escaped, got: %q", payload.Text)
335+
}
336+
}
337+
338+
// robustness sanity: confirm a zero http.Client.Timeout would mean an
339+
// unbounded client (not a bug in notify per se, but documents that ctx - not
340+
// Timeout - is what bounds a hung endpoint here).
341+
func TestNotifyZeroTimeoutIsUnbounded(t *testing.T) {
342+
blocked := make(chan struct{})
343+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
344+
<-blocked
345+
}))
346+
t.Cleanup(func() { close(blocked); srv.Close() })
347+
348+
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
349+
defer cancel()
350+
p := &slackProvider{webhook: srv.URL}
351+
done := make(chan error, 1)
352+
go func() { done <- p.send(ctx, srv.Client(), sampleFindings()) }()
353+
select {
354+
case err := <-done:
355+
if err == nil {
356+
t.Fatal("expected ctx-cancel error from hung endpoint")
357+
}
358+
case <-time.After(3 * time.Second):
359+
t.Fatal("send did not honor ctx cancellation on hung endpoint")
360+
}
361+
}

internal/notify/slack.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ package notify
1515
import (
1616
"context"
1717
"net/http"
18+
"strings"
1819

1920
"github.com/vmfunc/sif/internal/finding"
2021
)
@@ -34,12 +35,35 @@ type slackPayload struct {
3435
}
3536

3637
func (s *slackProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
37-
payload := slackPayload{Text: codeBlock(renderFindings(findings))}
38+
payload := slackPayload{Text: codeBlock(escapeSlackText(renderFindings(findings)))}
3839
return postJSON(ctx, client, s.webhook, payload)
3940
}
4041

42+
// escapeSlackText entity-escapes slack's three control characters ahead of any
43+
// other formatting, per slack's api docs: & first (so the escapes below don't
44+
// double-escape), then < and >. slack's parser resolves a bare "<...|...>" as
45+
// a link/mention regardless of surrounding code-fence text, so a scanned
46+
// target's title (e.g. "<https://evil.test|click>") would otherwise render as
47+
// a live masked link.
48+
func escapeSlackText(body string) string {
49+
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
50+
return r.Replace(body)
51+
}
52+
4153
// codeBlock wraps body in a triple-backtick fence; both slack and discord render
42-
// it fixed-width, which preserves the column-aligned finding lines.
54+
// it fixed-width, which preserves the column-aligned finding lines. body runs
55+
// through sanitizeFence first so attacker-controlled finding content (a title
56+
// pulled from the scanned target) can't close the fence early and inject
57+
// markdown/mentions outside it.
4358
func codeBlock(body string) string {
44-
return "```\n" + body + "```"
59+
return "```\n" + sanitizeFence(body) + "```"
60+
}
61+
62+
// sanitizeFence breaks up any triple-backtick run inside body by interleaving
63+
// zero-width spaces between the backticks. the text still reads as backticks
64+
// to a human but neither slack nor discord treats it as a fence boundary, so
65+
// it can't prematurely close the code block we wrap it in.
66+
func sanitizeFence(body string) string {
67+
const zwsp = "\u200b" // zero-width space
68+
return strings.ReplaceAll(body, "```", "`"+zwsp+"`"+zwsp+"`")
4569
}

0 commit comments

Comments
 (0)