diff --git a/.github/labeler.yml b/.github/labeler.yml index dd6c00aa56c6..4d80a6fc92f2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -254,6 +254,10 @@ plugins/opentelemetry: - changed-files: - any-glob-to-any-file: kong/plugins/opentelemetry/**/* +plugins/standard-webhooks: +- changed-files: + - any-glob-to-any-file: kong/plugins/standard-webhooks/**/* + schema-change-noteworthy: - changed-files: - any-glob-to-any-file: ['kong/db/schema/**/*.lua', 'kong/**/schema.lua', 'kong/plugins/**/daos.lua', 'plugins-ee/**/daos.lua', 'plugins-ee/**/schema.lua', 'kong/db/dao/*.lua', 'kong/enterprise_edition/redis/init.lua'] 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..a9448465fa2a --- /dev/null +++ b/changelog/unreleased/kong/plugins-add-standard-webhooks.yml @@ -0,0 +1,4 @@ +message: | + Add standard webhooks plugin +type: feature +scope: Plugin diff --git a/kong-3.7.0-0.rockspec b/kong-3.7.0-0.rockspec index 84f72ad4c951..89e4a0df0d3f 100644 --- a/kong-3.7.0-0.rockspec +++ b/kong-3.7.0-0.rockspec @@ -609,6 +609,10 @@ build = { ["kong.plugins.ai-prompt-guard.handler"] = "kong/plugins/ai-prompt-guard/handler.lua", ["kong.plugins.ai-prompt-guard.schema"] = "kong/plugins/ai-prompt-guard/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", diff --git a/kong/constants.lua b/kong/constants.lua index 0050ab1fee4f..63df1f2f5a47 100644 --- a/kong/constants.lua +++ b/kong/constants.lua @@ -42,6 +42,7 @@ local plugins = { "ai-prompt-guard", "ai-request-transformer", "ai-response-transformer", + "standard-webhooks", } local plugin_map = {} diff --git a/kong/plugins/standard-webhooks/handler.lua b/kong/plugins/standard-webhooks/handler.lua new file mode 100644 index 000000000000..a013a6cee023 --- /dev/null +++ b/kong/plugins/standard-webhooks/handler.lua @@ -0,0 +1,12 @@ +local plugin = require "kong.plugins.standard-webhooks.internal" + +local StandardWebhooks = { + VERSION = require("kong.meta").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..488941f28d9d --- /dev/null +++ b/kong/plugins/standard-webhooks/internal.lua @@ -0,0 +1,78 @@ +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] + else + return input + end +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 + return + end + + kong.log.debug("signature not matched") + return kong.response.error(400) +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..165926f07aef --- /dev/null +++ b/kong/plugins/standard-webhooks/schema.lua @@ -0,0 +1,38 @@ +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/01-unit/12-plugins_order_spec.lua b/spec/01-unit/12-plugins_order_spec.lua index d897784255ec..a051aeb98040 100644 --- a/spec/01-unit/12-plugins_order_spec.lua +++ b/spec/01-unit/12-plugins_order_spec.lua @@ -78,6 +78,7 @@ describe("Plugins", function() "ai-prompt-guard", "ai-proxy", "ai-response-transformer", + "standard-webhooks", "aws-lambda", "azure-functions", "proxy-cache", 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..afff58e052cd --- /dev/null +++ b/spec/03-plugins/44-standard-webhooks/01-unit_spec.lua @@ -0,0 +1,140 @@ +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..490f0b2e5c66 --- /dev/null +++ b/spec/03-plugins/44-standard-webhooks/02-integration_spec.lua @@ -0,0 +1,133 @@ +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