diff --git a/.changeset/grumpy-suns-remember.md b/.changeset/grumpy-suns-remember.md new file mode 100644 index 0000000000000..58cb976fbb104 --- /dev/null +++ b/.changeset/grumpy-suns-remember.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/http-router': patch +'@rocket.chat/meteor': patch +--- + +Fixes incoming webhook integrations not receiving parsed JSON from x-www-form-urlencoded payload field. diff --git a/apps/meteor/app/api/server/router.ts b/apps/meteor/app/api/server/router.ts index 0fba18a206093..7b65fe81e9868 100644 --- a/apps/meteor/app/api/server/router.ts +++ b/apps/meteor/app/api/server/router.ts @@ -38,10 +38,7 @@ export class RocketChatAPIRouter< return async (c: HonoContext): Promise> => { const { req, res } = c; const queryParams = this.parseQueryParams(req); - const bodyParams = await this.parseBodyParams<{ bodyParamsOverride: Record }>({ - request: req, - extra: { bodyParamsOverride: c.var['bodyParams-override'] || {} }, - }); + const bodyParams = c.get('bodyParams-override') ?? (await this.parseBodyParams({ request: req })); const request = req.raw.clone(); diff --git a/apps/meteor/app/integrations/server/api/api.ts b/apps/meteor/app/integrations/server/api/api.ts index 8e1a37c8e76fa..7e72862f70588 100644 --- a/apps/meteor/app/integrations/server/api/api.ts +++ b/apps/meteor/app/integrations/server/api/api.ts @@ -401,16 +401,29 @@ const middleware = async (c: Context, next: Next): Promise => { return next(); } + /** + * Slack/GitHub-style webhooks send JSON wrapped in a `payload` field with + * Content-Type: application/x-www-form-urlencoded (e.g. `payload={"text":"hello"}`). + * We unwrap it here so integrations receive the parsed JSON directly. + * + * Note: These webhooks only send the `payload` field with no additional form + * parameters, so we simply replace bodyParams with the parsed JSON. + */ if (body.payload) { - // need to compose the full payload in this weird way because body-parser thought it was a form - c.set('bodyParams-override', JSON.parse(body.payload)); + if (typeof body.payload === 'string') { + try { + c.set('bodyParams-override', JSON.parse(body.payload)); + } catch { + // Keep original without unwrapping + } + } return next(); } + incomingLogger.debug({ msg: 'Body received as application/x-www-form-urlencoded without the "payload" key, parsed as string', content, }); - c.set('bodyParams-override', JSON.parse(content)); } catch (e: any) { c.body(JSON.stringify({ success: false, error: e.message }), 400); } diff --git a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts index a8986b5bddcdc..5c4f4e074fa14 100644 --- a/apps/meteor/tests/end-to-end/api/incoming-integrations.ts +++ b/apps/meteor/tests/end-to-end/api/incoming-integrations.ts @@ -347,22 +347,20 @@ describe('[Incoming Integrations]', () => { .post(`/hooks/${integration._id}/${integration.token}`) .set('Content-Type', 'application/x-www-form-urlencoded') .send(`payload=${JSON.stringify(payload)}`) - .expect(200) - .expect(async () => { - return request - .get(api('channels.messages')) - .set(credentials) - .query({ - roomId: 'GENERAL', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('messages').and.to.be.an('array'); - expect(!!(res.body.messages as IMessage[]).find((m) => m.msg === payload.msg)).to.be.true; - }); - }); + .expect(200); + + const messagesResult = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(messagesResult.body).to.have.property('success', true); + expect(messagesResult.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(messagesResult.body.messages as IMessage[]).find((m) => m.msg === payload.msg)).to.be.true; }); }); @@ -444,52 +442,49 @@ describe('[Incoming Integrations]', () => { }); it('should send a message if the payload is a application/x-www-form-urlencoded JSON AND the integration has a valid script', async () => { - const payload = { msg: `Message as x-www-form-urlencoded JSON sent successfully at #${Date.now()}` }; + const payload = { text: `Message as x-www-form-urlencoded JSON sent successfully at #${Date.now()}` }; await request .post(`/hooks/${withScript._id}/${withScript.token}`) .set('Content-Type', 'application/x-www-form-urlencoded') .send(`payload=${JSON.stringify(payload)}`) - .expect(200) - .expect(async () => { - return request - .get(api('channels.messages')) - .set(credentials) - .query({ - roomId: 'GENERAL', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('messages').and.to.be.an('array'); - expect(!!(res.body.messages as IMessage[]).find((m) => m.msg === payload.msg)).to.be.true; - }); - }); + .expect(200); + + const messagesResult = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(messagesResult.body).to.have.property('success', true); + expect(messagesResult.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(messagesResult.body.messages as IMessage[]).find((m) => m.msg === payload.text)).to.be.true; }); - it('should send a message if the payload is a application/x-www-form-urlencoded JSON(when not set, default one) but theres no "payload" key, its just a string, the integration has a valid script', async () => { + it('should send a message if the payload is application/json and the integration has a valid script', async () => { const payload = { test: 'test' }; await request .post(`/hooks/${withScriptDefaultContentType._id}/${withScriptDefaultContentType.token}`) + .set('Content-Type', 'application/json') .send(JSON.stringify(payload)) - .expect(200) - .expect(async () => { - return request - .get(api('channels.messages')) - .set(credentials) - .query({ - roomId: 'GENERAL', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('messages').and.to.be.an('array'); - expect(!!(res.body.messages as IMessage[]).find((m) => m.msg === '[#VALUE](test)')).to.be.true; - }); - }); + .expect(200); + + const messagesResult = await request + .get(api('channels.messages')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(messagesResult.body).to.have.property('success', true); + expect(messagesResult.body).to.have.property('messages').and.to.be.an('array'); + expect(!!(messagesResult.body.messages as IMessage[]).find((m) => m.msg === '[#VALUE](test)')).to.be.true; }); });