diff --git a/.github/labeler.yml b/.github/labeler.yml index 7efee128b3cb..c7f1b466f5c4 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -258,6 +258,10 @@ plugins/standard-webhooks: - changed-files: - any-glob-to-any-file: kong/plugins/standard-webhooks/**/* +plugins/redirect: +- changed-files: + - any-glob-to-any-file: kong/plugins/redirect/**/* + schema-change-noteworthy: - changed-files: - any-glob-to-any-file: [ diff --git a/changelog/unreleased/kong/plugins-redirect.yml b/changelog/unreleased/kong/plugins-redirect.yml new file mode 100644 index 000000000000..1969db155b4d --- /dev/null +++ b/changelog/unreleased/kong/plugins-redirect.yml @@ -0,0 +1,4 @@ +message: | + "**redirect**: Add a new plugin to redirect requests to another location +type: "feature" +scope: "Plugin" diff --git a/kong-3.9.0-0.rockspec b/kong-3.9.0-0.rockspec index a3c618357312..0cbe859d1e2c 100644 --- a/kong-3.9.0-0.rockspec +++ b/kong-3.9.0-0.rockspec @@ -664,6 +664,9 @@ build = { ["kong.plugins.standard-webhooks.internal"] = "kong/plugins/standard-webhooks/internal.lua", ["kong.plugins.standard-webhooks.schema"] = "kong/plugins/standard-webhooks/schema.lua", + ["kong.plugins.redirect.handler"] = "kong/plugins/redirect/handler.lua", + ["kong.plugins.redirect.schema"] = "kong/plugins/redirect/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 7a05f24cf530..d38cb3f9e3d5 100644 --- a/kong/constants.lua +++ b/kong/constants.lua @@ -43,6 +43,7 @@ local plugins = { "ai-request-transformer", "ai-response-transformer", "standard-webhooks", + "redirect" } local plugin_map = {} diff --git a/kong/plugins/redirect/handler.lua b/kong/plugins/redirect/handler.lua new file mode 100644 index 000000000000..b6874e92ddff --- /dev/null +++ b/kong/plugins/redirect/handler.lua @@ -0,0 +1,32 @@ +local kong = kong +local kong_meta = require "kong.meta" +local ada = require "resty.ada" + +local RedirectHandler = {} + +-- Priority 779 so that it runs after all rate limiting/validation plugins +-- and all transformation plugins, but before any AI plugins which call upstream +RedirectHandler.PRIORITY = 779 +RedirectHandler.VERSION = kong_meta.version + +function RedirectHandler:access(conf) + -- Use the 'location' as-is as the default + -- This is equivalent to conf.incoming_path == 'ignore' + local location = conf.location + + if conf.keep_incoming_path then + -- Parse the URL in 'conf.location' and the incoming request + local location_url = ada.parse(location) + + -- Overwrite the path in 'location' with the path from the incoming request + location = location_url:set_pathname(kong.request.get_path()):set_search(kong.request.get_raw_query()):get_href() + end + + local headers = { + ["Location"] = location + } + + return kong.response.exit(conf.status_code, "redirecting", headers) +end + +return RedirectHandler diff --git a/kong/plugins/redirect/schema.lua b/kong/plugins/redirect/schema.lua new file mode 100644 index 000000000000..94f4f8469c1c --- /dev/null +++ b/kong/plugins/redirect/schema.lua @@ -0,0 +1,39 @@ +local typedefs = require "kong.db.schema.typedefs" + +return { + name = "redirect", + fields = { + { + protocols = typedefs.protocols_http + }, + { + config = { + type = "record", + fields = { + { + status_code = { + description = "The response code to send. Must be an integer between 100 and 599.", + type = "integer", + required = true, + default = 301, + between = { 100, 599 } + } + }, + { + location = typedefs.url { + description = "The URL to redirect to", + required = true + } + }, + { + keep_incoming_path = { + description = "Use the incoming request's path and query string in the redirect URL", + type = "boolean", + default = false + } + } + } + } + } + } +} diff --git a/spec/01-unit/12-plugins_order_spec.lua b/spec/01-unit/12-plugins_order_spec.lua index 986b75122690..595a17968587 100644 --- a/spec/01-unit/12-plugins_order_spec.lua +++ b/spec/01-unit/12-plugins_order_spec.lua @@ -73,6 +73,7 @@ describe("Plugins", function() "response-ratelimiting", "request-transformer", "response-transformer", + "redirect", "ai-request-transformer", "ai-prompt-template", "ai-prompt-decorator", diff --git a/spec/03-plugins/45-redirect/01-schema_spec.lua b/spec/03-plugins/45-redirect/01-schema_spec.lua new file mode 100644 index 000000000000..a8e433826e0c --- /dev/null +++ b/spec/03-plugins/45-redirect/01-schema_spec.lua @@ -0,0 +1,99 @@ +local PLUGIN_NAME = "redirect" +local null = ngx.null + +-- 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: redirect (schema)", function() + it("should accept a valid status_code", function() + local ok, err = validate({ + status_code = 404, + location = "https://example.com" + }) + assert.is_nil(err) + assert.is_truthy(ok) + end) + + it("should accept a valid location", function() + local ok, err = validate({ + location = "https://example.com" + }) + assert.is_nil(err) + assert.is_truthy(ok) + end) + + + + describe("errors", function() + it("status_code should only accept integers", function() + local ok, err = validate({ + status_code = "abcd", + location = "https://example.com" + }) + assert.falsy(ok) + assert.same("expected an integer", err.config.status_code) + end) + + it("status_code is not nullable", function() + local ok, err = validate({ + status_code = null, + location = "https://example.com" + }) + assert.falsy(ok) + assert.same("required field missing", err.config.status_code) + end) + + it("status_code < 100", function() + local ok, err = validate({ + status_code = 99, + location = "https://example.com" + }) + assert.falsy(ok) + assert.same("value should be between 100 and 599", err.config.status_code) + end) + + it("status_code > 599", function() + local ok, err = validate({ + status_code = 600, + location = "https://example.com" + }) + assert.falsy(ok) + assert.same("value should be between 100 and 599", err.config.status_code) + end) + + it("location is required", function() + local ok, err = validate({ + status_code = 301 + }) + assert.falsy(ok) + assert.same("required field missing", err.config.location) + end) + + it("location must be a url", function() + local ok, err = validate({ + status_code = 301, + location = "definitely_not_a_url" + }) + assert.falsy(ok) + assert.same("missing host in url", err.config.location) + end) + + it("incoming_path must be a boolean", function() + local ok, err = validate({ + status_code = 301, + location = "https://example.com", + keep_incoming_path = "invalid" + }) + assert.falsy(ok) + assert.same("expected a boolean", err.config.keep_incoming_path) + end) + end) +end) diff --git a/spec/03-plugins/45-redirect/02-access_spec.lua b/spec/03-plugins/45-redirect/02-access_spec.lua new file mode 100644 index 000000000000..563181c32ace --- /dev/null +++ b/spec/03-plugins/45-redirect/02-access_spec.lua @@ -0,0 +1,148 @@ +local helpers = require "spec.helpers" + +for _, strategy in helpers.each_strategy() do + describe("Plugin: redirect (access) [#" .. strategy .. "]", function() + local proxy_client + local admin_client + + lazy_setup(function() + local bp = helpers.get_db_utils(strategy, { "routes", "services", "plugins" }) + + -- Default status code + local route1 = bp.routes:insert({ + hosts = { "api1.redirect.test" } + }) + + bp.plugins:insert { + name = "redirect", + route = { + id = route1.id + }, + config = { + location = "https://example.com" + } + } + + -- Custom status code + local route2 = bp.routes:insert({ + hosts = { "api2.redirect.test" } + }) + + bp.plugins:insert { + name = "redirect", + route = { + id = route2.id + }, + config = { + status_code = 302, + location = "https://example.com" + } + } + + -- config.keep_incoming_path = false + local route3 = bp.routes:insert({ + hosts = { "api3.redirect.test" } + }) + + bp.plugins:insert { + name = "redirect", + route = { + id = route3.id + }, + config = { + location = "https://example.com/path?foo=bar" + } + } + + -- config.keep_incoming_path = true + local route4 = bp.routes:insert({ + hosts = { "api4.redirect.test" } + }) + + bp.plugins:insert { + name = "redirect", + route = { + id = route4.id + }, + config = { + location = "https://example.com/some_path?foo=bar", + keep_incoming_path = true + } + } + + assert(helpers.start_kong({ + database = strategy, + nginx_conf = "spec/fixtures/custom_nginx.template", + headers_upstream = "off" + })) + end) + + lazy_teardown(function() + helpers.stop_kong() + end) + + before_each(function() + proxy_client = helpers.proxy_client() + admin_client = helpers.admin_client() + end) + + after_each(function() + if proxy_client then + proxy_client:close() + end + if admin_client then + admin_client:close() + end + end) + + describe("status code", function() + it("default status code", function() + local res = assert(proxy_client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api1.redirect.test" + } + }) + assert.res_status(301, res) + end) + + it("custom status code", function() + local res = assert(proxy_client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api2.redirect.test" + } + }) + assert.res_status(302, res) + end) + end) + + describe("location header", function() + it("supports path and query params in location", function() + local res = assert(proxy_client:send { + method = "GET", + path = "/status/200", + headers = { + ["Host"] = "api3.redirect.test" + } + }) + local header = assert.response(res).has.header("location") + assert.equals("https://example.com/path?foo=bar", header) + end) + + it("keeps the existing redirect URL", function() + local res = assert(proxy_client:send { + method = "GET", + path = "/status/200?keep=this", + headers = { + ["Host"] = "api4.redirect.test" + } + }) + local header = assert.response(res).has.header("location") + assert.equals("https://example.com/status/200?keep=this", header) + end) + end) + end) +end diff --git a/spec/03-plugins/45-redirect/03-integration_spec.lua b/spec/03-plugins/45-redirect/03-integration_spec.lua new file mode 100644 index 000000000000..0df59db57999 --- /dev/null +++ b/spec/03-plugins/45-redirect/03-integration_spec.lua @@ -0,0 +1,89 @@ +local helpers = require "spec.helpers" + + +for _, strategy in helpers.each_strategy() do + describe("Plugin: redirect (integration) [#" .. strategy .. "]", function() + local proxy_client + local admin_client + local consumer + + lazy_setup(function() + local bp = helpers.get_db_utils(strategy, { + "routes", + "services", + "plugins", + "consumers", + "keyauth_credentials", + }) + + bp.routes:insert({ + hosts = { "api1.redirect.test" }, + }) + + bp.plugins:insert { + name = "key-auth", + } + + consumer = bp.consumers:insert { + username = "bob", + } + + bp.keyauth_credentials:insert { + key = "kong", + consumer = { id = consumer.id }, + } + + assert(helpers.start_kong({ + database = strategy, + nginx_conf = "spec/fixtures/custom_nginx.template", + })) + + proxy_client = helpers.proxy_client() + admin_client = helpers.admin_client() + end) + + lazy_teardown(function() + if proxy_client and admin_client then + proxy_client:close() + admin_client:close() + end + helpers.stop_kong() + end) + + it("can be applied on a consumer", function() + -- add the plugin to a consumer + local res = assert(admin_client:send { + method = "POST", + path = "/plugins", + headers = { + ["Content-type"] = "application/json", + }, + body = { + name = "redirect", + config = { + location = "https://example.com/path?foo=bar", + }, + consumer = { id = consumer.id }, + }, + }) + assert.response(res).has.status(201) + + -- verify access being blocked + helpers.wait_until(function() + res = assert(proxy_client:send { + method = "GET", + path = "/request", + headers = { + ["Host"] = "api1.redirect.test", + ["apikey"] = "kong", + }, + }) + return pcall(function() + assert.response(res).has.status(301) + end) + end, 10) + local header = assert.response(res).has.header("location") + assert.equals("https://example.com/path?foo=bar", header) + end) + end) +end diff --git a/spec/06-third-party/01-deck/01-deck-integration_spec.lua b/spec/06-third-party/01-deck/01-deck-integration_spec.lua index 21bdf4b58e55..c6465f9aa952 100644 --- a/spec/06-third-party/01-deck/01-deck-integration_spec.lua +++ b/spec/06-third-party/01-deck/01-deck-integration_spec.lua @@ -210,6 +210,12 @@ local function get_plugins_configs(service) config = { secret_v1 = "test", }, + }, + ["redirect"] = { + name = "redirect", + config = { + location = "https://example.com", + }, } } end