fix(telegram): keep legacy webhooks working via Telegram source-IP fallback

The secret-token check rejected every webhook registered before secret_token
support, breaking live triggers until re-saved. Fall back to verifying the
request originates from Telegram's published webhook IP ranges when no secret
is configured, so existing triggers keep firing with no re-save or migration
while forged updates from arbitrary hosts are still rejected. Webhooks with a
registered secret continue to use strict constant-time token verification.
This commit is contained in:
waleed
2026-06-10 09:02:22 -07:00
parent 87dface081
commit 41f133a9d7
2 changed files with 85 additions and 19 deletions
@@ -1,5 +1,6 @@
import { requestUtilsMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { telegramHandler } from '@/lib/webhooks/providers/telegram'
function reqWithHeaders(headers: Record<string, string>): NextRequest {
@@ -7,7 +8,11 @@ function reqWithHeaders(headers: Record<string, string>): NextRequest {
}
describe('Telegram webhook provider', () => {
it('verifyAuth rejects when secretToken is not configured', () => {
beforeEach(() => {
requestUtilsMockFns.mockGetClientIp.mockReturnValue('203.0.113.7')
})
it('verifyAuth rejects an unconfigured webhook when the source IP is not Telegram', () => {
const res = telegramHandler.verifyAuth!({
request: reqWithHeaders({ 'x-telegram-bot-api-secret-token': 'anything' }),
rawBody: '{}',
@@ -19,6 +24,32 @@ describe('Telegram webhook provider', () => {
expect((res as { status?: number })?.status).toBe(401)
})
it('verifyAuth accepts a legacy webhook (no secret) from a Telegram source IP', () => {
requestUtilsMockFns.mockGetClientIp.mockReturnValue('149.154.167.197')
const res = telegramHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1b',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('verifyAuth rejects a legacy webhook (no secret) when the source IP is unknown', () => {
requestUtilsMockFns.mockGetClientIp.mockReturnValue('unknown')
const res = telegramHandler.verifyAuth!({
request: reqWithHeaders({}),
rawBody: '{}',
requestId: 't1c',
providerConfig: {},
webhook: {},
workflow: {},
})
expect((res as { status?: number })?.status).toBe(401)
})
it('verifyAuth rejects when the secret token header is missing', () => {
const res = telegramHandler.verifyAuth!({
request: reqWithHeaders({}),
+52 -17
View File
@@ -3,7 +3,9 @@ import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, ne } from 'drizzle-orm'
import * as ipaddr from 'ipaddr.js'
import { NextResponse } from 'next/server'
import { getClientIp } from '@/lib/core/utils/request'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
@@ -17,28 +19,61 @@ import type {
const logger = createLogger('WebhookProvider:Telegram')
/**
* Telegram's published source ranges for webhook delivery.
* @see https://core.telegram.org/bots/webhooks
*/
const TELEGRAM_WEBHOOK_CIDRS: ReadonlyArray<readonly [string, number]> = [
['149.154.160.0', 20],
['91.108.4.0', 22],
] as const
/** Whether a client IP falls inside Telegram's documented webhook source ranges. */
function isTelegramWebhookIp(ip: string): boolean {
if (!ip || ip === 'unknown' || !ipaddr.isValid(ip)) {
return false
}
try {
const addr = ipaddr.process(ip)
if (addr.kind() !== 'ipv4') {
return false
}
return TELEGRAM_WEBHOOK_CIDRS.some(([network, prefix]) =>
addr.match([ipaddr.parse(network), prefix])
)
} catch {
return false
}
}
export const telegramHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
const secretToken = (providerConfig.secretToken as string | undefined)?.trim()
if (!secretToken) {
if (secretToken) {
const providedToken = request.headers.get('x-telegram-bot-api-secret-token')
if (!providedToken) {
logger.warn(
`[${requestId}] Telegram webhook missing secret token header — rejecting request`
)
return new NextResponse('Unauthorized - Missing Telegram secret token', { status: 401 })
}
if (!safeCompare(providedToken, secretToken)) {
logger.warn(`[${requestId}] Telegram secret token verification failed`)
return new NextResponse('Unauthorized - Invalid Telegram secret token', { status: 401 })
}
return null
}
const clientIp = getClientIp(request)
if (!isTelegramWebhookIp(clientIp)) {
logger.warn(
`[${requestId}] Telegram webhook missing secretToken in providerConfig — rejecting request. Re-save the trigger so a secret token can be registered with Telegram.`
`[${requestId}] Telegram webhook without a registered secret token rejected — source IP is not in Telegram's published ranges. Re-save the trigger to enable secret-token verification.`,
{ clientIp }
)
return new NextResponse(
'Unauthorized - Telegram webhook secret token is not configured. Re-save the trigger so a webhook can be registered.',
{ status: 401 }
)
}
const providedToken = request.headers.get('x-telegram-bot-api-secret-token')
if (!providedToken) {
logger.warn(`[${requestId}] Telegram webhook missing secret token header — rejecting request`)
return new NextResponse('Unauthorized - Missing Telegram secret token', { status: 401 })
}
if (!safeCompare(providedToken, secretToken)) {
logger.warn(`[${requestId}] Telegram secret token verification failed`)
return new NextResponse('Unauthorized - Invalid Telegram secret token', { status: 401 })
return new NextResponse('Unauthorized - Untrusted Telegram webhook source', { status: 401 })
}
return null