Skip to content

Commit b0397fc

Browse files
authored
Merge pull request #334 from TBX3D/harden-notify-clean
fix(notify): neutralize markdown injection and redact webhook url from errors
2 parents cd7e21f + 4de8fae commit b0397fc

3 files changed

Lines changed: 187 additions & 9 deletions

File tree

internal/notify/message.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import (
1616
"bytes"
1717
"context"
1818
"encoding/json"
19+
"errors"
1920
"fmt"
2021
"net/http"
22+
"net/url"
2123
"strings"
2224

2325
"github.com/vmfunc/sif/internal/finding"
@@ -46,24 +48,25 @@ func renderFindings(findings []finding.Finding) string {
4648
return b.String()
4749
}
4850

49-
// postJSON marshals payload and POSTs it to url through the shared client. it
50-
// drains+closes the response so the conn returns to httpx's pool, and treats any
51-
// non-2xx as a delivery failure so a 4xx from a bad webhook surfaces loudly.
52-
func postJSON(ctx context.Context, client *http.Client, url string, payload any) error {
51+
// postJSON marshals payload and POSTs it to endpoint through the shared
52+
// client. it drains+closes the response so the conn returns to httpx's pool,
53+
// and treats any non-2xx as a delivery failure so a 4xx from a bad webhook
54+
// surfaces loudly.
55+
func postJSON(ctx context.Context, client *http.Client, endpoint string, payload any) error {
5356
body, err := json.Marshal(payload)
5457
if err != nil {
5558
return fmt.Errorf("marshal payload: %w", err)
5659
}
5760

58-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
61+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
5962
if err != nil {
6063
return fmt.Errorf("build request: %w", err)
6164
}
6265
req.Header.Set("Content-Type", contentTypeJSON)
6366

6467
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
6568
if err != nil {
66-
return fmt.Errorf("post: %w", err)
69+
return fmt.Errorf("post to %s: %w", req.URL.Host, redactTransportErr(err))
6770
}
6871
defer httpx.DrainClose(resp)
6972

@@ -72,3 +75,16 @@ func postJSON(ctx context.Context, client *http.Client, url string, payload any)
7275
}
7376
return nil
7477
}
78+
79+
// redactTransportErr strips the webhook url out of a client.Do failure. for
80+
// these providers the url IS the credential, and http.Client wraps every
81+
// transport failure in a *url.Error whose Error() quotes it verbatim; unwrap
82+
// to the underlying cause (which only ever mentions host:port) and let the
83+
// caller prefix the host separately.
84+
func redactTransportErr(err error) error {
85+
var urlErr *url.Error
86+
if errors.As(err, &urlErr) {
87+
return urlErr.Err
88+
}
89+
return err
90+
}

internal/notify/notify_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,140 @@ func assertPostJSON(t *testing.T, c capture) {
222222
t.Errorf("content-type = %q, want %q", c.contentType, contentTypeJSON)
223223
}
224224
}
225+
226+
// deadURL returns a URL that will refuse connection: bind a listener, close
227+
// it, reuse the address. good enough to force a transport-level error out of
228+
// client.Do without touching the network.
229+
func deadURL(t *testing.T) string {
230+
t.Helper()
231+
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
232+
u := srv.URL
233+
srv.Close()
234+
return u
235+
}
236+
237+
// see redactTransportErr's doc comment (message.go) for why this matters.
238+
func TestNotifyErrorRedactsSecretWebhookURL(t *testing.T) {
239+
host := deadURL(t)
240+
secret := host + "/services/T00000000/B11111111/SUPERSECRETTOKEN"
241+
p := &slackProvider{webhook: secret}
242+
err := p.send(context.Background(), http.DefaultClient, sampleFindings())
243+
if err == nil {
244+
t.Fatal("expected transport error")
245+
}
246+
if strings.Contains(err.Error(), "SUPERSECRETTOKEN") {
247+
t.Fatalf("LEAK: secret webhook token present in error: %v", err)
248+
}
249+
if !strings.Contains(err.Error(), strings.TrimPrefix(host, "http://")) {
250+
t.Errorf("error dropped the host too, operator can't debug: %v", err)
251+
}
252+
}
253+
254+
func TestNotifyErrorRedactsTelegramToken(t *testing.T) {
255+
orig := telegramAPIBase
256+
host := deadURL(t)
257+
telegramAPIBase = host
258+
t.Cleanup(func() { telegramAPIBase = orig })
259+
260+
p := &telegramProvider{token: "123456:AAHsupersecretbottoken", chatID: "42"}
261+
err := p.send(context.Background(), http.DefaultClient, sampleFindings())
262+
if err == nil {
263+
t.Fatal("expected transport error")
264+
}
265+
if strings.Contains(err.Error(), "AAHsupersecretbottoken") {
266+
t.Fatalf("LEAK: telegram bot token present in error: %v", err)
267+
}
268+
if !strings.Contains(err.Error(), strings.TrimPrefix(host, "http://")) {
269+
t.Errorf("error dropped the host too, operator can't debug: %v", err)
270+
}
271+
}
272+
273+
// attacker-controlled finding content (a scanned target's page title, a
274+
// crawled url, a cms name) reaches the slack/discord code block verbatim. a
275+
// title that embeds a closing fence used to break out of our wrapping block
276+
// and inject live markdown (mentions, masked links) into the channel.
277+
func TestNotifyCodeBlockBreakoutNeutralized(t *testing.T) {
278+
var c capture
279+
srv := captureServer(t, &c)
280+
281+
// a backtick run of any length has to come out broken; 5, 8 and 11 are the
282+
// lengths that reformed a fence when only exact triples were replaced.
283+
for _, n := range []int{3, 4, 5, 6, 8, 11} {
284+
run := strings.Repeat("`", n)
285+
evil := []finding.Finding{{
286+
Target: "https://evil.test",
287+
Module: "probe",
288+
Severity: finding.SeverityHigh,
289+
Key: "probe:x",
290+
Title: run + "\n@everyone pwned <https://evil.test|click>\n" + run,
291+
}}
292+
p := &discordProvider{webhook: srv.URL}
293+
if err := p.send(context.Background(), srv.Client(), evil); err != nil {
294+
t.Fatalf("run of %d: send: %v", n, err)
295+
}
296+
var payload discordPayload
297+
if err := json.Unmarshal(c.body, &payload); err != nil {
298+
t.Fatalf("run of %d: unmarshal: %v", n, err)
299+
}
300+
// a clean payload has exactly the 2 fences we added (open+close); any more
301+
// means attacker content broke out.
302+
if fences := strings.Count(payload.Content, "```"); fences > 2 {
303+
t.Fatalf("INJECTION: run of %d backticks added %d extra code fences, breaking out: %q", n, fences-2, payload.Content)
304+
}
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: 28 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,36 @@ 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 (& first, so
43+
// the later replacements don't double-escape). slack resolves a bare
44+
// "<...|...>" as a link/mention regardless of surrounding code-fence text, so
45+
// an unescaped title would otherwise render as a live masked link.
46+
func escapeSlackText(body string) string {
47+
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
48+
return r.Replace(body)
49+
}
50+
4151
// codeBlock wraps body in a triple-backtick fence; both slack and discord render
42-
// it fixed-width, which preserves the column-aligned finding lines.
52+
// it fixed-width, which preserves the column-aligned finding lines. body runs
53+
// through sanitizeFence first so attacker-controlled finding content (a title
54+
// pulled from the scanned target) can't close the fence early and inject
55+
// markdown/mentions outside it.
4356
func codeBlock(body string) string {
44-
return "```\n" + body + "```"
57+
return "```\n" + sanitizeFence(body) + "```"
58+
}
59+
60+
// sanitizeFence separates every backtick in body from the next with a
61+
// zero-width space. the text still reads as backticks to a human but no two
62+
// are ever adjacent, so neither slack nor discord sees a fence boundary and
63+
// attacker content can't close the code block we wrap it in.
64+
//
65+
// breaking exact triples instead would leave a trailing bare backtick, and any
66+
// run of length \u2261 2 mod 3 (5, 8, 11...) would reform a contiguous triple.
67+
func sanitizeFence(body string) string {
68+
const zwsp = "\u200b" // zero-width space
69+
return strings.ReplaceAll(body, "`", "`"+zwsp)
4570
}

0 commit comments

Comments
 (0)