diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b120c46..a866c58b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Bump openresty to 1.21.4.3 [PR #1461](https://github.com/3scale/APIcast/pull/1461) [THREESCALE-10601](https://issues.redhat.com/browse/THREESCALE-10601) +- Support Financial-grade API (FAPI) - Baseline profile [PR #1465](https://github.com/3scale/APIcast/pull/1465) [THREESCALE-10973](https://issues.redhat.com/browse/THREESCALE-10973) + ## [3.15.0] 2024-04-04 ### Fixed diff --git a/Dockerfile b/Dockerfile index 6a5a6d37e..3e6a33319 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,7 @@ RUN luarocks install --deps-mode=none --tree /usr/local https://luarocks.org/man RUN luarocks install --deps-mode=none --tree /usr/local https://luarocks.org/manifests/knyar/nginx-lua-prometheus-0.20181120-2.src.rock RUN luarocks install --deps-mode=none --tree /usr/local https://luarocks.org/manifests/hamish/lua-resty-iputils-0.3.0-1.src.rock RUN luarocks install --deps-mode=none --tree /usr/local https://luarocks.org/manifests/golgote/net-url-0.9-1.src.rock +RUN luarocks install --deps-mode=none --tree /usr/local https://luarocks.org/manifests/membphis/lua-resty-ipmatcher-0.6.1-0.src.rock RUN yum -y remove libyaml-devel m4 openssl-devel git gcc luarocks && \ rm -rf /var/cache/yum && yum clean all -y && \ diff --git a/gateway/Roverfile.lock b/gateway/Roverfile.lock index ef2469e79..9c361dbd4 100644 --- a/gateway/Roverfile.lock +++ b/gateway/Roverfile.lock @@ -10,6 +10,7 @@ liquid 0.2.0-2||production lua-resty-env 0.4.0-1||production lua-resty-execvp 0.1.1-1||production lua-resty-http 0.17.1-0||production +lua-resty-ipmatcher 0.6.1-0||production lua-resty-iputils 0.3.0-2||production lua-resty-jit-uuid 0.0.7-2||production lua-resty-jwt 0.2.0-0||production diff --git a/gateway/apicast-scm-1.rockspec b/gateway/apicast-scm-1.rockspec index d588476db..e6477cd75 100644 --- a/gateway/apicast-scm-1.rockspec +++ b/gateway/apicast-scm-1.rockspec @@ -23,6 +23,7 @@ dependencies = { 'penlight', 'nginx-lua-prometheus == 0.20181120', 'lua-resty-jit-uuid', + 'lua-resty-ipmatcher', } build = { type = "make", diff --git a/gateway/src/apicast/policy/fapi/README.md b/gateway/src/apicast/policy/fapi/README.md new file mode 100644 index 000000000..8b630c428 --- /dev/null +++ b/gateway/src/apicast/policy/fapi/README.md @@ -0,0 +1,32 @@ +# FAPI Policy + +## Description + +The FAPI policy supports various features of the Financial-grade API (FAPI) standard. + +## Example configuration + +``` +"policy_chain": [ + { "name": "apicast.policy.fapi", "configuration": {} }, + { + "name": "apicast.policy.apicast" + } +] +``` + +### Validate x-fapi-customer-ip-address header + +``` +"policy_chain": [ + { + "name": "apicast.policy.fapi", + "configuration": { + "validate_x_fapi_customer_ip_address": true + } + }, + { + "name": "apicast.policy.apicast" + } +] +``` diff --git a/gateway/src/apicast/policy/fapi/apicast-config.json b/gateway/src/apicast/policy/fapi/apicast-config.json new file mode 100644 index 000000000..9d7ecb340 --- /dev/null +++ b/gateway/src/apicast/policy/fapi/apicast-config.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://apicast.io/policy-v1/schema#manifest#", + "name": "The Financial-grade API (FAPI)", + "summary": "Support FAPI profiles", + "description": ["This policy adding support for Financial-grade API (API) profiles" + ], + "version": "builtin", + "configuration": { + "type": "object", + "properties": { + "validate_x_fapi_customer_ip_address": { + "description": "Validate x-fapi-customer-ip-address header", + "type": "boolean", + "default": "false" + } + } + } +} diff --git a/gateway/src/apicast/policy/fapi/fapi.lua b/gateway/src/apicast/policy/fapi/fapi.lua new file mode 100644 index 000000000..d42c64884 --- /dev/null +++ b/gateway/src/apicast/policy/fapi/fapi.lua @@ -0,0 +1,74 @@ +--- Financial-grade API (FAPI) policy + +local policy = require('apicast.policy') +local _M = policy.new('Financial-grade API (FAPI) Policy', 'builtin') + +local uuid = require 'resty.jit-uuid' +local ipmatcher = require "resty.ipmatcher" +local fmt = string.format + +local new = _M.new +local X_FAPI_TRANSACTION_ID_HEADER = "x-fapi-transaction-id" +local X_FAPI_CUSTOMER_IP_ADDRESS = "x-fapi-customer-ip-address" + +local function is_valid_ip(ip) + if type(ip) ~= "string" then + return false + end + if ipmatcher.parse_ipv4(ip) then + return true + end + + return ipmatcher.parse_ipv6(ip) +end + +local function error(status_code, msg) + ngx.status = status_code + ngx.header.content_type = 'application/json; charset=utf-8' + ngx.print(fmt('{"error": "%s"}', msg)) + ngx.exit(ngx.status) +end + +--- Initialize FAPI policy +-- @tparam[config] table config +-- @field[config] validate_x_fapi_customer_ip_address Boolean +function _M.new(config) + local self = new(config) + self.validate_customer_ip_address = config and config.validate_x_fapi_customer_ip_address + return self +end + +function _M:access() + --- 6.2.1.13 + -- shall not reject requests with a x-fapi-customer-ip-address header containing a valid IPv4 or IPv6 address. + if self.validate_customer_ip_address then + local customer_ip = ngx.req.get_headers()[X_FAPI_CUSTOMER_IP_ADDRESS] + + if customer_ip then + -- The standard does not mention the case of having multiple IPs, but the + -- x-fapi-customer-ip-address can contain multiple IPs, however I think it doesn't + -- make much sense for this header to have more than one IP, so we reject the request + -- if the header is a table. + if not is_valid_ip(customer_ip) then + ngx.log(ngx.WARN, "invalid x-fapi-customer-ip-address") + return error(ngx.HTTP_FORBIDDEN, "invalid_request") + end + end + end +end + +function _M:header_filter() + --- 6.2.1.11 + -- shall set the response header x-fapi-interaction-id to the value received from the corresponding FAPI client request header or to a RFC4122 UUID value if the request header was not provided to track the interaction + local transaction_id = ngx.req.get_headers()[X_FAPI_TRANSACTION_ID_HEADER] + if not transaction_id or transaction_id == "" then + -- Nothing found, generate one + transaction_id = ngx.resp.get_headers()[X_FAPI_TRANSACTION_ID_HEADER] + if not transaction_id or transaction_id == "" then + transaction_id = uuid.generate_v4() + end + end + ngx.header[X_FAPI_TRANSACTION_ID_HEADER] = transaction_id +end + +return _M diff --git a/gateway/src/apicast/policy/fapi/init.lua b/gateway/src/apicast/policy/fapi/init.lua new file mode 100644 index 000000000..c39cead02 --- /dev/null +++ b/gateway/src/apicast/policy/fapi/init.lua @@ -0,0 +1 @@ +return require('fapi') diff --git a/spec/policy/fapi/fapi_spec.lua b/spec/policy/fapi/fapi_spec.lua new file mode 100644 index 000000000..1423ec135 --- /dev/null +++ b/spec/policy/fapi/fapi_spec.lua @@ -0,0 +1,81 @@ +local FAPIPolicy = require('apicast.policy.fapi') +local uuid = require('resty.jit-uuid') + +describe('fapi_1_baseline_profile policy', function() + local ngx_req_headers = {} + local ngx_resp_headers = {} + local context = {} + before_each(function() + ngx.header = {} + ngx_req_headers = {} + ngx_resp_headers = {} + context = {} + stub(ngx.req, 'get_headers', function() return ngx_req_headers end) + stub(ngx.req, 'set_header', function(name, value) ngx_req_headers[name] = value end) + stub(ngx.resp, 'get_headers', function() return ngx_resp_headers end) + stub(ngx.resp, 'set_header', function(name, value) ngx_resp_headers[name] = value end) + stub(ngx, 'print') + stub(ngx, 'exit') + end) + + describe('.new', function() + it('works without configuration', function() + assert(FAPIPolicy.new({})) + end) + end) + + describe('.header_filter', function() + it('Use value from request', function() + ngx_req_headers['x-fapi-transaction-id'] = 'abc' + local transaction_id_policy = FAPIPolicy.new({}) + transaction_id_policy:header_filter() + assert.same('abc', ngx.header['x-fapi-transaction-id']) + end) + + it('Only use x-fapi-transaction-id from request if the header also exist in response from upstream', function() + ngx_req_headers['x-fapi-transaction-id'] = 'abc' + ngx_resp_headers['x-fapi-transaction-id'] = 'bdf' + local transaction_id_policy = FAPIPolicy.new({}) + transaction_id_policy:header_filter() + assert.same('abc', ngx.header['x-fapi-transaction-id']) + end) + + it('Use x-fapi-transaction-id from upstream response', function() + ngx_resp_headers['x-fapi-transaction-id'] = 'abc' + local transaction_id_policy = FAPIPolicy.new({}) + transaction_id_policy:header_filter() + assert.same('abc', ngx.header['x-fapi-transaction-id']) + end) + + it('generate uuid if header does not exist in both request and response', function() + local transaction_id_policy = FAPIPolicy.new({}) + transaction_id_policy:header_filter() + assert.is_true(uuid.is_valid(ngx.header['x-fapi-transaction-id'])) + end) + end) + + describe('x-fapi-customer-ip-address', function() + it('Allow request with valid IPv4', function() + ngx_req_headers['x-fapi-customer-ip-address'] = '127.0.0.1' + local transaction_id_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true}) + transaction_id_policy:access() + assert.stub(ngx.exit).was_not.called_with(403) + end) + + it('Allow request with valid IPv6', function() + ngx_req_headers['x-fapi-customer-ip-address'] = '2001:db8::123:12:1' + local transaction_id_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true}) + transaction_id_policy:access() + assert.stub(ngx.exit).was_not.called_with(403) + end) + + it('Reject request if header contains more than 1 IP', function() + ngx_req_headers['x-fapi-customer-ip-address'] = {"2001:db8::123:12:1", "127.0.0.1"} + local transaction_id_policy = FAPIPolicy.new({validate_x_fapi_customer_ip_address=true}) + transaction_id_policy:access() + assert.same(ngx.status, 403) + assert.stub(ngx.print).was.called_with('{"error": "invalid_request"}') + assert.stub(ngx.exit).was.called_with(403) + end) + end) +end) diff --git a/t/apicast-policy-fapi.t b/t/apicast-policy-fapi.t new file mode 100644 index 000000000..1efa14247 --- /dev/null +++ b/t/apicast-policy-fapi.t @@ -0,0 +1,310 @@ +use lib 't'; +use Test::APIcast::Blackbox 'no_plan'; + +# Test::Nginx does not allow to grep access logs, so we redirect them to +# stderr to be able to use "grep_error_log" by setting APICAST_ACCESS_LOG_FILE + +run_tests(); + +__DATA__ + +=== TEST 1: Enables fapi policy inject x-fapi-transaction-id header to the response +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.fapi", "configuration": {} + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + ngx.exit(200) + } + } +--- more_headers +x-fapi-transaction-id: abc +--- response_headers +x-fapi-transaction-id: abc +--- request +GET /?user_key=value +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 2: When x-fapi-transaction-id exist in both request and response headers, always use +value from request +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.fapi" + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + ngx.header['x-fapi-transaction-id'] = "blah" + ngx.exit(200) + } + } +--- more_headers +x-fapi-transaction-id: abc +--- request +GET /?user_key=value +--- response_headers +x-fapi-transaction-id: abc +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 3: Use x-fapi-transaction-id header from upstream response +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.fapi" + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + ngx.header['x-fapi-transaction-id'] = "blah" + ngx.exit(200) + } + } +--- request +GET /?user_key=value +--- response_headers +x-fapi-transaction-id: blah +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 4: inject uuid to the response header +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.fapi" + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + ngx.exit(200) + } + } +--- request +GET /?user_key=value +--- response_headers_like +x-fapi-transaction-id: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 5: Validate x-fapi-customer-ip-address header +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.fapi", + "configuration": { + "validate_x_fapi_customer_ip_address": true + } + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + ngx.exit(200) + } + } +--- more_headers +x-fapi-customer-ip-address: 192.168.0.1 +--- request +GET /?user_key=value +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 6: Reject request with invalid x-fapi-customer-ip-address header +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.fapi", + "configuration": { + "validate_x_fapi_customer_ip_address": true + } + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + ngx.exit(200) + } + } +--- more_headers +x-fapi-customer-ip-address: something +--- request +GET /?user_key=value +--- error_code: 403 +--- response_body chomp +{"error": "invalid_request"} +--- no_error_log +[error]