Skip to content

Commit 56f3500

Browse files
committed
fix(telegram): stop auto-linking destinations in /policy show
Telegram was rendering every destination string in /policy show output as a clickable blue link (example.com, api.github.com, etc.) because the message was sent in plain text mode and Telegram auto-detects URL patterns. Wrap the output in <pre>...</pre> and send with ParseMode=HTML + DisableWebPagePreview. Inside a preformatted block Telegram leaves URL patterns alone, and the monospace rendering also aligns the columns better. - commands.go: htmlPreOpen/htmlPreClose constants, htmlEscapeText helper, wrap both policyShow (engine fallback) and policyShowFromStore in <pre>. Escape destination, tool, pattern, replacement, name, source, default verdict, and protocols. - approval.go: sendReply path sniffs the <pre> prefix and switches to HTML parse mode with web-page preview disabled. Other replies are still plain text. - Tests: TestPolicyShowEscapesHTML and a wrapping check in TestPolicyShowIncludesAllFields.
1 parent b519e2c commit 56f3500

3 files changed

Lines changed: 79 additions & 9 deletions

File tree

internal/telegram/approval.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,14 @@ func (tc *TelegramChannel) handleMessage(msg *tgbotapi.Message) {
434434
}
435435

436436
reply := tgbotapi.NewMessage(tc.chatID, response)
437+
// Responses that start with "<pre>" are pre-rendered HTML (see
438+
// policyShow / policyShowFromStore). Send them with HTML parse mode so
439+
// the block renders monospaced and Telegram does not auto-link URLs
440+
// inside. All other responses are plain text.
441+
if strings.HasPrefix(response, "<pre>") {
442+
reply.ParseMode = tgbotapi.ModeHTML
443+
reply.DisableWebPagePreview = true
444+
}
437445
if _, err := tc.api.Send(reply); err != nil {
438446
log.Printf("telegram send error: %s", sanitizeError(err))
439447
}

