From 49432eaa36a3451530ea41ec4a58bd4afeda0323 Mon Sep 17 00:00:00 2001 From: An Tran Date: Thu, 9 Oct 2025 12:56:01 +1000 Subject: [PATCH 01/18] [fapi] use x-fapi-interaction-id header --- gateway/src/apicast/policy/fapi/fapi.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gateway/src/apicast/policy/fapi/fapi.lua b/gateway/src/apicast/policy/fapi/fapi.lua index fd597816f..52c17c778 100644 --- a/gateway/src/apicast/policy/fapi/fapi.lua +++ b/gateway/src/apicast/policy/fapi/fapi.lua @@ -13,7 +13,7 @@ local b64 = require('ngx.base64') local fmt = string.format local new = _M.new -local X_FAPI_TRANSACTION_ID_HEADER = "x-fapi-transaction-id" +local X_FAPI_INTERACTION_ID_HEADER = "x-fapi-interaction-id" local X_FAPI_CUSTOMER_IP_ADDRESS = "x-fapi-customer-ip-address" -- The "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header @@ -98,15 +98,15 @@ 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] + local transaction_id = ngx.req.get_headers()[X_FAPI_INTERACTION_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] + transaction_id = ngx.resp.get_headers()[X_FAPI_INTERACTION_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 + ngx.header[X_FAPI_INTERACTION_ID_HEADER] = transaction_id end return _M From e6759af293babcb76fcaf92ced063232e75dd874 Mon Sep 17 00:00:00 2001 From: An Tran Date: Thu, 9 Oct 2025 13:16:35 +1000 Subject: [PATCH 02/18] [fapi] improve documentation --- CHANGELOG.md | 9 +++++++ gateway/src/apicast/policy/fapi/README.md | 29 +++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 765cd61aa..9666c1b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Fixed +- Correct FAPI header to `x-fapi-interaction-id` [PR #1557](https://github.com/3scale/APIcast/pull/1557) [THREESCALE-11957](https://issues.redhat.com/browse/THREESCALE-11957) + +### Added +- Update APIcast schema manifest [PR #1550](https://github.com/3scale/APIcast/pull/1550) +- Update luarocks to v3.12.0 [PR #1555](https://github.com/3scale/APIcast/pull/1555) + +### Removed + ## [3.16.0] 2025-05-19 ### Fixed diff --git a/gateway/src/apicast/policy/fapi/README.md b/gateway/src/apicast/policy/fapi/README.md index a2fe27807..783dd54d2 100644 --- a/gateway/src/apicast/policy/fapi/README.md +++ b/gateway/src/apicast/policy/fapi/README.md @@ -4,11 +4,13 @@ The FAPI policy supports various features of the Financial-grade API (FAPI) standard. -* FAPI 1.0 Baseline Profile -* FAPI 1.0 Advance Profile +* [FAPI 1.0 Baseline Profile](https://openid.net/specs/openid-financial-api-part-1-1_0.html) +* [FAPI 1.0 Advance Profile](https://openid.net/specs/openid-financial-api-part-2-1_0.html) ## Example configuration +FAPI policy 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. + ``` "policy_chain": [ { "name": "apicast.policy.fapi", "configuration": {} }, @@ -17,8 +19,24 @@ The FAPI policy supports various features of the Financial-grade API (FAPI) stan } ] ``` +### Log the value of x-fapi-interaction-id header + +``` +"policy_chain": [ + { "name": "apicast.policy.fapi", "configuration": {} }, + { + "name": "apicast.policy.logging", + "configuration": { + "enable_access_logs": false, + "custom_logging": "[{{time_local}}] {{host}}:{{server_port}} {{remote_addr}}:{{remote_port}} x-fapi-interaction-id: {{resp.headers.x-fapi-interaction-id}} \"{{request}}\" {{status}} {{body_bytes_sent}} ({{request_time}}) {{post_action_impact}} ", + } + } + { "name": "apicast.policy.apicast" } +] +``` ### Validate x-fapi-customer-ip-address header +Validate requests with a x-fapi-customer-ip-address header containing a valid IPv4 or IPv6 address ``` "policy_chain": [ @@ -36,6 +54,13 @@ The FAPI policy supports various features of the Financial-grade API (FAPI) stan ### Validate certificate-bound access tokens +Certificate-bound access tokens, as defined in [RFC 8705]((https://datatracker.ietf.org/doc/html/rfc8705)), enhance security by linking tokens to clients, thereby verifying the sender's authorization to access protected resources. + +You'll need to: +* Configure an Identity Provider (IdP) such as Keycloak configured with mTLS and X.509 client certificate authentication. +* Configure the gateway to handle mTLS client certificate authentication. +* Enable `validate_oauth2_certificate_bound_access_token` in the FAPI plugin. + ``` "policy_chain": [ { From a7d50c62d32c33867f565449693fd0d9fe4a193e Mon Sep 17 00:00:00 2001 From: An Tran Date: Thu, 9 Oct 2025 18:30:50 +1000 Subject: [PATCH 03/18] [fapi] update tests to reflect recent change --- spec/policy/fapi/fapi_spec.lua | 21 ++++++++++----------- t/apicast-policy-fapi.t | 22 +++++++++++----------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/spec/policy/fapi/fapi_spec.lua b/spec/policy/fapi/fapi_spec.lua index e65ad4bfb..124e990e3 100644 --- a/spec/policy/fapi/fapi_spec.lua +++ b/spec/policy/fapi/fapi_spec.lua @@ -7,7 +7,6 @@ local clientCert = assert(fixture('CA', 'client.crt')) local header_parameter = 'x5t#S256' local function jwt_cnf() - local cnf = b64.encode_base64url(X509.new(clientCert):digest('SHA256')) return { [header_parameter] = b64.encode_base64url(X509.new(clientCert):digest('SHA256')) } end @@ -36,31 +35,31 @@ describe('fapi_1_baseline_profile policy', function() describe('.header_filter', function() it('Use value from request', function() - ngx_req_headers['x-fapi-transaction-id'] = 'abc' + ngx_req_headers['x-fapi-interaction-id'] = 'abc' local fapi_policy = FAPIPolicy.new({}) fapi_policy:header_filter() - assert.same('abc', ngx.header['x-fapi-transaction-id']) + assert.same('abc', ngx.header['x-fapi-interaction-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' + it('Only use x-fapi-interaction-id from request if the header also exist in response from upstream', function() + ngx_req_headers['x-fapi-interaction-id'] = 'abc' + ngx_resp_headers['x-fapi-interaction-id'] = 'bdf' local fapi_policy = FAPIPolicy.new({}) fapi_policy:header_filter() - assert.same('abc', ngx.header['x-fapi-transaction-id']) + assert.same('abc', ngx.header['x-fapi-interaction-id']) end) - it('Use x-fapi-transaction-id from upstream response', function() - ngx_resp_headers['x-fapi-transaction-id'] = 'abc' + it('Use x-fapi-interaction-id from upstream response', function() + ngx_resp_headers['x-fapi-interaction-id'] = 'abc' local fapi_policy = FAPIPolicy.new({}) fapi_policy:header_filter() - assert.same('abc', ngx.header['x-fapi-transaction-id']) + assert.same('abc', ngx.header['x-fapi-interaction-id']) end) it('generate uuid if header does not exist in both request and response', function() local fapi_policy = FAPIPolicy.new({}) fapi_policy:header_filter() - assert.is_true(uuid.is_valid(ngx.header['x-fapi-transaction-id'])) + assert.is_true(uuid.is_valid(ngx.header['x-fapi-interaction-id'])) end) end) diff --git a/t/apicast-policy-fapi.t b/t/apicast-policy-fapi.t index 662d9c1db..be37d282d 100644 --- a/t/apicast-policy-fapi.t +++ b/t/apicast-policy-fapi.t @@ -23,7 +23,7 @@ run_tests(); __DATA__ -=== TEST 1: Enables fapi policy inject x-fapi-transaction-id header to the response +=== TEST 1: Enables fapi policy inject x-fapi-interaction-id header to the response --- configuration { "services": [ @@ -62,9 +62,9 @@ __DATA__ } } --- more_headers -x-fapi-transaction-id: abc +x-fapi-interaction-id: abc --- response_headers -x-fapi-transaction-id: abc +x-fapi-interaction-id: abc --- request GET /?user_key=value --- error_code: 200 @@ -73,7 +73,7 @@ GET /?user_key=value -=== TEST 2: When x-fapi-transaction-id exist in both request and response headers, always use +=== TEST 2: When x-fapi-interaction-id exist in both request and response headers, always use value from request --- configuration { @@ -109,23 +109,23 @@ value from request --- upstream location / { content_by_lua_block { - ngx.header['x-fapi-transaction-id'] = "blah" + ngx.header['x-fapi-interaction-id'] = "blah" ngx.exit(200) } } --- more_headers -x-fapi-transaction-id: abc +x-fapi-interaction-id: abc --- request GET /?user_key=value --- response_headers -x-fapi-transaction-id: abc +x-fapi-interaction-id: abc --- error_code: 200 --- no_error_log [error] -=== TEST 3: Use x-fapi-transaction-id header from upstream response +=== TEST 3: Use x-fapi-interaction-id header from upstream response --- configuration { "services": [ @@ -160,14 +160,14 @@ x-fapi-transaction-id: abc --- upstream location / { content_by_lua_block { - ngx.header['x-fapi-transaction-id'] = "blah" + ngx.header['x-fapi-interaction-id'] = "blah" ngx.exit(200) } } --- request GET /?user_key=value --- response_headers -x-fapi-transaction-id: blah +x-fapi-interaction-id: blah --- error_code: 200 --- no_error_log [error] @@ -215,7 +215,7 @@ x-fapi-transaction-id: blah --- 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}$ +x-fapi-interaction-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] From fbecf1a41631480b195fdd98272249a53951bc5e Mon Sep 17 00:00:00 2001 From: An Tran Date: Thu, 9 Oct 2025 19:16:06 +1000 Subject: [PATCH 04/18] [fapi] address PR review feedback --- gateway/src/apicast/policy/fapi/README.md | 4 ++-- gateway/src/apicast/policy/fapi/fapi.lua | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gateway/src/apicast/policy/fapi/README.md b/gateway/src/apicast/policy/fapi/README.md index 783dd54d2..1bda34e03 100644 --- a/gateway/src/apicast/policy/fapi/README.md +++ b/gateway/src/apicast/policy/fapi/README.md @@ -54,10 +54,10 @@ Validate requests with a x-fapi-customer-ip-address header containing a valid IP ### Validate certificate-bound access tokens -Certificate-bound access tokens, as defined in [RFC 8705]((https://datatracker.ietf.org/doc/html/rfc8705)), enhance security by linking tokens to clients, thereby verifying the sender's authorization to access protected resources. +Certificate-bound access tokens, as defined in [RFC 8705](https://datatracker.ietf.org/doc/html/rfc8705), enhance security by linking tokens to clients, thereby verifying the sender's authorization to access protected resources. You'll need to: -* Configure an Identity Provider (IdP) such as Keycloak configured with mTLS and X.509 client certificate authentication. +* Configure an Identity Provider (IdP) such as Keycloak with mTLS and X.509 client certificate authentication. * Configure the gateway to handle mTLS client certificate authentication. * Enable `validate_oauth2_certificate_bound_access_token` in the FAPI plugin. diff --git a/gateway/src/apicast/policy/fapi/fapi.lua b/gateway/src/apicast/policy/fapi/fapi.lua index 52c17c778..e6dba30df 100644 --- a/gateway/src/apicast/policy/fapi/fapi.lua +++ b/gateway/src/apicast/policy/fapi/fapi.lua @@ -98,15 +98,15 @@ 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_INTERACTION_ID_HEADER] - if not transaction_id or transaction_id == "" then + local interaction_id = ngx.req.get_headers()[X_FAPI_INTERACTION_ID_HEADER] + if not interaction_id or interaction_id == "" then -- Nothing found, generate one - transaction_id = ngx.resp.get_headers()[X_FAPI_INTERACTION_ID_HEADER] - if not transaction_id or transaction_id == "" then - transaction_id = uuid.generate_v4() + interaction_id = ngx.resp.get_headers()[X_FAPI_INTERACTION_ID_HEADER] + if not interaction_id or interaction_id == "" then + interaction_id = uuid.generate_v4() end end - ngx.header[X_FAPI_INTERACTION_ID_HEADER] = transaction_id + ngx.header[X_FAPI_INTERACTION_ID_HEADER] = interaction_id end return _M From 1cc56f73eacf02234bb200ab073ec151b8717cf7 Mon Sep 17 00:00:00 2001 From: An Tran Date: Fri, 9 Jan 2026 15:44:03 +1000 Subject: [PATCH 05/18] Update lua-rover to v0.2.3 --- Dockerfile.devel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.devel b/Dockerfile.devel index b3d6c9b84..31971a012 100644 --- a/Dockerfile.devel +++ b/Dockerfile.devel @@ -3,7 +3,7 @@ FROM registry.access.redhat.com/ubi8 ARG OPENRESTY_RPM_VERSION="1.21.4-1.el8" ARG LUAROCKS_VERSION="3.12.0" ARG JAEGERTRACING_CPP_CLIENT_RPM_VERSION="0.3.1-13.el8" -ARG LUAROVER_VERSION="0.2.2" +ARG LUAROVER_VERSION="0.2.3" WORKDIR /tmp From 7f6b8e5dbd26c4b61ccc5fbacfc338719f072304 Mon Sep 17 00:00:00 2001 From: An Tran Date: Fri, 9 Jan 2026 16:16:24 +1000 Subject: [PATCH 06/18] Update CI image to 1.21.4-4 --- .circleci/config.yml | 2 +- Makefile | 2 +- docker-compose-devel.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 74a79ac62..2fce4fb87 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -105,7 +105,7 @@ executors: openresty: working_directory: /opt/app-root/apicast docker: - - image: quay.io/3scale/apicast-ci:openresty-1.21.4-3 + - image: quay.io/3scale/apicast-ci:openresty-1.21.4-4 - image: mirror.gcr.io/library/redis environment: TEST_NGINX_BINARY: openresty diff --git a/Makefile b/Makefile index 8ed1954c0..43c926a79 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ NPROC ?= $(firstword $(shell nproc 2>/dev/null) 1) SEPARATOR="\n=============================================\n" -DEVEL_IMAGE ?= quay.io/3scale/apicast-ci:openresty-1.21.4-1 +DEVEL_IMAGE ?= quay.io/3scale/apicast-ci:openresty-1.21.4-4 DEVEL_DOCKERFILE ?= Dockerfile.devel RUNTIME_IMAGE ?= quay.io/3scale/apicast:latest diff --git a/docker-compose-devel.yml b/docker-compose-devel.yml index 9f3a6c037..37787ea2d 100644 --- a/docker-compose-devel.yml +++ b/docker-compose-devel.yml @@ -2,7 +2,7 @@ version: '2.2' services: development: - image: ${IMAGE:-quay.io/3scale/apicast-ci:openresty-1.21.4-3} + image: ${IMAGE:-quay.io/3scale/apicast-ci:openresty-1.21.4-4} platform: "linux/amd64" depends_on: - redis From 70f3b57cfb9b5fc422bbe79726ade6fbb32a7da3 Mon Sep 17 00:00:00 2001 From: An Tran Date: Wed, 28 Jan 2026 12:32:40 +1000 Subject: [PATCH 07/18] Remove link-check --- .github/workflows/link_check.yaml | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 .github/workflows/link_check.yaml diff --git a/.github/workflows/link_check.yaml b/.github/workflows/link_check.yaml deleted file mode 100644 index 6dbbbeefd..000000000 --- a/.github/workflows/link_check.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: link-check - -on: [push, pull_request] -permissions: - contents: read - pull-requests: read - statuses: write -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - statuses: write - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: 14 - - - name: Test - run: | - npm install -g yarn - make test-doc From f95a6d2c0c528da4db1b709a8139d8c8fb05887d Mon Sep 17 00:00:00 2001 From: An Tran Date: Fri, 16 Jan 2026 18:06:12 +1000 Subject: [PATCH 08/18] perf: cache policy manifests Previously, every time the policy chain was rebuilt, the gateway would look for the manifests file on the disk. This caused a delay for incoming requests. Since the manifests are static and do not change at runtime, it is more efficient to cache them at the module level. This caching will speed up the lookup process. --- gateway/src/apicast/policy_loader.lua | 40 ++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/gateway/src/apicast/policy_loader.lua b/gateway/src/apicast/policy_loader.lua index 2ccecf2cc..34554623c 100644 --- a/gateway/src/apicast/policy_loader.lua +++ b/gateway/src/apicast/policy_loader.lua @@ -16,6 +16,11 @@ local concat = table.concat local setmetatable = setmetatable local pcall = pcall +local tab_new = require('table.new') +local isempty = require('table.isempty') + +local manifests_cache = tab_new(32, 0) + local _M = {} local resty_env = require('resty.env') @@ -70,8 +75,22 @@ local function lua_load_path(load_path) return format('%s/?.lua', load_path) end +local function get_manifest(name, version) + local manifests = manifests_cache[name] + if manifests then + for _, manifest in ipairs(manifests) do + if version == manifest.version then + return manifest + end + end + end +end + local function load_manifest(name, version, path) - local manifest = read_manifest(path) + local manifest = get_manifest(name, version) + if not manifest then + manifest = read_manifest(path) + end if manifest then if manifest.version ~= version then @@ -110,8 +129,8 @@ end function _M:load_path(name, version, paths) local failures = {} - for _, path in ipairs(paths or self.policy_load_paths()) do - local manifest, load_path = load_manifest(name, version, format('%s/%s/%s', path, name, version) ) + if version == 'builtin' then + local manifest, load_path = load_manifest(name, version, format('%s/%s', self.builtin_policy_load_path(), name) ) if manifest then return load_path, manifest.configuration @@ -120,8 +139,8 @@ function _M:load_path(name, version, paths) end end - if version == 'builtin' then - local manifest, load_path = load_manifest(name, version, format('%s/%s', self.builtin_policy_load_path(), name) ) + for _, path in ipairs(paths or self.policy_load_paths()) do + local manifest, load_path = load_manifest(name, version, format('%s/%s/%s', path, name, version) ) if manifest then return load_path, manifest.configuration @@ -130,6 +149,7 @@ function _M:load_path(name, version, paths) end end + return nil, nil, failures end @@ -173,9 +193,15 @@ end -- Returns all the policy modules function _M:get_all() local policy_modules = {} + local manifests - local policy_manifests_loader = require('apicast.policy_manifests_loader') - local manifests = policy_manifests_loader.get_all() + if isempty(manifests_cache) then + local policy_manifests_loader = require('apicast.policy_manifests_loader') + manifests = policy_manifests_loader.get_all() + manifests_cache = manifests + else + manifests = manifests_cache + end for policy_name, policy_manifests in pairs(manifests) do for _, manifest in ipairs(policy_manifests) do From e976bf962068d4fcf78bcf885d4aa1fb190c3608 Mon Sep 17 00:00:00 2001 From: An Tran Date: Fri, 16 Jan 2026 19:04:33 +1000 Subject: [PATCH 09/18] perf: reuse PolicyOrderChecker --- gateway/src/apicast/policy_chain.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/gateway/src/apicast/policy_chain.lua b/gateway/src/apicast/policy_chain.lua index f8b6d07a2..1c1d089e7 100644 --- a/gateway/src/apicast/policy_chain.lua +++ b/gateway/src/apicast/policy_chain.lua @@ -193,12 +193,15 @@ function _M:add_policy(name, version, ...) end end +local default_policy_order_check = PolicyOrderChecker.new(policy_manifests_loader.get_all()) + -- Checks if there are any policies placed in the wrong place in the chain. -- It doesn't return anything, it prints error messages when there's a problem. function _M:check_order(manifests) - PolicyOrderChecker.new( - manifests or policy_manifests_loader.get_all() - ):check(self) + if manifests then + PolicyOrderChecker.new(manifests):check(self) + end + default_policy_order_check:check(self) end local function call_chain(phase_name) From 15d50c95a483c5e083de168efcb020a5b9e0cd0a Mon Sep 17 00:00:00 2001 From: An Tran Date: Fri, 16 Jan 2026 21:20:29 +1000 Subject: [PATCH 10/18] perf: cache the JSON schema validator Creating a json schema validator is somewhat expensive, so we cache this step with a local LRU cache --- .../src/apicast/policy_config_validator.lua | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/gateway/src/apicast/policy_config_validator.lua b/gateway/src/apicast/policy_config_validator.lua index dbc9eb0da..46a61460f 100644 --- a/gateway/src/apicast/policy_config_validator.lua +++ b/gateway/src/apicast/policy_config_validator.lua @@ -3,8 +3,35 @@ -- Validates a policy configuration against a policy config JSON schema. local jsonschema = require('jsonschema') +local lrucache = require("resty.lrucache") -local _M = { } +local cached_validator = lrucache.new(100) + +local _M = { + _VERSION=0.1 +} + +local function create_validator(schema) + local ok, res = pcall(jsonschema.generate_validator, schema) + if ok then + return res + end + + return nil, res +end + +local function get_validator(schema) + local validator, err = cached_validator:get(schema) + if not validator then + validator, err = create_validator(schema) + if not validator then + return nil, err + end + cached_validator:set(schema, validator) + end + + return validator, nil +end --- Validate a policy configuration -- Checks if a policy configuration is valid according to the given schema. @@ -13,7 +40,10 @@ local _M = { } -- @treturn boolean True if the policy configuration is valid. False otherwise. -- @treturn string Error message only when the policy config is invalid. function _M.validate_config(config, config_schema) - local validator = jsonschema.generate_validator(config_schema or {}) + local validator, err = get_validator(config_schema or {}) + if not validator then + return false, err + end return validator(config or {}) end From 76af8c4ae92cf3b1c1a7733e887ff3e7a1c5076a Mon Sep 17 00:00:00 2001 From: An Tran Date: Mon, 19 Jan 2026 13:18:21 +1000 Subject: [PATCH 11/18] perf: avoid construct the full policy chain when parsing response from portal --- .../configuration_loader/remote_v2.lua | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/gateway/src/apicast/configuration_loader/remote_v2.lua b/gateway/src/apicast/configuration_loader/remote_v2.lua index a86f2b47b..4bbe36565 100644 --- a/gateway/src/apicast/configuration_loader/remote_v2.lua +++ b/gateway/src/apicast/configuration_loader/remote_v2.lua @@ -102,22 +102,24 @@ local function service_config_endpoint(portal_endpoint, service_id, env, version ) end +local function get_oidc_issuer_endpoint(proxy_content) + return proxy_content.proxy and proxy_content.proxy.oidc_issuer_endpoint +end + local function parse_proxy_configs(self, proxy_configs) local config = { services = array(), oidc = array() } for i, proxy_conf in ipairs(proxy_configs) do local proxy_config = proxy_conf.proxy_config + local content = proxy_config.content - -- Copy the config because parse_service have side-effects. It adds - -- liquid templates in some policies and those cannot be encoded into a - -- JSON. We should get rid of these side effects. - local original_proxy_config = deepcopy(proxy_config) + config.services[i] = content - local service = configuration.parse_service(proxy_config.content) - - -- We always assign a oidc to the service, even an empty one with the - -- service_id, if not on APICAST_SERVICES_LIST will fail on filtering - local oidc = self:oidc_issuer_configuration(service) + local issuer_endpoint = get_oidc_issuer_endpoint(content) + local oidc + if issuer_endpoint then + oidc = self.oidc:call(issuer_endpoint, self.ttl) + end if not oidc then oidc = {} end @@ -125,10 +127,9 @@ local function parse_proxy_configs(self, proxy_configs) -- deepcopy because this can be cached, and we want to have a deepcopy to -- avoid issues with service_id local oidc_copy = deepcopy(oidc) - oidc_copy.service_id = service.id + oidc_copy.service_id = tostring(content.id) config.oidc[i] = oidc_copy - config.services[i] = original_proxy_config.content end return cjson.encode(config) end @@ -482,20 +483,22 @@ function _M:config(service, environment, version, service_regexp_filter) if res.status == 200 then local proxy_config = cjson.decode(res.body).proxy_config - - -- Copy the config because parse_service have side-effects. It adds - -- liquid templates in some policies and those cannot be encoded into a - -- JSON. We should get rid of these side effects. - local original_proxy_config = deepcopy(proxy_config) + local content = proxy_config.content local config_service = configuration.parse_service(proxy_config.content) if service_regexp_filter and not config_service:match_host(service_regexp_filter) then return nil, "Service filtered out because APICAST_SERVICES_FILTER_BY_URL" end - original_proxy_config.oidc = self:oidc_issuer_configuration(config_service) + local issuer_endpoint = get_oidc_issuer_endpoint(content) + local oidc + + if issuer_endpoint then + oidc = self.oidc:call(issuer_endpoint, self.ttl) + end - return original_proxy_config + proxy_config.oidc = oidc + return proxy_config else return nil, status_code_error(res) end From 3f85ecdff6ab4740a0d3e6aac2c5cbf6d8d622b6 Mon Sep 17 00:00:00 2001 From: An Tran Date: Thu, 22 Jan 2026 12:59:38 +1000 Subject: [PATCH 12/18] Address PR feedbacks --- .../src/apicast/configuration_loader/remote_v2.lua | 6 ++---- gateway/src/apicast/policy_chain.lua | 2 +- gateway/src/apicast/policy_loader.lua | 12 ++++++++---- spec/configuration_loader/remote_v2_spec.lua | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/gateway/src/apicast/configuration_loader/remote_v2.lua b/gateway/src/apicast/configuration_loader/remote_v2.lua index 4bbe36565..5ca66b087 100644 --- a/gateway/src/apicast/configuration_loader/remote_v2.lua +++ b/gateway/src/apicast/configuration_loader/remote_v2.lua @@ -120,6 +120,8 @@ local function parse_proxy_configs(self, proxy_configs) if issuer_endpoint then oidc = self.oidc:call(issuer_endpoint, self.ttl) end + -- We always assign a oidc to the service, even an empty one with the + -- service_id, if not on APICAST_SERVICES_LIST will fail on filtering if not oidc then oidc = {} end @@ -452,10 +454,6 @@ function _M:services() return services end -function _M:oidc_issuer_configuration(service) - return self.oidc:call(service.oidc.issuer_endpoint, self.ttl) -end - function _M:config(service, environment, version, service_regexp_filter) local http_client = self.http_client diff --git a/gateway/src/apicast/policy_chain.lua b/gateway/src/apicast/policy_chain.lua index 1c1d089e7..26c4f84b5 100644 --- a/gateway/src/apicast/policy_chain.lua +++ b/gateway/src/apicast/policy_chain.lua @@ -199,7 +199,7 @@ local default_policy_order_check = PolicyOrderChecker.new(policy_manifests_loade -- It doesn't return anything, it prints error messages when there's a problem. function _M:check_order(manifests) if manifests then - PolicyOrderChecker.new(manifests):check(self) + return PolicyOrderChecker.new(manifests):check(self) end default_policy_order_check:check(self) end diff --git a/gateway/src/apicast/policy_loader.lua b/gateway/src/apicast/policy_loader.lua index 34554623c..39855dc50 100644 --- a/gateway/src/apicast/policy_loader.lua +++ b/gateway/src/apicast/policy_loader.lua @@ -16,10 +16,10 @@ local concat = table.concat local setmetatable = setmetatable local pcall = pcall -local tab_new = require('table.new') local isempty = require('table.isempty') -local manifests_cache = tab_new(32, 0) +-- Module-level cache storage (one per worker process) +local manifests_cache = {} local _M = {} @@ -75,7 +75,11 @@ local function lua_load_path(load_path) return format('%s/?.lua', load_path) end -local function get_manifest(name, version) +-- Get a cached manifest by policy name and version +-- @tparam string name The policy name +-- @tparam string version The policy version +-- @treturn table|nil The cached manifest table, or nil if not cached +local function get_cached_manifest(name, version) local manifests = manifests_cache[name] if manifests then for _, manifest in ipairs(manifests) do @@ -87,7 +91,7 @@ local function get_manifest(name, version) end local function load_manifest(name, version, path) - local manifest = get_manifest(name, version) + local manifest = get_cached_manifest(name, version) if not manifest then manifest = read_manifest(path) end diff --git a/spec/configuration_loader/remote_v2_spec.lua b/spec/configuration_loader/remote_v2_spec.lua index 7854f9599..0eee62dd1 100644 --- a/spec/configuration_loader/remote_v2_spec.lua +++ b/spec/configuration_loader/remote_v2_spec.lua @@ -651,7 +651,7 @@ UwIDAQAB it('does not crash on empty issuer', function() local service = { oidc = { issuer_endpoint = '' }} - assert.falsy(loader:oidc_issuer_configuration(service)) + assert.falsy(loader.oidc:call(service.oidc.issuer_endpoint, 0)) end) end) From 17da137effe8565ac87439ceb3f296429b9b38ff Mon Sep 17 00:00:00 2001 From: An Tran Date: Mon, 2 Feb 2026 19:05:40 +1000 Subject: [PATCH 13/18] ci: enfore changelog entry on PR --- .github/workflows/changelog-requirement.yml | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/changelog-requirement.yml diff --git a/.github/workflows/changelog-requirement.yml b/.github/workflows/changelog-requirement.yml new file mode 100644 index 000000000..1e5be5db8 --- /dev/null +++ b/.github/workflows/changelog-requirement.yml @@ -0,0 +1,33 @@ +name: Changelog Requirement + +on: + pull_request: + types: [ opened, synchronize, labeled, unlabeled ] + paths: + - 'gateway/**' + +jobs: + require-changelog: + if: ${{ !contains(github.event.*.labels.*.name, 'skip-changelog') }} + name: Requires changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Find changelog files + id: changelog-file + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # v47 + with: + # Avoid using single or double quotes for multiline patterns + files: | + CHANGELOG.md + + - name: Check changelog existence + if: steps.changelog-file.outputs.any_changed == 'false' + run: | + echo("Please include a CHANGELOG entry. You can find it at [CHANGELOG.md](https://github.com/3scale/apicast/blob/master/CHANGELOG.md)." + echo "Note, we hard-wrap at 80 chars and use 2 spaces after the last line." + echo "If you believe this PR requires no changelog entry, label it with \"skip-changelog\"." + exit 1 From ed7dd82a3b862fc0e65a3a3d765d690afcb1243c Mon Sep 17 00:00:00 2001 From: An Tran Date: Wed, 4 Feb 2026 12:53:01 +1000 Subject: [PATCH 14/18] ci: only trigger changelog workflow on pull request --- .github/workflows/changelog-requirement.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/changelog-requirement.yml b/.github/workflows/changelog-requirement.yml index 1e5be5db8..56bec01ce 100644 --- a/.github/workflows/changelog-requirement.yml +++ b/.github/workflows/changelog-requirement.yml @@ -8,7 +8,7 @@ on: jobs: require-changelog: - if: ${{ !contains(github.event.*.labels.*.name, 'skip-changelog') }} + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} name: Requires changelog runs-on: ubuntu-latest steps: @@ -27,7 +27,7 @@ jobs: - name: Check changelog existence if: steps.changelog-file.outputs.any_changed == 'false' run: | - echo("Please include a CHANGELOG entry. You can find it at [CHANGELOG.md](https://github.com/3scale/apicast/blob/master/CHANGELOG.md)." + echo "Please include a CHANGELOG entry. You can find it at [CHANGELOG.md](https://github.com/3scale/apicast/blob/master/CHANGELOG.md)." echo "Note, we hard-wrap at 80 chars and use 2 spaces after the last line." echo "If you believe this PR requires no changelog entry, label it with \"skip-changelog\"." exit 1 From 407cfed9f684927e9ed278086751af0e1592f1c5 Mon Sep 17 00:00:00 2001 From: An Tran Date: Fri, 23 Jan 2026 15:27:18 +1000 Subject: [PATCH 15/18] fix: only validate oidc setting if authentication method is set to oidc --- CHANGELOG.md | 1 + .../src/apicast/configuration_loader/oidc.lua | 9 +++ .../configuration_loader/remote_v2.lua | 2 +- spec/configuration_loader/oidc_spec.lua | 61 ++++++++++++++++++- spec/configuration_loader/remote_v2_spec.lua | 42 +++++++++++-- 5 files changed, 108 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9666c1b26..c5e85fec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Correct FAPI header to `x-fapi-interaction-id` [PR #1557](https://github.com/3scale/APIcast/pull/1557) [THREESCALE-11957](https://issues.redhat.com/browse/THREESCALE-11957) +- Only validate oidc setting if authentication method is set to oidc [PR #1568](https://github.com/3scale/APIcast/pull/1568) [THREESCALE-11441](https://issues.redhat.com/browse/THREESCALE-11441) ### Added - Update APIcast schema manifest [PR #1550](https://github.com/3scale/APIcast/pull/1550) diff --git a/gateway/src/apicast/configuration_loader/oidc.lua b/gateway/src/apicast/configuration_loader/oidc.lua index 8241e7420..28ea9389c 100644 --- a/gateway/src/apicast/configuration_loader/oidc.lua +++ b/gateway/src/apicast/configuration_loader/oidc.lua @@ -21,6 +21,15 @@ _M.discovery = require('resty.oidc.discovery').new() local function load_service(service) if not service or not service.proxy then return nil end + local proxy = service.proxy + + -- Only fetch OIDC configuration if authentication method is set to 'oidc' + local authentication = proxy.authentication_method or service.backend_version + + if authentication ~= 'oidc' then + return nil + end + local result = _M.discovery:call(service.proxy.oidc_issuer_endpoint) if result and service.id then diff --git a/gateway/src/apicast/configuration_loader/remote_v2.lua b/gateway/src/apicast/configuration_loader/remote_v2.lua index 5ca66b087..e36d57581 100644 --- a/gateway/src/apicast/configuration_loader/remote_v2.lua +++ b/gateway/src/apicast/configuration_loader/remote_v2.lua @@ -103,7 +103,7 @@ local function service_config_endpoint(portal_endpoint, service_id, env, version end local function get_oidc_issuer_endpoint(proxy_content) - return proxy_content.proxy and proxy_content.proxy.oidc_issuer_endpoint + return proxy_content.proxy and (proxy_content.proxy.authentication_method == "oidc") and proxy_content.proxy.oidc_issuer_endpoint end local function parse_proxy_configs(self, proxy_configs) diff --git a/spec/configuration_loader/oidc_spec.lua b/spec/configuration_loader/oidc_spec.lua index 887424603..ccf11f8f5 100644 --- a/spec/configuration_loader/oidc_spec.lua +++ b/spec/configuration_loader/oidc_spec.lua @@ -24,6 +24,17 @@ describe('OIDC Configuration loader', function() assert(loader.call(config)) end) + it('ignores config with oidc_issuer_endpoint but not oidc authentication mode', function() + local config = cjson.encode{ + services = { + { id = 21, proxy = { oidc_issuer_endpoint = 'https://user:pass@example.com' } }, + { id = 42 }, + } + } + + assert(loader.call(config)) + end) + it('forwards all parameters', function() assert.same({'{"oidc":[]}', 'one', 'two'}, { loader.call('{}', 'one', 'two')}) end) @@ -31,7 +42,7 @@ describe('OIDC Configuration loader', function() it('gets openid configuration', function() local config = { services = { - { id = 21, proxy = { oidc_issuer_endpoint = 'https://user:pass@example.com' } }, + { id = 21, proxy = { oidc_issuer_endpoint = 'https://user:pass@example.com', authentication_method = 'oidc' }}, } } @@ -58,7 +69,8 @@ describe('OIDC Configuration loader', function() { "id": 21, "proxy": { - "oidc_issuer_endpoint": "https://user:pass@example.com" + "oidc_issuer_endpoint": "https://user:pass@example.com", + "authentication_method": "oidc" } } ], @@ -97,5 +109,50 @@ describe('OIDC Configuration loader', function() loader.call(cjson.encode(config)) end) + + it('ignore openid configuration if authentication_method is not oidc', function() + local config = { + services = { + { id = 21, proxy = { oidc_issuer_endpoint = 'https://user:pass@example.com', authentication_method = '1' }}, + } + } + + test_backend + .expect{ url = "https://example.com/.well-known/openid-configuration" } + .respond_with{ + status = 200, + headers = { content_type = 'application/json' }, + body = [[{"jwks_uri":"http://example.com/jwks","issuer":"https://example.com"}]], + } + + test_backend + .expect{ url = "http://example.com/jwks" } + .respond_with{ + status = 200, + headers = { content_type = 'application/json' }, + body = [[{"keys":[]}]], + } + + local oidc = loader.call(cjson.encode(config)) + local expected_oidc = cjson.decode([[ + { + "services": [ + { + "id": 21, + "proxy": { + "oidc_issuer_endpoint": "https://user:pass@example.com", + "authentication_method": "1" + } + } + ], + "oidc": [ + { + "service_id": 21 + } + ] + } + ]]) + assert.same(expected_oidc, cjson.decode(oidc)) + end) end) end) diff --git a/spec/configuration_loader/remote_v2_spec.lua b/spec/configuration_loader/remote_v2_spec.lua index 0eee62dd1..a4e9a5806 100644 --- a/spec/configuration_loader/remote_v2_spec.lua +++ b/spec/configuration_loader/remote_v2_spec.lua @@ -251,7 +251,10 @@ describe('Configuration Remote Loader V2', function() environment = 'sandbox', content = { id = 42, backend_version = 1, - proxy = { oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' } + proxy = { + authentication_method= 'oidc', + oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' + } } } } @@ -311,6 +314,28 @@ UwIDAQAB } }, }, config.oidc) end) + + it('ignore OIDC configuration when authentication_method is not oidc', function() + test_backend.expect{ url = 'http://example.com/admin/api/services/42/proxy/configs/staging/latest.json' }. + respond_with{ status = 200, body = cjson.encode( + { + proxy_config = { + version = 2, + environment = 'sandbox', + content = { + id = 42, backend_version = 1, + proxy = { + authentication_method= '1', + oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' + } + } + } + } + ) } + + local config = assert(loader:config({ id = 42 }, 'staging', 'latest')) + assert.is_nil(config.oidc) + end) end) describe(':index_per_service', function() @@ -580,7 +605,10 @@ UwIDAQAB { proxy_config = { content = { - proxy = { oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' } + proxy = { + authentication_method= 'oidc', + oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' + } } } } @@ -730,7 +758,10 @@ UwIDAQAB content = { id = 2, backend_version = 1, - proxy = { oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' } + proxy = { + authentication_method= 'oidc', + oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' + } } } } @@ -920,7 +951,10 @@ UwIDAQAB content = { id = 2, backend_version = 1, - proxy = { oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' } + proxy = { + authentication_method= 'oidc', + oidc_issuer_endpoint = 'http://user:pass@idp.example.com/auth/realms/foo/' + } } } } From 111a2de12090a68fcfa1c2309627171132a8611e Mon Sep 17 00:00:00 2001 From: An Tran Date: Tue, 3 Mar 2026 14:44:04 +1000 Subject: [PATCH 16/18] perf(response_writer): minimize socket:send() calls --- gateway/src/resty/http/response_writer.lua | 30 +-- spec/resty/http/response_writer_spec.lua | 248 +++++++++++++++++++++ 2 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 spec/resty/http/response_writer_spec.lua diff --git a/gateway/src/resty/http/response_writer.lua b/gateway/src/resty/http/response_writer.lua index fc320512d..e1332aa7e 100644 --- a/gateway/src/resty/http/response_writer.lua +++ b/gateway/src/resty/http/response_writer.lua @@ -1,5 +1,7 @@ local fmt = string.format local str_lower = string.lower +local insert = table.insert +local concat = table.concat local _M = { } @@ -32,7 +34,6 @@ end -- write_response writes response body reader to sock in the HTTP/1.x server response format, -- The connection is closed if send() fails or when returning a non-zero function _M.send_response(sock, response, chunksize) - local bytes, err chunksize = chunksize or 65536 if not response then @@ -44,33 +45,32 @@ function _M.send_response(sock, response, chunksize) return nil, "socket not initialized yet" end - -- Status line - -- TODO: get HTTP version from request - local status = fmt("HTTP/%d.%d %03d %s\r\n", 1, 1, response.status, response.reason) - bytes, err = send(sock, status) - if not bytes then - return nil, "failed to send status line, err: " .. (err or "unknown") - end + -- Build status line + headers into a single buffer to minimize send() calls + local buf = { + fmt("HTTP/1.1 %03d %s\r\n", response.status, response.reason) + } -- Filter out hop-by-hop headeres for k, v in pairs(response.headers) do if not HOP_BY_HOP_HEADERS[str_lower(k)] then - local header = fmt("%s: %s\r\n", k, v) - bytes, err = sock:send(header) - if not bytes then - return nil, "failed to send status line, err: " .. (err or "unknown") - end + insert(buf, k .. ": " .. v .. cr_lf) end end -- End-of-header - bytes, err = send(sock, cr_lf) + insert(buf, cr_lf) + + local bytes, err = sock:send(concat(buf)) if not bytes then - return nil, "failed to send status line, err: " .. (err or "unknown") + return nil, "failed to send headers, err: " .. (err or "unknown") end -- Write body local reader = response.body_reader + if not reader then + return nil, "no body reader" + end + repeat local chunk, read_err diff --git a/spec/resty/http/response_writer_spec.lua b/spec/resty/http/response_writer_spec.lua new file mode 100644 index 000000000..cb171c7c5 --- /dev/null +++ b/spec/resty/http/response_writer_spec.lua @@ -0,0 +1,248 @@ +local response_writer = require('resty.http.response_writer') + + +describe('resty.http.response_writer', function() + + local mock_sock + local sent_data + + local function make_response(opts) + opts = opts or {} + local chunks = opts.chunks or { "hello" } + local idx = 0 + return { + status = opts.status or 200, + reason = opts.reason or "OK", + headers = opts.headers or {}, + body_reader = function() + idx = idx + 1 + return chunks[idx] + end + } + end + + before_each(function() + sent_data = {} + mock_sock = { + send = function(_, data) + table.insert(sent_data, data) + return #data, nil + end + } + + stub(ngx, 'log') + end) + + describe('.send_response', function() + + it('returns nil when no response is provided', function() + local ok, err = response_writer.send_response(mock_sock, nil) + assert.is_nil(ok) + assert.is_nil(err) + end) + + it('returns error when no socket is provided', function() + local ok, err = response_writer.send_response(nil, make_response()) + assert.is_nil(ok) + assert.equal("socket not initialized yet", err) + end) + + it('sends status line in first send', function() + local response = make_response({ status = 200, reason = "OK" }) + response_writer.send_response(mock_sock, response) + + assert.truthy(string.find(sent_data[1], "^HTTP/1.1 200 OK\r\n")) + end) + + it('formats different status codes', function() + local response = make_response({ status = 404, reason = "Not Found" }) + response_writer.send_response(mock_sock, response) + + assert.truthy(string.find(sent_data[1], "^HTTP/1.1 404 Not Found\r\n")) + end) + + it('batches status line and headers in a single send', function() + local response = make_response({ + headers = { ["Content-Type"] = "text/plain", ["X-Custom"] = "value" } + }) + response_writer.send_response(mock_sock, response) + + -- First send contains status line + headers + end-of-header CRLF + local header_block = sent_data[1] + assert.truthy(string.find(header_block, "^HTTP/1.1")) + assert.truthy(string.find(header_block, "Content%-Type: text/plain\r\n")) + assert.truthy(string.find(header_block, "X%-Custom: value\r\n")) + assert.truthy(string.find(header_block, "\r\n\r\n$")) + end) + + it('filters hop-by-hop headers', function() + local response = make_response({ + headers = { + ["Connection"] = "keep-alive", + ["Keep-Alive"] = "timeout=5", + ["Transfer-Encoding"] = "chunked", + ["Proxy-Authenticate"] = "Basic", + ["Proxy-Authorization"] = "Basic abc", + ["TE"] = "trailers", + ["Trailers"] = "Expires", + ["Upgrade"] = "websocket", + ["Content-Length"] = "5", + ["X-Custom"] = "value", + } + }) + response_writer.send_response(mock_sock, response) + + local all_sent = table.concat(sent_data) + assert.falsy(string.find(all_sent, "Connection:")) + assert.falsy(string.find(all_sent, "Keep%-Alive:")) + assert.falsy(string.find(all_sent, "Transfer%-Encoding:")) + assert.falsy(string.find(all_sent, "Proxy%-Authenticate:")) + assert.falsy(string.find(all_sent, "Proxy%-Authorization:")) + assert.falsy(string.find(all_sent, "TE:")) + assert.falsy(string.find(all_sent, "Trailers:")) + assert.falsy(string.find(all_sent, "Upgrade:")) + assert.falsy(string.find(all_sent, "Content%-Length:")) + assert.truthy(string.find(all_sent, "X%-Custom: value")) + end) + + it('filters hop-by-hop headers case-insensitively', function() + local response = make_response({ + headers = { + ["CONNECTION"] = "close", + ["KEEP-ALIVE"] = "timeout=5", + } + }) + response_writer.send_response(mock_sock, response) + + local all_sent = table.concat(sent_data) + assert.falsy(string.find(all_sent, "CONNECTION:")) + assert.falsy(string.find(all_sent, "KEEP%-ALIVE:")) + end) + + it('sends end-of-header marker', function() + local response = make_response({ headers = {} }) + response_writer.send_response(mock_sock, response) + + assert.truthy(string.find(sent_data[1], "\r\n\r\n$")) + end) + + it('sends body chunks after headers', function() + local response = make_response({ chunks = { "hello world" } }) + response_writer.send_response(mock_sock, response) + + -- sent_data[1] is headers, sent_data[2] is body chunk + assert.equal("hello world", sent_data[2]) + end) + + it('sends multi-chunk body', function() + local response = make_response({ chunks = { "chunk1", "chunk2", "chunk3" } }) + response_writer.send_response(mock_sock, response) + + assert.equal("chunk1", sent_data[2]) + assert.equal("chunk2", sent_data[3]) + assert.equal("chunk3", sent_data[4]) + end) + + it('returns true on success', function() + local response = make_response() + local ok, err = response_writer.send_response(mock_sock, response) + + assert.is_true(ok) + assert.is_nil(err) + end) + + it('returns error when headers send fails', function() + mock_sock.send = function() return nil, "closed" end + local response = make_response() + local ok, err = response_writer.send_response(mock_sock, response) + + assert.is_nil(ok) + assert.truthy(string.find(err, "failed to send headers")) + end) + + it('returns error when body send fails', function() + local call_count = 0 + mock_sock.send = function(_, data) + call_count = call_count + 1 + if call_count == 1 then + return #data, nil -- headers succeed + end + return nil, "closed" -- body chunk fails + end + + local response = make_response({ chunks = { "hello" } }) + local ok, err = response_writer.send_response(mock_sock, response) + + assert.is_nil(ok) + assert.truthy(string.find(err, "failed to send response body")) + end) + + it('returns error when body reader fails', function() + local response = { + status = 200, + reason = "OK", + headers = {}, + body_reader = function() + return nil, "read error" + end + } + local ok, err = response_writer.send_response(mock_sock, response) + + assert.is_nil(ok) + assert.truthy(string.find(err, "failed to read response body")) + end) + + it('returns error when response has no body_reader', function() + local response = { + status = 200, + reason = "OK", + headers = {}, + } + local ok, err = response_writer.send_response(mock_sock, response) + + assert.is_nil(ok) + assert.equal("no body reader", err) + end) + + it('passes chunksize to body_reader', function() + local received_chunksize + local response = { + status = 200, + reason = "OK", + headers = {}, + body_reader = function(size) + received_chunksize = size + return nil + end + } + response_writer.send_response(mock_sock, response, 1024) + + assert.equal(1024, received_chunksize) + end) + + it('uses default chunksize of 65536', function() + local received_chunksize + local response = { + status = 200, + reason = "OK", + headers = {}, + body_reader = function(size) + received_chunksize = size + return nil + end + } + response_writer.send_response(mock_sock, response) + + assert.equal(65536, received_chunksize) + end) + + it('handles empty body', function() + local response = make_response({ chunks = {} }) + local ok, err = response_writer.send_response(mock_sock, response) + + assert.is_true(ok) + assert.is_nil(err) + end) + end) +end) + From b292d469e6c26996836bd8122c9ba6596da88484 Mon Sep 17 00:00:00 2001 From: An Tran Date: Wed, 4 Mar 2026 14:05:49 +1000 Subject: [PATCH 17/18] perf(http_proxy) small optimization to avoid unnecessary allocation --- gateway/src/apicast/http_proxy.lua | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/gateway/src/apicast/http_proxy.lua b/gateway/src/apicast/http_proxy.lua index 0481b2a9c..1ad5b4d22 100644 --- a/gateway/src/apicast/http_proxy.lua +++ b/gateway/src/apicast/http_proxy.lua @@ -1,7 +1,9 @@ local format = string.format local tostring = tostring +local ngx = ngx local ngx_get_method = ngx.req.get_method local ngx_http_version = ngx.req.http_version +local ngx_req_get_headers = ngx.req.get_headers local resty_url = require "resty.url" local url_helper = require('resty.url_helper') @@ -49,9 +51,10 @@ local function forward_https_request(proxy_uri, uri, proxy_opts) local sock local opts = proxy_opts or {} local req_method = ngx_get_method() - local encoding = ngx.req.get_headers()["Transfer-Encoding"] + local headers = ngx_req_get_headers(0, true) + local encoding = headers["Transfer-Encoding"] local is_chunked = encoding and encoding:lower() == "chunked" - local content_type = ngx.req.get_headers()["Content-Type"] + local content_type = headers["Content-Type"] local content_type_is_urlencoded = content_type and content_type:lower() == "application/x-www-form-urlencoded" local raw = false @@ -123,6 +126,7 @@ local function forward_https_request(proxy_uri, uri, proxy_opts) end ngx.req.set_header("Content-Length", tostring(contentLength)) + headers["Content-Length"] = tostring(contentLength) end end end @@ -131,7 +135,7 @@ local function forward_https_request(proxy_uri, uri, proxy_opts) -- header, otherwise the upstream won't be able to read the body as it expected chunk encoded -- body if is_chunked then - ngx.req.set_header("Transfer-Encoding", nil) + headers["Content-Length"] = nil end end end @@ -140,7 +144,7 @@ local function forward_https_request(proxy_uri, uri, proxy_opts) uri = uri, method = ngx.req.get_method(), headers = ngx.req.get_headers(0, true), - path = format('%s%s%s', ngx.var.uri, ngx.var.is_args, ngx.var.query_string or ''), + path = ngx.var.uri .. ngx.var.is_args .. (ngx.var.query_string or ''), body = body, proxy_uri = proxy_uri, proxy_options = opts @@ -194,7 +198,7 @@ function _M.request(upstream, proxy_uri) local proxy_auth if proxy_uri.user or proxy_uri.password then - proxy_auth = "Basic " .. ngx.encode_base64(concat({ proxy_uri.user or '', proxy_uri.password or '' }, ':')) + proxy_auth = "Basic " .. ngx.encode_base64((proxy_uri.user or '') .. ":" .. (proxy_uri.password or '')) end if uri.scheme == 'http' then -- rewrite the request to use http_proxy From d3ec09caade050a3cc1d6fdade14eb209cc708ef Mon Sep 17 00:00:00 2001 From: An Tran Date: Wed, 4 Mar 2026 14:49:26 +1000 Subject: [PATCH 18/18] perf(http_proxy): stream the response to avoid memory bloat --- gateway/src/apicast/http_proxy.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/src/apicast/http_proxy.lua b/gateway/src/apicast/http_proxy.lua index 1ad5b4d22..56eade611 100644 --- a/gateway/src/apicast/http_proxy.lua +++ b/gateway/src/apicast/http_proxy.lua @@ -169,7 +169,7 @@ local function forward_https_request(proxy_uri, uri, proxy_opts) return sock:send("HTTP/1.1 502 Bad Gateway") end else - httpc:proxy_response(res) + httpc:proxy_response(res, DEFAULT_CHUNKSIZE) httpc:set_keepalive() end else