diff --git a/apisix/plugins/request-validation.lua b/apisix/plugins/request-validation.lua new file mode 100644 index 000000000000..db5b22381923 --- /dev/null +++ b/apisix/plugins/request-validation.lua @@ -0,0 +1,96 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local core = require("apisix.core") +local plugin_name = "request-validation" +local ngx = ngx +local io = io + +local schema = { + type = "object", + properties = { + body_schema = {type = "object"}, + header_schema = {type = "object"} + }, + anyOf = { + {required = {"body_schema"}}, + {required = {"header_schema"}} + } +} + + +local _M = { + version = 0.1, + priority = 2800, + type = 'validation', + name = plugin_name, + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +function _M.rewrite(conf) + local headers = ngx.req.get_headers() + + if conf.header_schema then + local ok, err = core.schema.check(conf.header_schema, headers) + if not ok then + core.log.error("req schema validation failed", err) + core.response.exit(400, err) + end + end + + if conf.body_schema then + ngx.req.read_body() + local req_body, error + local body = ngx.req.get_body_data() + + if not body then + local filename = ngx.req.get_body_file() + if not filename then + return core.response.exit(500) + end + local fd = io.open(filename, 'rb') + if not fd then + return core.response.exit(500) + end + body = fd:read('*a') + end + + if headers["content-type"] == "application/x-www-form-urlencoded" then + req_body, error = ngx.decode_args(body) + else -- JSON as default + req_body, error = core.json.decode(body) + end + + if not req_body then + core.log.error('failed to decode the req body', error) + return core.response.exit(400, error) + end + + local ok, err = core.schema.check(conf.body_schema, req_body) + if not ok then + core.log.error("req schema validation failed", err) + return core.response.exit(400, err) + end + end +end + +return _M diff --git a/conf/config.yaml b/conf/config.yaml index 06470689afcf..95ff64e860cd 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -170,6 +170,7 @@ plugins: # plugin list - echo - authz-keycloak - uri-blocker + - request-validation stream_plugins: - mqtt-proxy diff --git a/doc/plugins/request-validation.md b/doc/plugins/request-validation.md new file mode 100644 index 000000000000..fd2322756b21 --- /dev/null +++ b/doc/plugins/request-validation.md @@ -0,0 +1,151 @@ + + +[Chinese](request-validation-cn.md) + +# Summary +- [**Name**](#name) +- [**Attributes**](#attributes) +- [**How To Enable**](#how-to-enable) +- [**Test Plugin**](#test-plugin) +- [**Disable Plugin**](#disable-plugin) +- [**Examples**](#examples) + + +## Name + +`request-validation` plugin validates the requests before forwarding to an upstream service. The validation plugin uses +json-schema to validate the schema. The plugin can be used to validate the headers and body data. + +For more information on schema, refer to [JSON schema](https://github.com/api7/jsonschema) for more information. + +## Attributes + +|Name |Requirement |Description| +|--------- |-------- |-----------| +| header_schema |optional |schema for the header data| +| body_schema |optional |schema for the body data| + +## How To Enable + +Create a route and enable the request-validation plugin on the route: + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/5 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/get", + "plugins": { + "request-validation": { + "body_schema": { + "type": "object", + "required": ["required_payload"], + "properties": { + "required_payload": {"type": "string"}, + "boolean_payload": {"type": "boolean"} + } + } + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +} +``` + +## Test Plugin + +```shell +curl --header "Content-Type: application/json" \ + --request POST \ + --data '{"boolean-payload":true,"required_payload":"hello"}' \ + http://127.0.0.1:9080/get +``` + +If the schema is violated the plugin will yield a `400` bad request. + +## Disable Plugin + +Remove the corresponding json configuration in the plugin configuration to disable the `request-validation`. +APISIX plugins are hot-reloaded, therefore no need to restart APISIX. + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/5 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/get", + "plugins": { + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +} +``` + + +## Examples: + +**Using ENUMS:** + +```shell +"body_schema": { + "type": "object", + "required": ["required_payload"], + "properties": { + "emum_payload": { + "type": "string", + enum: ["enum_string_1", "enum_string_2"] + default = "enum_string_1" + } + } +} +``` + + +**JSON with multiple levels:** + +```shell +"body_schema": { + "type": "object", + "required": ["required_payload"], + "properties": { + "boolean_payload": {"type": "boolean"}, + "child_element_name": { + "type": "object", + "properties": { + "http_statuses": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 200, + "maximum": 599 + }, + "uniqueItems": true, + "default": [200, 201, 202, 203] + } + } + } + } +} +``` diff --git a/t/admin/plugins.t b/t/admin/plugins.t index 9d8e64d43904..9d96556cf3cd 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -30,7 +30,7 @@ __DATA__ --- request GET /apisix/admin/plugins/list --- response_body_like eval -qr/\["fault-injection","serverless-pre-function","batch-requests","cors","ip-restriction","uri-blocker","openid-connect","wolf-rbac","basic-auth","jwt-auth","key-auth","consumer-restriction","authz-keycloak","proxy-mirror","proxy-cache","proxy-rewrite","limit-conn","limit-count","limit-req","node-status","redirect","response-rewrite","grpc-transcode","prometheus","echo","http-logger","tcp-logger","kafka-logger","syslog","udp-logger","zipkin","skywalking","serverless-post-function"\]/ +qr/\["fault-injection","serverless-pre-function","batch-requests","cors","ip-restriction","uri-blocker","request-validation","openid-connect","wolf-rbac","basic-auth","jwt-auth","key-auth","consumer-restriction","authz-keycloak","proxy-mirror","proxy-cache","proxy-rewrite","limit-conn","limit-count","limit-req","node-status","redirect","response-rewrite","grpc-transcode","prometheus","echo","http-logger","tcp-logger","kafka-logger","syslog","udp-logger","zipkin","skywalking","serverless-post-function"\]/ --- no_error_log [error] diff --git a/t/debug/debug-mode.t b/t/debug/debug-mode.t index 0299b6cd2006..1621fbe33a69 100644 --- a/t/debug/debug-mode.t +++ b/t/debug/debug-mode.t @@ -60,6 +60,7 @@ loaded plugin and sort by priority: 4010 name: batch-requests loaded plugin and sort by priority: 4000 name: cors loaded plugin and sort by priority: 3000 name: ip-restriction loaded plugin and sort by priority: 2900 name: uri-blocker +loaded plugin and sort by priority: 2800 name: request-validation loaded plugin and sort by priority: 2599 name: openid-connect loaded plugin and sort by priority: 2555 name: wolf-rbac loaded plugin and sort by priority: 2520 name: basic-auth diff --git a/t/plugin/request-validation.t b/t/plugin/request-validation.t new file mode 100644 index 000000000000..268e48af9f4a --- /dev/null +++ b/t/plugin/request-validation.t @@ -0,0 +1,390 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +log_level('debug'); +repeat_each(1); +no_long_string(); +no_root_location(); +run_tests; + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.request-validation") + local ok, err = plugin.check_schema({body_schema = {}}) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + + +=== TEST 2: missing schema for header and body +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.request-validation") + local ok, err = plugin.check_schema({}) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +object matches none of the requireds: ["body_schema"] or ["header_schema"] +done +--- no_error_log +[error] + + + +=== TEST 3: add plugin with all combinations +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "request-validation": { + "body_schema": { + "type": "object", + "required": ["required_payload"], + "properties": { + "required_payload": {"type": "string"}, + "boolean_payload": {"type": "boolean"}, + "timeouts": { + "type": "integer", + "minimum": 1, + "maximum": 254, + "default": 3 + }, + "req_headers": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/opentracing" + }]], + [[{ + "node": { + "value": { + "plugins": { + "request-validation": { + "body_schema": { + "type": "object", + "required": ["required_payload"], + "properties": { + "required_payload": {"type": "string"}, + "boolean_payload": {"type": "boolean"}, + "timeouts": { + "type": "integer", + "minimum": 1, + "maximum": 254, + "default": 3 + }, + "req_headers": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/opentracing" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 4: required payload missing +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/opentracing" + local res, err = httpc:request_uri(uri, + { + method = "POST", + body = '{"boolean-payload": true}', + headers = { + ["Content-Type"] = "application/json", + } + }) + + if res.status == 400 then + ngx.say("required field missing") + else + ngx.say("failed") + end + } + } +--- request +GET /t +--- response_body +required field missing +--- error_log +property "required_payload" is required + + + +=== TEST 5: required payload added +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/opentracing" + local res, err = httpc:request_uri(uri, + { + method = "POST", + body = '{"boolean-payload": true,' .. + '"required_payload": "hello"}', + headers = { + ["Content-Type"] = "application/json", + } + }) + + if res.status == 200 then + ngx.say("hello1 world") + else + ngx.say("failed") + end + } + } +--- request +GET /t +--- response_body +hello1 world +--- no_error_log + + +=== TEST 6: Add plugin with header_schema +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "request-validation": { + "header_schema": { + "type": "object", + "required": ["required_payload"], + "properties": { + "required_payload": {"type": "string"}, + "boolean_payload": {"type": "boolean"}, + "timeouts": { + "type": "integer", + "minimum": 1, + "maximum": 254, + "default": 3 + }, + "req_headers": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/opentracing" + }]], + [[{ + "node": { + "value": { + "plugins": { + "request-validation": { + "header_schema": { + "type": "object", + "required": ["required_payload"], + "properties": { + "required_payload": {"type": "string"}, + "boolean_payload": {"type": "boolean"}, + "timeouts": { + "type": "integer", + "minimum": 1, + "maximum": 254, + "default": 3 + }, + "req_headers": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/opentracing" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 7: required header payload missing +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/opentracing" + local res, err = httpc:request_uri(uri, + { + method = "GET", + headers = { + ["Content-Type"] = "application/json" + } + }) + + if res.status == 400 then + ngx.say("required field missing") + else + ngx.say("failed") + end + } + } +--- request +GET /t +--- response_body +required field missing +--- error_log +property "required_payload" is required + + + +=== TEST 8: required header added in header +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/opentracing" + local res, err = httpc:request_uri(uri, + { + method = "GET", + headers = { + ["Content-Type"] = "application/json", + ["required_payload"] = "test payload" + } + }) + + if res.status == 200 then + ngx.say("hello1 world") + else + ngx.say("failed") + end + } + } +--- request +GET /t +--- response_body +hello1 world +--- no_error_log +[error]