diff --git a/.github/labeler.yml b/.github/labeler.yml index ba00e8f75e07..fbb6e920efc5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -414,6 +414,10 @@ copyright: - changed-files: - any-glob-to-any-file: COPYRIGHT +plugins/standard-webhooks: +- changed-files: + - any-glob-to-any-file: kong/plugins/standard-webhooks/**/* + schema-change-noteworthy: - changed-files: - any-glob-to-any-file: [ diff --git a/changelog/unreleased/kong/plugins-add-standard-webhooks.yml b/changelog/unreleased/kong/plugins-add-standard-webhooks.yml new file mode 100644 index 000000000000..b7907dfca096 --- /dev/null +++ b/changelog/unreleased/kong/plugins-add-standard-webhooks.yml @@ -0,0 +1,4 @@ +message: | + **standard-webhooks**: Added standard webhooks plugin. +type: feature +scope: Plugin diff --git a/kong-3.8.0-0.rockspec b/kong-3.8.0-0.rockspec index 79172312b620..353076ff4033 100644 --- a/kong-3.8.0-0.rockspec +++ b/kong-3.8.0-0.rockspec @@ -1037,6 +1037,10 @@ build = { ["kong.plugins.xml-threat-protection.handler"] = "kong/plugins/xml-threat-protection/handler.lua", ["kong.plugins.xml-threat-protection.schema"] = "kong/plugins/xml-threat-protection/schema.lua", + ["kong.plugins.standard-webhooks.handler"] = "kong/plugins/standard-webhooks/handler.lua", + ["kong.plugins.standard-webhooks.internal"] = "kong/plugins/standard-webhooks/internal.lua", + ["kong.plugins.standard-webhooks.schema"] = "kong/plugins/standard-webhooks/schema.lua", + ["kong.vaults.env"] = "kong/vaults/env/init.lua", ["kong.vaults.env.schema"] = "kong/vaults/env/schema.lua", ["kong.vaults.aws"] = "kong/vaults/aws/init.lua", diff --git a/kong/constants.lua b/kong/constants.lua index a5a4774d529b..3e8860a50c4b 100644 --- a/kong/constants.lua +++ b/kong/constants.lua @@ -53,6 +53,7 @@ local plugins = { "ai-prompt-guard", "ai-request-transformer", "ai-response-transformer", + "standard-webhooks", } local ce_plugin_map = {} diff --git a/kong/plugins/standard-webhooks/handler.lua b/kong/plugins/standard-webhooks/handler.lua new file mode 100644 index 000000000000..b7db05322a15 --- /dev/null +++ b/kong/plugins/standard-webhooks/handler.lua @@ -0,0 +1,19 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local plugin = require "kong.plugins.standard-webhooks.internal" + +local StandardWebhooks = { + VERSION = require("kong.meta").core_version, + PRIORITY = 760 +} + +function StandardWebhooks:access(conf) + plugin.access(conf) +end + +return StandardWebhooks diff --git a/kong/plugins/standard-webhooks/internal.lua b/kong/plugins/standard-webhooks/internal.lua new file mode 100644 index 000000000000..a8160ea40cad --- /dev/null +++ b/kong/plugins/standard-webhooks/internal.lua @@ -0,0 +1,83 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local kong = kong +local mac = require "resty.openssl.mac" +local tonumber = tonumber +local ngx = ngx +local type = type + +local HEADER_WEBHOOK_ID = "webhook-id" +local HEADER_WEBHOOK_SIGN = "webhook-signature" +local HEADER_WEBHOOK_TS = "webhook-timestamp" + +local function getHeader(input) + if type(input) == "table" then + return input[1] + end + + return input +end + +local function sign(secret, id, ts, payload) + local d, err = mac.new(secret, "HMAC", nil, "sha256") + if err then + kong.log.error(err) + return kong.response.error(500) + end + local r, err = d:final(id .. "." .. ts .. "." .. payload) + if err then + kong.log.error(err) + return kong.response.error(500) + end + return "v1," .. ngx.encode_base64(r) +end + +local function extract_webhook() + local headers = kong.request.get_headers() + + local id = getHeader(headers[HEADER_WEBHOOK_ID]) + local signature = getHeader(headers[HEADER_WEBHOOK_SIGN]) + local ts = getHeader(headers[HEADER_WEBHOOK_TS]) + if not id or not signature or not ts then + kong.log.debug("missing required headers") + return kong.response.error(400) + end + + ts = tonumber(ts) or 0 -- if parse fails we inject 0, which will fail on clock-skew check + + return id, signature, ts +end + + +local function access(config) + local id, signature, ts = extract_webhook() + + if ngx.now() - ts > config.tolerance_second then + kong.log.debug("timestamp tolerance exceeded") + return kong.response.error(400) + end + + local body = kong.request.get_raw_body() + + if not body or body == "" then + kong.log.debug("missing required body") + return kong.response.error(400) + end + + local expected_signature = sign(config.secret_v1, id, ts, body) + + if signature ~= expected_signature then + kong.log.debug("signature not matched") + return kong.response.error(400) + end +end + +return { + access = access, + sign = sign +} diff --git a/kong/plugins/standard-webhooks/schema.lua b/kong/plugins/standard-webhooks/schema.lua new file mode 100644 index 000000000000..9a942c4a5bef --- /dev/null +++ b/kong/plugins/standard-webhooks/schema.lua @@ -0,0 +1,45 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local typedefs = require "kong.db.schema.typedefs" + +local PLUGIN_NAME = "standard-webhooks" + +local schema = { + name = PLUGIN_NAME, + fields = { + { consumer = typedefs.no_consumer }, + { protocols = typedefs.protocols_http }, + { + config = { + type = "record", + fields = { + { + secret_v1 = { + type = "string", + required = true, + description = "Webhook secret", + referenceable = true, + encrypted = true, + }, + }, + { + tolerance_second = { + description = "Tolerance of the webhook timestamp in seconds. If the webhook timestamp is older than this number of seconds, it will be rejected with a '400' response.", + type = "integer", + required = true, + gt = -1, + default = 5 * 60 + } + } + } + } + } + } +} + +return schema diff --git a/spec-ee/03-plugins/01-plugins_order_spec.lua b/spec-ee/03-plugins/01-plugins_order_spec.lua index 5f451398117f..c5af81d65848 100644 --- a/spec-ee/03-plugins/01-plugins_order_spec.lua +++ b/spec-ee/03-plugins/01-plugins_order_spec.lua @@ -176,6 +176,7 @@ describe("Plugins", function() 'ai-prompt-guard', 'ai-proxy', 'ai-response-transformer', + 'standard-webhooks', 'kafka-upstream', 'aws-lambda', 'azure-functions', diff --git a/spec/01-unit/12-plugins_order_spec.lua b/spec/01-unit/12-plugins_order_spec.lua index e43d4d8191b2..74f1b3dc5e2b 100644 --- a/spec/01-unit/12-plugins_order_spec.lua +++ b/spec/01-unit/12-plugins_order_spec.lua @@ -109,6 +109,7 @@ describe("Plugins", function() "ai-prompt-guard", "ai-proxy", "ai-response-transformer", + "standard-webhooks", "aws-lambda", "azure-functions", "upstream-timeout", diff --git a/spec/03-plugins/44-standard-webhooks/01-unit_spec.lua b/spec/03-plugins/44-standard-webhooks/01-unit_spec.lua new file mode 100644 index 000000000000..5bbcf20b3cc2 --- /dev/null +++ b/spec/03-plugins/44-standard-webhooks/01-unit_spec.lua @@ -0,0 +1,147 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local PLUGIN_NAME = "standard-webhooks" + + +-- helper function to validate data against a schema +local validate do + local validate_entity = require("spec.helpers").validate_plugin_config_schema + local plugin_schema = require("kong.plugins."..PLUGIN_NAME..".schema") + + function validate(data) + return validate_entity(data, plugin_schema) + end +end + + +describe(PLUGIN_NAME .. ": (schema)", function() + + + it("accepts a valid config", function() + local ok, err = validate({ + secret_v1 = "abc123", + tolerance_second = 5*60, + }) + assert.is_nil(err) + assert.is_truthy(ok) + end) + + + describe("secret", function() + + it("must be set", function() + local ok, err = validate({ + secret_v1 = nil, + tolerance_second = 5*60, + }) + + assert.is_same({ + ["config"] = { + ["secret_v1"] = 'required field missing', + } + }, err) + assert.is_falsy(ok) + end) + + + it("is not nullable", function() + local ok, err = validate({ + secret_v1 = assert(ngx.null), + tolerance_second = 5*60, + }) + + assert.is_same({ + ["config"] = { + ["secret_v1"] = 'required field missing', + } + }, err) + assert.is_falsy(ok) + end) + + + it("must be a string", function() + local ok, err = validate({ + secret_v1 = 123, + tolerance_second = 5*60, + }) + + assert.is_same({ + ["config"] = { + ["secret_v1"] = 'expected a string', + } + }, err) + assert.is_falsy(ok) + end) + + end) + + + + describe("tolerance_second", function() + + it("gets a default", function() + local ok, err = validate({ + secret_v1 = "abc123", + tolerance_second = nil, + }) + + assert.is_nil(err) + assert.are.same(ok.config, { + secret_v1 = "abc123", + tolerance_second = 5*60, + }) + end) + + + it("is not nullable", function() + local ok, err = validate({ + secret_v1 = "abc123", + tolerance_second = assert(ngx.null), + }) + + assert.is_same({ + ["config"] = { + ["tolerance_second"] = 'required field missing', + } + }, err) + assert.is_falsy(ok) + end) + + + it("must be an integer", function() + local ok, err = validate({ + secret_v1 = "abc123", + tolerance_second = 5.67, + }) + + assert.is_same({ + ["config"] = { + ["tolerance_second"] = 'expected an integer', + } + }, err) + assert.is_falsy(ok) + end) + + + it("must be >= 0", function() + local ok, err = validate({ + secret_v1 = "abc123", + tolerance_second = -1, + }) + + assert.is_same({ + ["config"] = { + ["tolerance_second"] = 'value must be greater than -1', + } + }, err) + assert.is_falsy(ok) + end) + + end) + +end) diff --git a/spec/03-plugins/44-standard-webhooks/02-integration_spec.lua b/spec/03-plugins/44-standard-webhooks/02-integration_spec.lua new file mode 100644 index 000000000000..0a6f8cb9d72c --- /dev/null +++ b/spec/03-plugins/44-standard-webhooks/02-integration_spec.lua @@ -0,0 +1,140 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local PLUGIN_NAME = "standard-webhooks" +local helpers = require "spec.helpers" +local swh = require "kong.plugins.standard-webhooks.internal" + +local SECRET = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" +local MESSAGE_ID = "msg_p5jXN8AQM9LWM0D4loKWxJek" + +for _, strategy in helpers.all_strategies() do + local client + + describe(PLUGIN_NAME .. ": (Access)", function() + lazy_setup(function() + local bp = helpers.get_db_utils(strategy, {"routes", "services", "plugins"}, {PLUGIN_NAME}) + + local r = bp.routes:insert({ + paths = {"/"} + }) + + bp.plugins:insert{ + route = r, + name = PLUGIN_NAME, + config = { + secret_v1 = SECRET + } + } + + -- start kong + assert(helpers.start_kong({ + -- set the strategy + database = strategy, + -- use the custom test template to create a local mock server + nginx_conf = "spec/fixtures/custom_nginx.template", + -- write & load declarative config, only if 'strategy=off' + declarative_config = strategy == "off" and helpers.make_yaml_file() or nil + })) + end) + lazy_teardown(function() + helpers.stop_kong() + end) + + before_each(function() + client = helpers.proxy_client() + end) + + after_each(function() + if client then + client:close() + end + end) + + it("rejects missing headers", function() + local res = client:post("/", { + headers = { + ["Content-Type"] = "application/json", + ["webhook-id"] = MESSAGE_ID, + ["webhook-timestamp"] = math.floor(ngx.now()) + }, + body = { + foo = "bar" + } + }) + + assert.response(res).has.status(400) + end) + + it("rejects invalid timestamp", function() + local res = client:post("/", { + headers = { + ["Content-Type"] = "application/json", + ["webhook-id"] = MESSAGE_ID, + ["webhook-signature"] = "asdf", + ["webhook-timestamp"] = "XYZ" + }, + body = { + foo = "bar" + } + }) + + assert.response(res).has.status(400) + end) + + it("rejects missing body", function() + local res = client:post("/", { + headers = { + ["Content-Type"] = "application/json", + ["webhook-id"] = MESSAGE_ID, + ["webhook-signature"] = "asdf", + ["webhook-timestamp"] = math.floor(ngx.now()) + } + }) + + assert.response(res).has.status(400) + end) + + it("accepts correct signature", function() + local ts = math.floor(ngx.now()) + local signature = swh.sign(SECRET, MESSAGE_ID, ts, '{"foo":"bar"}') + + local res = client:post("/", { + headers = { + ["Content-Type"] = "application/json", + ["webhook-id"] = MESSAGE_ID, + ["webhook-signature"] = signature, + ["webhook-timestamp"] = ts + }, + body = { + foo = "bar" + } + }) + + assert.response(res).has.status(200) + end) + + it("fails because the timestamp tolerance is exceeded", function() + local ts = math.floor(ngx.now()) - 6 * 60 + local signature = swh.sign(SECRET, MESSAGE_ID, ts, '{"foo":"bar"}') + + local res = client:post("/", { + headers = { + ["Content-Type"] = "application/json", + ["webhook-id"] = MESSAGE_ID, + ["webhook-signature"] = signature, + ["webhook-timestamp"] = ts + }, + body = { + foo = "bar" + } + }) + + assert.response(res).has.status(400) + end) + end) +end