Files
replypier/e2e/specs/channels/forged-webhooks.spec.ts
WangShen007 6cd904d460 docs: 交付文档、维护指引与端到端回归
补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
2026-10-21 21:40:00 +08:00

101 lines
7.3 KiB
TypeScript

import {
test, expect, connectEmail, connectLine, connectMessenger, connectSms, connectWhatsAppNumber, digits,
expectUnchanged, hmac, installSlackWorkspace, metaSignature, metaToken, phoneNumber, routeToAgent, sendWebhook,
slackSignature, unique, whatsappPayload, TelegramBot,
} from '@e2e'
// Anyone can POST to a webhook URL. These are the requests a provider would never
// send: unsigned, wrongly signed, replayed — and events that are signed but must
// never start an AI turn (our own echoes, delivery receipts, other bots).
test.describe('forged webhooks are refused', () => {
test('Meta (WhatsApp, Messenger, Instagram): a wrong or missing signature @channels @security', async ({ adminApi, request }) => {
const { phoneNumberId } = await connectWhatsAppNumber(adminApi)
const json = JSON.stringify(whatsappPayload(phoneNumberId, phoneNumber().slice(1), 'Mallory', { type: 'text', text: { body: 'hi' } }))
const wrongKey = `sha256=${hmac('sha256', 'not-the-app-secret', json).toString('hex')}`
expect((await sendWebhook(request, '/webhooks/meta', { json }, { 'x-hub-signature-256': wrongKey })).status()).toBe(403)
expect((await sendWebhook(request, '/webhooks/meta', { json })).status()).toBe(403)
})
test('Slack: a wrong signature, or a correctly signed request replayed later @channels @security', async ({ request }) => {
const json = JSON.stringify({ type: 'event_callback', team_id: 'T0FORGED', event: { type: 'message', channel: 'D1', user: 'U1', text: 'hi', ts: '1.1' } })
const now = String(Math.floor(Date.now() / 1000))
const tenMinutesAgo = String(Math.floor(Date.now() / 1000) - 600)
const wrong = { 'x-slack-request-timestamp': now, 'x-slack-signature': 'v0=00' }
expect((await sendWebhook(request, '/webhooks/slack', { json }, wrong)).status()).toBe(403)
const replayed = { 'x-slack-request-timestamp': tenMinutesAgo, 'x-slack-signature': slackSignature(json, tenMinutesAgo) }
expect((await sendWebhook(request, '/webhooks/slack', { json }, replayed)).status()).toBe(403)
})
test('Telegram: a wrong webhook secret @channels @security', async ({ adminApi, request, agent }) => {
const { account } = await TelegramBot.connect(adminApi, request, agent.id)
const response = await sendWebhook(request, `/webhooks/telegram/${account.id}`, {
json: JSON.stringify({ update_id: 1, message: { message_id: 1, date: 0, chat: { id: 1, type: 'private' }, from: { id: 1, is_bot: false, first_name: 'M' }, text: 'hi' } }),
}, { 'x-telegram-bot-api-secret-token': 'guessed' })
expect(response.status()).toBe(403)
})
test('LINE: a signature made with the wrong channel secret @channels @security', async ({ adminApi, request }) => {
const account = await connectLine(adminApi, unique('line-secret'), unique('line-token'))
const json = JSON.stringify({ destination: account.external_account_id, events: [{ type: 'message', source: { type: 'user', userId: `U${digits(32)}` }, message: { type: 'text', id: '1', text: 'hi' } }] })
const response = await sendWebhook(request, `/webhooks/line/${account.id}`, { json }, {
'x-line-signature': hmac('sha256', 'not-the-channel-secret', json).toString('base64'),
})
expect(response.status()).toBe(403)
})
test('SMS: Twilio and Plivo signatures made with the wrong auth token @channels @security', async ({ adminApi, request }) => {
const number = phoneNumber()
const twilio = await connectSms(adminApi, 'twilio', number, { account_sid: unique('AC'), auth_token: unique('token') })
const form = { From: phoneNumber(), To: number, Body: 'hi', MessageSid: unique('SM') }
const forgedTwilio = await sendWebhook(request, `/webhooks/sms/twilio/${twilio.id}`, { form }, { 'x-twilio-signature': hmac('sha1', 'guessed', 'x').toString('base64') })
expect(forgedTwilio.status()).toBe(403)
const plivo = await connectSms(adminApi, 'plivo', phoneNumber(), { auth_id: unique('MA'), auth_token: unique('token') })
const forgedPlivo = await sendWebhook(request, `/webhooks/sms/plivo/${plivo.id}`, { form: { From: '447700900000', To: '447700900001', Text: 'hi', MessageUUID: unique('u') } }, {
'x-plivo-signature-v3': hmac('sha256', 'guessed', 'x').toString('base64'), 'x-plivo-signature-v3-nonce': 'n',
})
expect(forgedPlivo.status()).toBe(403)
})
test('email: an inbound-parse post without the inbox\'s token @channels @security', async ({ adminApi, request }) => {
const inbox = await connectEmail(adminApi, `${unique('support')}@e2e.example.com`)
const json = JSON.stringify({ from: 'mallory@example.com', to: inbox.external_account_id, subject: 'hi', text: 'hi' })
expect((await sendWebhook(request, `/webhooks/email/${inbox.id}?token=guessed`, { json })).status()).toBe(403)
expect((await sendWebhook(request, `/webhooks/email/${inbox.id}`, { json })).status()).toBe(403)
})
})
test.describe('signed events that must not start an AI turn', () => {
test('a Messenger echo of our own message, a WhatsApp delivery receipt and another bot\'s Slack message @channels', async ({ adminApi, request, agent, llm }) => {
await llm.script(agent, (s) => s.otherwise().reply('This should never be sent.'))
// Messenger: Meta echoes the page's own sends back with is_echo.
const pageId = digits(15)
const page = await connectMessenger(adminApi, pageId, metaToken(pageId))
await routeToAgent(adminApi, page.id, agent.id)
const echo = { object: 'page', entry: [{ id: pageId, messaging: [{ sender: { id: pageId }, recipient: { id: digits(16) }, timestamp: Date.now(), message: { mid: unique('m'), text: 'Hello from us', is_echo: true } }] }] }
expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(echo) }, metaSignature(JSON.stringify(echo)))).ok()).toBe(true)
// WhatsApp: delivery/read receipts arrive as `statuses`, with no messages.
const number = await connectWhatsAppNumber(adminApi)
await routeToAgent(adminApi, number.account.id, agent.id)
const receipt = { object: 'whatsapp_business_account', entry: [{ id: '1', changes: [{ field: 'messages', value: { messaging_product: 'whatsapp', metadata: { phone_number_id: number.phoneNumberId }, statuses: [{ id: 'wamid.x', status: 'read', timestamp: '1', recipient_id: '447700900000' }] } }] }] }
expect((await sendWebhook(request, '/webhooks/meta', { json: JSON.stringify(receipt) }, metaSignature(JSON.stringify(receipt)))).ok()).toBe(true)
// Slack: messages posted by a bot (including ours) carry bot_id.
const workspace = await installSlackWorkspace(adminApi, request)
await routeToAgent(adminApi, workspace.account.id, agent.id)
const teamId = workspace.teamId
const now = String(Math.floor(Date.now() / 1000))
const botMessage = JSON.stringify({ type: 'event_callback', team_id: teamId, event_id: unique('Ev'), event: { type: 'message', channel_type: 'im', channel: 'D1', bot_id: 'B1', text: 'beep', ts: `${now}.000100` } })
expect((await sendWebhook(request, '/webhooks/slack', { json: botMessage }, { 'x-slack-request-timestamp': now, 'x-slack-signature': slackSignature(botMessage, now) })).ok()).toBe(true)
// Each was accepted (a provider would retry a refusal) and none reached the model.
await expectUnchanged('model calls', async () => (await llm.requests(agent)).length, 5_000)
expect(await llm.requests(agent)).toHaveLength(0)
})
})