internal/telegram/commands.go

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,9 @@ func (h *CommandHandler) policyShow() string {
216216
// Fallback to engine snapshot when store is not configured.
217217
snap := h.engine.Load().Snapshot()
218218
var b strings.Builder
219+
b.WriteString(htmlPreOpen)
219220
b.WriteString("Current policy (default: ")
220-
b.WriteString(snap.Default.String())
221+
b.WriteString(htmlEscapeText(snap.Default.String()))
221222
b.WriteString(")\n\n")
222223

223224
for _, section := range []struct {
@@ -235,14 +236,14 @@ func (h *CommandHandler) policyShow() string {
235236
b.WriteString(":\n")
236237
for _, r := range section.rules {
237238
b.WriteString(" ")
238-
b.WriteString(r.Destination)
239+
b.WriteString(htmlEscapeText(r.Destination))
239240
if len(r.Ports) > 0 {
240241
b.WriteString(" ports=")
241242
b.WriteString(formatPorts(r.Ports))
242243
}
243244
if len(r.Protocols) > 0 {
244245
b.WriteString(" protocols=")
245-
b.WriteString(strings.Join(r.Protocols, ","))
246+
b.WriteString(htmlEscapeText(strings.Join(r.Protocols, ",")))
246247
}
247248
b.WriteString("\n")
248249
}
@@ -252,9 +253,32 @@ func (h *CommandHandler) policyShow() string {
252253
b.WriteString("No rules configured.")
253254
}
254255

256+
b.WriteString(htmlPreClose)
255257
return b.String()
256258
}
257259

260+
// htmlPreOpen and htmlPreClose wrap Telegram HTML messages in a preformatted
261+
// block. Using <pre> achieves two things: monospace fixed-width rendering so
262+
// columns line up, and disabling Telegram's URL auto-linking so destinations
263+
// like `api.github.com` do not render as clickable blue links.
264+
//
265+
// Callers send the resulting string with ParseMode=HTML; see the sendReply
266+
// path in approval.go which sniffs the <pre> prefix.
267+
const (
268+
htmlPreOpen = "<pre>"
269+
htmlPreClose = "</pre>"
270+
)
271+
272+
// htmlEscapeText escapes the three characters that Telegram's HTML parse mode
273+
// requires in text nodes: &, <, >. Quotes and apostrophes are left alone
274+
// since we don't use them in tag attributes.
275+
func htmlEscapeText(s string) string {
276+
s = strings.ReplaceAll(s, "&", "&amp;")
277+
s = strings.ReplaceAll(s, "<", "&lt;")
278+
s = strings.ReplaceAll(s, ">", "&gt;")
279+
return s
280+
}
281+
258282
func (h *CommandHandler) policyShowFromStore() string {
259283
cfg, err := h.store.GetConfig()
260284
if err != nil {
@@ -271,8 +295,9 @@ func (h *CommandHandler) policyShowFromStore() string {
271295
}
272296

273297
var b strings.Builder
298+
b.WriteString(htmlPreOpen)
274299
b.WriteString("Current policy (default: ")
275-
b.WriteString(dv)
300+
b.WriteString(htmlEscapeText(dv))
276301
b.WriteString(")\n\n")
277302

278303
for _, section := range []struct {
@@ -302,23 +327,23 @@ func (h *CommandHandler) policyShowFromStore() string {
302327
} else if r.Pattern != "" {
303328
target = "pattern:" + r.Pattern
304329
}
305-
fmt.Fprintf(&b, " [%d] %s", r.ID, target)
330+
fmt.Fprintf(&b, " [%d] %s", r.ID, htmlEscapeText(target))
306331
if len(r.Ports) > 0 {
307332
b.WriteString(" ports=")
308333
b.WriteString(formatPorts(r.Ports))
309334
}
310335
if len(r.Protocols) > 0 {
311336
b.WriteString(" protocols=")
312-
b.WriteString(strings.Join(r.Protocols, ","))
337+
b.WriteString(htmlEscapeText(strings.Join(r.Protocols, ",")))
313338
}
314339
if r.Replacement != "" {
315-
fmt.Fprintf(&b, " -> %q", r.Replacement)
340+
fmt.Fprintf(&b, " -> %q", htmlEscapeText(r.Replacement))
316341
}
317342
if r.Name != "" {
318-
fmt.Fprintf(&b, " (%s)", r.Name)
343+
fmt.Fprintf(&b, " (%s)", htmlEscapeText(r.Name))
319344
}
320345
if r.Source != "" {
321-
fmt.Fprintf(&b, " [%s]", r.Source)
346+
fmt.Fprintf(&b, " [%s]", htmlEscapeText(r.Source))
322347
}
323348
b.WriteString("\n")
324349
}
@@ -328,6 +353,7 @@ func (h *CommandHandler) policyShowFromStore() string {
328353
b.WriteString("No rules configured.")
329354
}
330355

356+
b.WriteString(htmlPreClose)
331357
return b.String()
332358
}
333359

internal/telegram/commands_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,42 @@ func TestPolicyShowIncludesAllFields(t *testing.T) {
483483
t.Errorf("policy show output missing %q\nfull output:\n%s", want, out)
484484
}
485485
}
486+
487+
// Wrap in <pre>...</pre> so the sender uses HTML parse mode, which
488+
// disables Telegram's URL auto-linking for destinations like
489+
// example.com.
490+
if !strings.HasPrefix(out, "<pre>") || !strings.HasSuffix(out, "</pre>") {
491+
t.Errorf("policy show output must be wrapped in <pre>...</pre>: %q", out)
492+
}
493+
}
494+
495+
func TestPolicyShowEscapesHTML(t *testing.T) {
496+
s := newTestStore(t)
497+
if _, err := s.AddRule("deny", store.RuleOpts{
498+
Pattern: "<script>",
499+
Source: "manual",
500+
}); err != nil {
501+
t.Fatal(err)
502+
}
503+
if _, err := s.AddRule("redact", store.RuleOpts{
504+
Pattern: "a&b",
505+
Replacement: "<x>",
506+
}); err != nil {
507+
t.Fatal(err)
508+
}
509+
510+
handler := newTestHandlerWithStore(t, s, nil, "")
511+
out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}})
512+
513+
if strings.Contains(out, "<script>") {
514+
t.Errorf("raw <script> must be HTML-escaped: %s", out)
515+
}
516+
if !strings.Contains(out, "&lt;script&gt;") {
517+
t.Errorf("expected &lt;script&gt; in output: %s", out)
518+
}
519+
if !strings.Contains(out, "a&amp;b") {
520+
t.Errorf("expected a&amp;b in output: %s", out)
521+
}
486522
}
487523

488524
func TestPolicyRemoveThenRecompile(t *testing.T) {

0 commit comments

Comments
 (0)