@@ -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 , "<https://evil.test|click>" ) || ! strings .Contains (payload .Text , "&" ) {
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+ }
0 commit comments