Skip to content

Commit 6b3946f

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 6b3946f

3 files changed

Lines changed: 78 additions & 18 deletions

File tree

internal/telegram/approval.go

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

436436
reply := tgbotapi.NewMessage(tc.chatID, response)
437+
// Responses that contain <code> or <b> are pre-rendered HTML (see
438+
// policyShow / policyShowFromStore). Send them with HTML parse mode so
439+
// the tags render and Telegram does not auto-link URLs inside <code>.
440+
// Plain-text responses never contain these tags because dynamic input
441+
// gets HTML-escaped before insertion.
442+
if strings.Contains(response, "<code>") || strings.Contains(response, "<b>") {
443+
reply.ParseMode = tgbotapi.ModeHTML
444+
reply.DisableWebPagePreview = true
445+
}
437446
if _, err := tc.api.Send(reply); err != nil {
438447
log.Printf("telegram send error: %s", sanitizeError(err))
439448
}

internal/telegram/commands.go

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,7 @@ 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("Current policy (default: ")
220-
b.WriteString(snap.Default.String())
221-
b.WriteString(")\n\n")
219+
fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(snap.Default.String()))
222220

223221
for _, section := range []struct {
224222
label string
@@ -235,14 +233,14 @@ func (h *CommandHandler) policyShow() string {
235233
b.WriteString(":\n")
236234
for _, r := range section.rules {
237235
b.WriteString(" ")
238-
b.WriteString(r.Destination)
236+
b.WriteString(htmlCode(r.Destination))
239237
if len(r.Ports) > 0 {
240238
b.WriteString(" ports=")
241239
b.WriteString(formatPorts(r.Ports))
242240
}
243241
if len(r.Protocols) > 0 {
244242
b.WriteString(" protocols=")
245-
b.WriteString(strings.Join(r.Protocols, ","))
243+
b.WriteString(htmlCode(strings.Join(r.Protocols, ",")))
246244
}
247245
b.WriteString("\n")
248246
}
@@ -255,6 +253,23 @@ func (h *CommandHandler) policyShow() string {
255253
return b.String()
256254
}
257255

256+
// htmlEscapeText escapes the three characters that Telegram's HTML parse mode
257+
// requires in text nodes: &, <, >. Quotes and apostrophes are left alone
258+
// since we don't use them in tag attributes.
259+
func htmlEscapeText(s string) string {
260+
s = strings.ReplaceAll(s, "&", "&amp;")
261+
s = strings.ReplaceAll(s, "<", "&lt;")
262+
s = strings.ReplaceAll(s, ">", "&gt;")
263+
return s
264+
}
265+
266+
// htmlCode wraps s in <code>...</code> after HTML-escaping it. Inside <code>
267+
// Telegram will not auto-detect URLs, so destinations like api.github.com
268+
// render as monospace text rather than clickable blue links.
269+
func htmlCode(s string) string {
270+
return "<code>" + htmlEscapeText(s) + "</code>"
271+
}
272+
258273
func (h *CommandHandler) policyShowFromStore() string {
259274
cfg, err := h.store.GetConfig()
260275
if err != nil {
@@ -271,9 +286,7 @@ func (h *CommandHandler) policyShowFromStore() string {
271286
}
272287

273288
var b strings.Builder
274-
b.WriteString("Current policy (default: ")
275-
b.WriteString(dv)
276-
b.WriteString(")\n\n")
289+
fmt.Fprintf(&b, "Current policy (default: %s)\n\n", htmlCode(dv))
277290

278291
for _, section := range []struct {
279292
label string
@@ -293,32 +306,33 @@ func (h *CommandHandler) policyShowFromStore() string {
293306
if len(sectionRules) == 0 {
294307
continue
295308
}
309+
b.WriteString("<b>")
296310
b.WriteString(section.label)
297-
b.WriteString(":\n")
311+
b.WriteString("</b>:\n")
298312
for _, r := range sectionRules {
299313
target := r.Destination
300314
if r.Tool != "" {
301315
target = "tool:" + r.Tool
302316
} else if r.Pattern != "" {
303317
target = "pattern:" + r.Pattern
304318
}
305-
fmt.Fprintf(&b, " [%d] %s", r.ID, target)
319+
fmt.Fprintf(&b, " [%d] %s", r.ID, htmlCode(target))
306320
if len(r.Ports) > 0 {
307321
b.WriteString(" ports=")
308322
b.WriteString(formatPorts(r.Ports))
309323
}
310324
if len(r.Protocols) > 0 {
311325
b.WriteString(" protocols=")
312-
b.WriteString(strings.Join(r.Protocols, ","))
326+
b.WriteString(htmlCode(strings.Join(r.Protocols, ",")))
313327
}
314328
if r.Replacement != "" {
315-
fmt.Fprintf(&b, " -> %q", r.Replacement)
329+
fmt.Fprintf(&b, " -> %s", htmlCode(r.Replacement))
316330
}
317331
if r.Name != "" {
318-
fmt.Fprintf(&b, " (%s)", r.Name)
332+
fmt.Fprintf(&b, " (%s)", htmlEscapeText(r.Name))
319333
}
320334
if r.Source != "" {
321-
fmt.Fprintf(&b, " [%s]", r.Source)
335+
fmt.Fprintf(&b, " [%s]", htmlEscapeText(r.Source))
322336
}
323337
b.WriteString("\n")
324338
}

internal/telegram/commands_test.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -469,20 +469,57 @@ func TestPolicyShowIncludesAllFields(t *testing.T) {
469469
out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}})
470470

471471
mustContain := []string{
472-
"example.com",
472+
"<code>example.com</code>",
473473
"ports=443",
474-
"protocols=quic",
474+
"protocols=<code>quic</code>",
475475
"(test rule)",
476476
"[manual]",
477-
"pattern:sk-[A-Za-z0-9]+",
478-
`-> "sk-REDACTED"`,
477+
"<code>pattern:sk-[A-Za-z0-9]+</code>",
478+
"-> <code>sk-REDACTED</code>",
479479
"[seed]",
480480
}
481481
for _, want := range mustContain {
482482
if !strings.Contains(out, want) {
483483
t.Errorf("policy show output missing %q\nfull output:\n%s", want, out)
484484
}
485485
}
486+
487+
// Section headers are bolded so the sender picks HTML parse mode,
488+
// which also disables Telegram's URL auto-linking inside <code>.
489+
if !strings.Contains(out, "<b>ALLOW</b>") {
490+
t.Errorf("policy show output must bold section headers: %q", out)
491+
}
492+
}
493+
494+
func TestPolicyShowEscapesHTML(t *testing.T) {
495+
s := newTestStore(t)
496+
if _, err := s.AddRule("deny", store.RuleOpts{
497+
Pattern: "<script>",
498+
Source: "manual",
499+
}); err != nil {
500+
t.Fatal(err)
501+
}
502+
if _, err := s.AddRule("redact", store.RuleOpts{
503+
Pattern: "a&b",
504+
Replacement: "<x>",
505+
}); err != nil {
506+
t.Fatal(err)
507+
}
508+
509+
handler := newTestHandlerWithStore(t, s, nil, "")
510+
out := handler.Handle(&Command{Name: "policy", Args: []string{"show"}})
511+
512+
// Raw "<script>" must not survive the <code> wrapper. It should be
513+
// rendered as "<code>pattern:&lt;script&gt;</code>".
514+
if strings.Contains(out, "pattern:<script>") {
515+
t.Errorf("raw <script> must be HTML-escaped: %s", out)
516+
}
517+
if !strings.Contains(out, "&lt;script&gt;") {
518+
t.Errorf("expected &lt;script&gt; in output: %s", out)
519+
}
520+
if !strings.Contains(out, "a&amp;b") {
521+
t.Errorf("expected a&amp;b in output: %s", out)
522+
}
486523
}
487524

488525
func TestPolicyRemoveThenRecompile(t *testing.T) {

0 commit comments

Comments
 (0)