From 7cf94f21a2c67040f0cc01d30b83fef80e57e01f Mon Sep 17 00:00:00 2001 From: Angel Caamal Date: Mon, 11 May 2026 21:35:20 +0000 Subject: [PATCH 1/4] fix(slack): replace deprecated @slack/events-api with native crypto validation --- functions/slack/index.js | 39 +++++++++++++----- functions/slack/package.json | 3 +- functions/slack/test/integration.test.js | 4 +- functions/slack/test/unit.test.js | 51 +++++++++++++++--------- 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/functions/slack/index.js b/functions/slack/index.js index e004b6efd5..83519d2641 100644 --- a/functions/slack/index.js +++ b/functions/slack/index.js @@ -17,7 +17,7 @@ // [START functions_slack_setup] const functions = require('@google-cloud/functions-framework'); const google = require('@googleapis/kgsearch'); -const {verifyRequestSignature} = require('@slack/events-api'); +const crypto = require('crypto'); // Get a reference to the Knowledge Graph Search component const kgsearch = google.kgsearch('v1'); @@ -93,15 +93,36 @@ const formatSlackMessage = (query, response) => { * @param {string} req.rawBody Raw body of webhook request to check signature against. */ const verifyWebhook = req => { - const signature = { - signingSecret: process.env.SLACK_SECRET, - requestSignature: req.headers['x-slack-signature'], - requestTimestamp: req.headers['x-slack-request-timestamp'], - body: req.rawBody, - }; + const signingSecret = process.env.SLACK_SECRET; + const requestSignature = req.headers['x-slack-signature']; + const requestTimestamp = req.headers['x-slack-request-timestamp']; + const requestBody = req.rawBody; + + if (!requestSignature || !requestTimestamp) { + const err = new Error('Missing Slack validation headers.'); + err.code = 400; + throw err; + } + + const baseString = `v0:${requestTimestamp}:${requestBody}`; + const expectedSignature = + 'v0=' + + crypto + .createHmac('sha256', signingSecret) + .update(baseString, 'utf8') + .digest('hex'); + + const sigBuffer = Buffer.from(requestSignature, 'utf8'); + const expBuffer = Buffer.from(expectedSignature, 'utf8'); - // This method throws an exception if an incoming request is invalid. - verifyRequestSignature(signature); + if ( + sigBuffer.length !== expBuffer.length || + !crypto.timingSafeEqual(sigBuffer, expBuffer) + ) { + const err = new Error('Invalid Slack signature.'); + err.code = 401; + throw err; + } }; // [END functions_verify_webhook] diff --git a/functions/slack/package.json b/functions/slack/package.json index de00569994..e3b595a0e7 100644 --- a/functions/slack/package.json +++ b/functions/slack/package.json @@ -16,8 +16,7 @@ }, "dependencies": { "@google-cloud/functions-framework": "^3.1.0", - "@googleapis/kgsearch": "^1.0.0", - "@slack/events-api": "^3.0.0" + "@googleapis/kgsearch": "^1.0.0" }, "devDependencies": { "c8": "^10.0.0", diff --git a/functions/slack/test/integration.test.js b/functions/slack/test/integration.test.js index ecdaa71e09..5faf5b74d6 100644 --- a/functions/slack/test/integration.test.js +++ b/functions/slack/test/integration.test.js @@ -20,7 +20,7 @@ const supertest = require('supertest'); const functionsFramework = require('@google-cloud/functions-framework/testing'); const {SLACK_SECRET} = process.env; -const SLACK_TIMESTAMP = Date.now(); +const SLACK_TIMESTAMP = Math.floor(Date.now() / 1000).toString(); require('../index'); @@ -101,6 +101,6 @@ describe('functions_slack_format functions_slack_request functions_slack_search const query = 'kolach'; const server = functionsFramework.getTestServer('kgSearch'); - await supertest(server).post('/').send({text: query}).expect(500); + await supertest(server).post('/').send({text: query}).expect(400); }); }); diff --git a/functions/slack/test/unit.test.js b/functions/slack/test/unit.test.js index e6382c123e..fd95418a74 100644 --- a/functions/slack/test/unit.test.js +++ b/functions/slack/test/unit.test.js @@ -17,17 +17,40 @@ const sinon = require('sinon'); const proxyquire = require('proxyquire').noCallThru(); const assert = require('assert'); +const crypto = require('crypto'); const {getFunction} = require('@google-cloud/functions-framework/testing'); const method = 'POST'; const query = 'giraffe'; -const SLACK_TOKEN = 'slack-token'; +const SLACK_SECRET = process.env.SLACK_SECRET || 'slack-token'; const KG_API_KEY = 'kg-api-key'; +const signMockRequest = (req, bodyText, isValid = true) => { + req.body = {text: bodyText}; + req.rawBody = JSON.stringify(req.body); + const timestamp = Math.floor(Date.now() / 1000).toString(); + + let signature; + if (!isValid) { + signature = 'v0=invalid_signature_hash_for_testing'; + } else { + const baseString = `v0:${timestamp}:${req.rawBody}`; + signature = + 'v0=' + + crypto + .createHmac('sha256', SLACK_SECRET) + .update(baseString, 'utf8') + .digest('hex'); + } + + req.headers['x-slack-request-timestamp'] = timestamp; + req.headers['x-slack-signature'] = signature; +}; + const getSample = () => { const config = { - SLACK_TOKEN: SLACK_TOKEN, + SLACK_SECRET: SLACK_SECRET, KG_API_KEY: KG_API_KEY, }; const kgsearch = { @@ -38,21 +61,16 @@ const getSample = () => { const googleapis = { kgsearch: sinon.stub().returns(kgsearch), }; - const eventsApi = { - verifyRequestSignature: sinon.stub().returns(true), - }; return { program: proxyquire('../', { '@googleapis/kgsearch': googleapis, process: {env: config}, - '@slack/events-api': eventsApi, }), mocks: { googleapis: googleapis, kgsearch: kgsearch, config: config, - eventsApi: eventsApi, }, }; }; @@ -129,14 +147,13 @@ describe('functions_slack_search', () => { describe('functions_slack_search functions_verify_webhook', () => { it('Throws if invalid slack token', async () => { - const error = new Error('Invalid credentials'); + const error = new Error('Invalid Slack signature.'); error.code = 401; const mocks = getMocks(); - const sample = getSample(); + getSample(); mocks.req.method = method; - mocks.req.body.text = 'not empty'; - sample.mocks.eventsApi.verifyRequestSignature = sinon.stub().returns(false); + signMockRequest(mocks.req, 'not empty', false); const kgSearch = getFunction('kgSearch'); @@ -161,8 +178,7 @@ describe('functions_slack_request functions_slack_search functions_verify_webhoo const sample = getSample(); mocks.req.method = method; - mocks.req.body.token = SLACK_TOKEN; - mocks.req.body.text = query; + signMockRequest(mocks.req, query, true); sample.mocks.kgsearch.entities.search.yields(error); const kgSearch = getFunction('kgSearch'); @@ -187,8 +203,7 @@ describe('functions_slack_format functions_slack_request functions_slack_search const sample = getSample(); mocks.req.method = method; - mocks.req.body.token = SLACK_TOKEN; - mocks.req.body.text = query; + signMockRequest(mocks.req, query, true); sample.mocks.kgsearch.entities.search.yields(null, { data: {itemListElement: []}, }); @@ -215,8 +230,7 @@ describe('functions_slack_format functions_slack_request functions_slack_search const sample = getSample(); mocks.req.method = method; - mocks.req.body.token = SLACK_TOKEN; - mocks.req.body.text = query; + signMockRequest(mocks.req, query, true); sample.mocks.kgsearch.entities.search.yields(null, { data: { itemListElement: [ @@ -263,8 +277,7 @@ describe('functions_slack_format functions_slack_request functions_slack_search const sample = getSample(); mocks.req.method = method; - mocks.req.body.token = SLACK_TOKEN; - mocks.req.body.text = query; + signMockRequest(mocks.req, query, true); sample.mocks.kgsearch.entities.search.yields(null, { data: { itemListElement: [ From 1e808a662449219966a63e45ffd9cf1164a46bae Mon Sep 17 00:00:00 2001 From: Angel Caamal Date: Mon, 11 May 2026 21:59:36 +0000 Subject: [PATCH 2/4] fix(slack): inject fallback environment variables for unit test CI compatibility --- functions/slack/index.js | 8 ++++++++ functions/slack/test/integration.test.js | 4 ++-- functions/slack/test/unit.test.js | 7 +++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/functions/slack/index.js b/functions/slack/index.js index 83519d2641..44a471137d 100644 --- a/functions/slack/index.js +++ b/functions/slack/index.js @@ -104,6 +104,14 @@ const verifyWebhook = req => { throw err; } + if (!signingSecret) { + const err = new Error( + 'Server configuration error: SLACK_SECRET is missing.' + ); + err.code = 500; + throw err; + } + const baseString = `v0:${requestTimestamp}:${requestBody}`; const expectedSignature = 'v0=' + diff --git a/functions/slack/test/integration.test.js b/functions/slack/test/integration.test.js index 5faf5b74d6..bbe3415c84 100644 --- a/functions/slack/test/integration.test.js +++ b/functions/slack/test/integration.test.js @@ -18,8 +18,8 @@ const assert = require('assert'); const crypto = require('crypto'); const supertest = require('supertest'); const functionsFramework = require('@google-cloud/functions-framework/testing'); - -const {SLACK_SECRET} = process.env; +process.env.SLACK_SECRET = process.env.SLACK_SECRET || 'test-slack-secret'; +const SLACK_SECRET = process.env.SLACK_SECRET; const SLACK_TIMESTAMP = Math.floor(Date.now() / 1000).toString(); require('../index'); diff --git a/functions/slack/test/unit.test.js b/functions/slack/test/unit.test.js index fd95418a74..90f7a6d402 100644 --- a/functions/slack/test/unit.test.js +++ b/functions/slack/test/unit.test.js @@ -23,8 +23,11 @@ const {getFunction} = require('@google-cloud/functions-framework/testing'); const method = 'POST'; const query = 'giraffe'; -const SLACK_SECRET = process.env.SLACK_SECRET || 'slack-token'; -const KG_API_KEY = 'kg-api-key'; +process.env.SLACK_SECRET = process.env.SLACK_SECRET || 'slack-token'; +process.env.KG_API_KEY = process.env.KG_API_KEY || 'test-kg-api-key'; + +const SLACK_SECRET = process.env.SLACK_SECRET; +const KG_API_KEY = process.env.KG_API_KEY; const signMockRequest = (req, bodyText, isValid = true) => { req.body = {text: bodyText}; From e7f05d4e27e17025cc07415ee301f858c0dd888d Mon Sep 17 00:00:00 2001 From: Angel Caamal Date: Tue, 12 May 2026 18:44:08 +0000 Subject: [PATCH 3/4] test(slack): apply code review feedback and add mock data for CI --- functions/slack/index.js | 19 ++++++++----- functions/slack/package.json | 1 + functions/slack/test/integration.test.js | 34 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/functions/slack/index.js b/functions/slack/index.js index 44a471137d..673d68a547 100644 --- a/functions/slack/index.js +++ b/functions/slack/index.js @@ -112,13 +112,18 @@ const verifyWebhook = req => { throw err; } - const baseString = `v0:${requestTimestamp}:${requestBody}`; - const expectedSignature = - 'v0=' + - crypto - .createHmac('sha256', signingSecret) - .update(baseString, 'utf8') - .digest('hex'); + // Prevent replay attacks by verifying the timestamp is recent + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - Number(requestTimestamp)) > 60 * 5) { + const err = new Error('Slack request timestamp is too old.'); + err.code = 401; + throw err; + } + + const hmac = crypto.createHmac('sha256', signingSecret); + hmac.update('v0:' + requestTimestamp + ':', 'utf8'); + hmac.update(requestBody || ''); + const expectedSignature = 'v0=' + hmac.digest('hex'); const sigBuffer = Buffer.from(requestSignature, 'utf8'); const expBuffer = Buffer.from(expectedSignature, 'utf8'); diff --git a/functions/slack/package.json b/functions/slack/package.json index e3b595a0e7..ff6fb55fcb 100644 --- a/functions/slack/package.json +++ b/functions/slack/package.json @@ -21,6 +21,7 @@ "devDependencies": { "c8": "^10.0.0", "mocha": "^10.0.0", + "nock": "^14.0.15", "proxyquire": "^2.1.0", "sinon": "^18.0.0", "supertest": "^7.0.0" diff --git a/functions/slack/test/integration.test.js b/functions/slack/test/integration.test.js index bbe3415c84..4e289c9c15 100644 --- a/functions/slack/test/integration.test.js +++ b/functions/slack/test/integration.test.js @@ -18,6 +18,8 @@ const assert = require('assert'); const crypto = require('crypto'); const supertest = require('supertest'); const functionsFramework = require('@google-cloud/functions-framework/testing'); +const nock = require('nock'); + process.env.SLACK_SECRET = process.env.SLACK_SECRET || 'test-slack-secret'; const SLACK_SECRET = process.env.SLACK_SECRET; const SLACK_TIMESTAMP = Math.floor(Date.now() / 1000).toString(); @@ -38,8 +40,33 @@ const generateSignature = query => { }; describe('functions_slack_format functions_slack_request functions_slack_search functions_verify_webhook', () => { + afterEach(() => { + nock.cleanAll(); + }); + it('returns search results', async () => { const query = 'kolach'; + + // Mock: Intercept the Google API request and return the expected data + nock('https://kgsearch.googleapis.com') + .get('/v1/entities:search') + .query(true) + .reply(200, { + itemListElement: [ + { + result: { + name: 'Kolach', + description: 'Pastry', + detailedDescription: { + articleBody: + 'A kolach is a pastry that holds a portion of fruit surrounded by puffy dough.', + url: 'http://domain.com/kolach', + }, + }, + }, + ], + }); + const server = functionsFramework.getTestServer('kgSearch'); const response = await supertest(server) .post('/') @@ -64,6 +91,13 @@ describe('functions_slack_format functions_slack_request functions_slack_search it('handles non-existent query', async () => { const query = 'g1bb3r1shhhhhhh'; + nock('https://kgsearch.googleapis.com') + .get('/v1/entities:search') + .query(true) + .reply(200, { + itemListElement: [], + }); + const server = functionsFramework.getTestServer('kgSearch'); const response = await supertest(server) .post('/') From 8cb2f1b692e51be87f58f20865fa3e067502e821 Mon Sep 17 00:00:00 2001 From: Angel Caamal Date: Tue, 12 May 2026 18:51:27 +0000 Subject: [PATCH 4/4] chore(slack): downgrade nock to v13 to support legacy Node.js versions in CI matrix --- functions/slack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/slack/package.json b/functions/slack/package.json index ff6fb55fcb..d38714e8d9 100644 --- a/functions/slack/package.json +++ b/functions/slack/package.json @@ -21,7 +21,7 @@ "devDependencies": { "c8": "^10.0.0", "mocha": "^10.0.0", - "nock": "^14.0.15", + "nock": "^13.5.6", "proxyquire": "^2.1.0", "sinon": "^18.0.0", "supertest": "^7.0.0"