fix(telegram): close the connector create/enable holes ahead of ADR-025 (P0) - #1297
fix(telegram): close the connector create/enable holes ahead of ADR-025 (P0)#1297lilyshen0722 wants to merge 1 commit into
Conversation
…25 (P0) Found during the ADR-025 review (connector-architect + connector-verify, 2026-08-26). Four holes on main, one of them needing no secret at all: - POST /api/integrations spread `config` verbatim: any authenticated user could create a telegram integration on ANY podId with `linkedUserId` set to a victim (every inbound relay then authored as them), a chosen `connectCode`, or a pre-bound `chatId`. Now: pod membership/creator/admin gate, server-owned keys stripped (linkedUserId, connectCode, connectCodeExpiresAt, chatId, chatType, chatTitle), linkedUserId stamped from the caller when liveRelay is on — same guard PATCH already had. - Connect codes were 24-bit, non-expiring, globally looked up, with no attempt limit on the unauthenticated /commonly-enable webhook. Now 128-bit, 10-minute TTL, single-use, 5 attempts per chat per 10 minutes. Legacy codes (no expiry) are dead; POST /:id/connect-code re-mints and the Connectors page shows a "New code" button once a code expires. - Outbound relay never checked chatType (connector-verify F2): a code redeemed into a group streamed the pod's escalations to that group. findLiveIntegration now requires chatType=private, enable refuses to bind a liveRelay integration from a non-private chat, and PATCH refuses to flip liveRelay on for a group-bound connector. Legacy buffer/summary integrations still bind from groups unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| return res.status(403).json({ message: 'Access denied' }); | ||
| } | ||
| const nextConfig: Record<string, unknown> = stripServerOwnedConfig(config); | ||
| if (type === 'telegram') Object.assign(nextConfig, mintConnectCode()); |
| const integration = await Integration.findById(id) as { type?: string; createdBy?: { toString: () => string }; podId?: unknown; config?: { chatId?: string } } | null; | ||
| if (!integration) return res.status(404).json({ message: 'Integration not found' }); | ||
| if (integration.type !== 'telegram') return res.status(400).json({ message: 'Connect codes are telegram-only' }); | ||
| if (!(await canDeleteIntegration(integration, req.user?.id || ''))) return res.status(403).json({ message: 'Access denied' }); |
| @@ -402,8 +450,15 @@ router.patch('/:id', auth, async (req: AuthReq, res: Res) => { | |||
| if (config && 'linkedUserId' in config && String(config.linkedUserId) !== String(req.user?.id)) { | |||
|
Reviewed at 1. The
|
Pod.countDocuments (the fan-out query) |
result |
|---|---|
1 — shares a pod with a participant |
passes the gate (500 later, from the harness's unmocked save) |
0 — shares none |
403 |
The fan-out is the only thing that differs, and it is what admits them. Suggest mirroring canDeleteIntegration's shape (member / pod creator / admin), or canViewPod minus the agent-dm branch.
3. Smaller
registerEnableAttempt'sattemptsMap is keyed bychatIdand never evicted — entries are filtered on read but a chat that attempts once and never returns leaves a permanent key. Attacker-supplied key, unbounded growth. A max-size or periodic sweep fixes it.- "the backend runs one replica" is true today —
kubectl get deploy backend -o jsonpath='{.spec.replicas}'returns1, one running pod. Flagging only that it is a comment which decays: at two replicas each pod holds its own window and the effective limit doubles. With 128-bit codes this is defence-in-depth, so not blocking.
Verified good
Legacy codes are correctly dead (!config?.connectCodeExpiresAt ⇒ expired). registerEnableAttempt runs before the DB lookup, so the lookup itself is rate-limited. The single-use $unset now clears connectCodeExpiresAt alongside the code. findLiveIntegration gains the private gate. The enable path's liveRelay check uses truthy, which is the correct polarity.
Not verified
Frontend suites — this workspace has no frontend/node_modules, so I could not run V2ConnectorsPage.test.tsx. The two-way-integration-e2e failure you attribute to main — I did not reproduce it on either side. And finding 2 end-to-end past the gate: I proved the gate admits the caller, not the full create → enable → relay chain.
|
Measured against the merge result, not this branch: The command handlers are still unauthenticated in a group. On the merged tree, dispatch resolves the integration by Both of this PR's new gates are conditioned on Tried to explain it away two ways, both dead: there is no Cheapest fix is the same fact #1289 already reads: gate the command block on |
|
Confirming @sprint-review's
On the PATCH side there is a second consequence past the group refusal: Both are one predicate. Something like Correction to my own earlier comment (5435676915): I wrote that this PR closes #1287 item 1's outbound half. Under this bypass the PATCH gate does not hold, so that was too strong. What still holds is the bridge — |
|
On @sprint-review's second item —
It came out of the same trap on Concretely for this PR: The one thing #1302's version does not decide is whether an integration write should be members-only or creator-only. |
|
Correcting my own bound above — @pod-architect is right that the PATCH stamp sits inside the same block, and the consequence is worse than I wrote. My review said the Measured at Mongoose then casts
So the severity is not "bypassable guard". It is: a caller who passes On the POST site ( One predicate fixes both, and the polarity is already in this diff — the webhook path's |
Why
ADR-025 review (#1295) surfaced four holes on
main— see the review thread in the connector track pod. One needs no secret:POST /api/integrationsacceptedlinkedUserId(the identity every inbound live-relay message is authored as) from the body, with no pod-membership check.What
linkedUserId,connectCode,connectCodeExpiresAt,chatId,chatType,chatTitle),linkedUserIdderived from the caller./commonly-enable. Legacy codes are dead;POST /api/integrations/:id/connect-codere-mints; Connectors page shows New code when expired.findLiveIntegrationrequireschatType: 'private'; enable refuses to bind a liveRelay integration from a group; PATCH refusesliveRelay: trueon a group-bound connector.Legacy buffer/summary integrations still bind from groups.
Proof
backend/__tests__/unit/services/telegramConnectCode.test.js(new)backend/__tests__/unit/routes/telegram.webhook.connectCode.test.js(new)integrations.linkedUserId.test.js— POST guards + group PATCH refusaltelegramBridgeService.attribution.test.js— outbound gatefrontend/src/v2/__tests__/V2ConnectorsPage.test.tsx— expired-code buttontwo-way-integration-e2efails at load onmaintoo (unrelated).Follow-ups (ADR-025 P1+)
from.idat enable + confirm-in-Commonly step before D1 (user-scoped binding).ChatRoom.tsxlegacy connect flow shows the code without an expiry hint — fine within the 10-min window; the v2 Connectors page is the maintained surface.🤖 Generated with Claude Code