Skip to content

Commit 7315ea4

Browse files
committed
fix(hub/wecom): use parseAccessToken for bind validation
The naive prefix-strip accepted any text starting with `${cliApiToken}:` and stored the suffix as the namespace, so `TOKEN:foo:bar` was confirmed as namespace `foo:bar` — but parseAccessToken (used by the CLI and /api/auth) splits on the LAST colon and would parse the same input as baseToken=`TOKEN:foo`, namespace=`bar`, then reject it because the base token doesn't match. Net effect: a persisted WeCom binding for a namespace no client could ever authenticate into. Replace the manual parse with parseAccessToken + constantTimeEquals on the base token. Now WeCom accepts exactly what the rest of HAPI accepts: bare TOKEN binds to `default`, TOKEN:<ns> binds to <ns>, TOKEN:foo:bar is rejected.
1 parent 5f264a2 commit 7315ea4

2 files changed

Lines changed: 36 additions & 9 deletions

File tree

hub/src/wecom/bot.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,28 @@ describe('WecomBot binding', () => {
257257
const body = client.sendMessage.mock.calls[0][1] as Extract<SendMsgBody, { msgtype: 'markdown' }>
258258
expect(body.markdown.content).toContain('Already bound to namespace **nsA**')
259259
})
260+
261+
it('rejects "<token>:<a>:<b>" because parseAccessToken splits on the LAST colon', async () => {
262+
// Old behaviour persisted namespace `foo:bar`, which no CLI/web client
263+
// could ever authenticate into (parseAccessToken would parse the same
264+
// string as baseToken=`TOKEN:foo`, namespace=`bar`).
265+
const { client, addUser } = makeBot([])
266+
client.emit('message.text', textFrame('u-new', 'TOKEN:foo:bar'))
267+
await tick()
268+
expect(addUser).not.toHaveBeenCalled()
269+
expect(client.sendMessage.mock.calls).toHaveLength(0)
270+
})
271+
272+
it('binds to the default namespace when only the bare token is sent', async () => {
273+
// parseAccessToken returns { baseToken, namespace: 'default' } when the
274+
// input has no colon — match that behaviour rather than silently dropping.
275+
const { client, addUser } = makeBot([])
276+
client.emit('message.text', textFrame('u-new', 'TOKEN'))
277+
await tick()
278+
expect(addUser).toHaveBeenCalledWith('wecom', 'u-new', 'default')
279+
const body = client.sendMessage.mock.calls[0][1] as Extract<SendMsgBody, { msgtype: 'markdown' }>
280+
expect(body.markdown.content).toContain('namespace **default**')
281+
})
260282
})
261283

262284
function clickFrame(eventKey: string, userid: string, reqId: string): WsFrame {

hub/src/wecom/bot.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
buildSessionCompletionCard,
1616
buildTaskCard
1717
} from './sessionView'
18+
import { parseAccessToken } from '../utils/accessToken'
19+
import { constantTimeEquals } from '../utils/crypto'
1820

1921
/** 30 s cooldown before we try to reconnect after a server-initiated kick. */
2022
const SERVER_KICK_RECONNECT_DELAY_MS = 30_000
@@ -168,19 +170,22 @@ export class WecomBot implements NotificationChannel {
168170

169171
private onTextMessage(frame: WsFrame): void {
170172
const body = frame.body as { text?: { content?: string }; from?: { userid?: string } } | undefined
171-
const content = body?.text?.content?.trim()
173+
const content = body?.text?.content
172174
const userid = body?.from?.userid
173175
if (!content || !userid) return
174176

175-
const prefix = `${this.cliApiToken}:`
176-
if (!content.startsWith(prefix)) return
177-
const namespace = content.slice(prefix.length).trim()
178-
if (!namespace) return
177+
// Use parseAccessToken (the same parser /api/auth and the CLI use) so we
178+
// can't persist a binding that no client could authenticate into. The
179+
// naive prefix-strip would accept `TOKEN:foo:bar` as namespace `foo:bar`
180+
// even though parseAccessToken splits on the LAST colon and would reject
181+
// that token (baseToken becomes `TOKEN:foo`, which is not our token).
182+
const parsed = parseAccessToken(content)
183+
if (!parsed || !constantTimeEquals(parsed.baseToken, this.cliApiToken)) return
184+
const namespace = parsed.namespace
179185

180-
// Accept any non-empty namespace, matching parseAccessToken() and the
181-
// /api/auth flow. Escape markdown metacharacters when interpolating
182-
// into the confirmation reply so a namespace with `*` / `_` / `` ` ``
183-
// can't break the rendered card.
186+
// Escape markdown metacharacters when interpolating into the
187+
// confirmation reply so a namespace with `*` / `_` / `` ` `` can't
188+
// break the rendered card.
184189
const safeNamespace = escapeMarkdown(namespace)
185190
const safeUserid = escapeMarkdown(userid)
186191

0 commit comments

Comments
 (0)