This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 6c7b56a9b1 fix(json): make cjson instances inherit APISIX's cjson 
options (#13680)
6c7b56a9b1 is described below

commit 6c7b56a9b19ebe56f09d8eaa43380362c752af98
Author: AlinsRan <[email protected]>
AuthorDate: Wed Jul 15 13:29:41 2026 +0800

    fix(json): make cjson instances inherit APISIX's cjson options (#13680)
---
 apisix/core/json.lua                     |   2 -
 apisix/patch.lua                         |  33 ++++++++
 t/core/json.t                            |  48 +++++++++++
 t/plugin/openid-connect-userinfo-array.t | 140 +++++++++++++++++++++++++++++++
 4 files changed, 221 insertions(+), 2 deletions(-)

diff --git a/apisix/core/json.lua b/apisix/core/json.lua
index 9418917b35..a7ce18ae38 100644
--- a/apisix/core/json.lua
+++ b/apisix/core/json.lua
@@ -38,8 +38,6 @@ local rapidjson_null
 local rapidjson_encode_opts = { sort_keys = true }
 
 
-cjson.encode_escape_forward_slash(false)
-cjson.decode_array_with_array_mt(true)
 local _M = {
     version = 0.1,
     array_mt = cjson.array_mt,
diff --git a/apisix/patch.lua b/apisix/patch.lua
index f8d504b74a..31383b7701 100644
--- a/apisix/patch.lua
+++ b/apisix/patch.lua
@@ -364,7 +364,40 @@ local function luasocket_tcp()
 end
 
 
+-- lua-cjson options are per-instance, and cjson.new() always starts from the
+-- compile-time defaults instead of inheriting the singleton's configuration.
+-- core/json.lua only enables decode_array_with_array_mt on the `cjson.safe`
+-- singleton, so every dependency holding a private instance 
(lua-resty-session,
+-- lua-resty-healthcheck, lua-resty-worker-events, lua-resty-aws, ...) decodes
+-- arrays without the array metatable, and an empty array is then re-encoded as
+-- an object. Enable the option on both singletons and let every instance 
created
+-- afterwards inherit it. This only changes the default: a library explicitly
+-- calling decode_array_with_array_mt(false) on its own instance still wins.
+--
+-- encode_escape_forward_slash is set here as well. It looks redundant today,
+-- because in the bundled lua-cjson that setter mutates a shared escape table 
and
+-- therefore already applies process-wide, including to instances created 
before
+-- it was called. Upstream has since made it per-instance, so once a runtime
+-- upgrade picks that up, every private instance would silently go back to
+-- escaping `/`. Setting it here pins the current behaviour instead of relying 
on
+-- that implementation detail.
+local function patch_cjson(cjson)
+    cjson.decode_array_with_array_mt(true)
+    cjson.encode_escape_forward_slash(false)
+
+    local original_new = cjson.new
+    cjson.new = function (...)
+        local instance = original_new(...)
+        patch_cjson(instance)
+        return instance
+    end
+end
+
+
 function _M.patch()
+    patch_cjson(require("cjson"))
+    patch_cjson(require("cjson.safe"))
+
     -- make linter happy
     -- luacheck: ignore
     ngx_socket.tcp = function ()
diff --git a/t/core/json.t b/t/core/json.t
index 1409f54f1e..68fc3675cf 100644
--- a/t/core/json.t
+++ b/t/core/json.t
@@ -193,3 +193,51 @@ b.d: 1
 e: text
 a or default: default
 top-level null: nil
+
+
+
+=== TEST 9: cjson instances created by dependencies keep empty arrays as arrays
+--- config
+    location /t {
+        content_by_lua_block {
+            -- a dependency that keeps a private instance, e.g. 
lua-resty-session
+            local safe_instance = require("cjson.safe").new()
+            ngx.say("cjson.safe instance: ",
+                    safe_instance.encode(safe_instance.decode('{"arr":[]}')))
+
+            local instance = require("cjson").new()
+            ngx.say("cjson instance: ", 
instance.encode(instance.decode('{"arr":[]}')))
+
+            -- the plain singleton is a separate config from cjson.safe
+            local cjson = require("cjson")
+            ngx.say("cjson singleton: ", 
cjson.encode(cjson.decode('{"arr":[]}')))
+        }
+    }
+--- response_body
+cjson.safe instance: {"arr":[]}
+cjson instance: {"arr":[]}
+cjson singleton: {"arr":[]}
+
+
+
+=== TEST 10: cjson instances created by dependencies do not escape forward 
slashes
+--- config
+    location /t {
+        content_by_lua_block {
+            -- today the bundled lua-cjson keeps a shared escape table, so this
+            -- already holds; the patch pins it for when the option becomes
+            -- per-instance upstream
+            local safe_instance = require("cjson.safe").new()
+            ngx.say("cjson.safe instance: ", safe_instance.encode({uri = 
"/a/b"}))
+
+            local instance = require("cjson").new()
+            ngx.say("cjson instance: ", instance.encode({uri = "/a/b"}))
+
+            local cjson = require("cjson")
+            ngx.say("cjson singleton: ", cjson.encode({uri = "/a/b"}))
+        }
+    }
+--- response_body
+cjson.safe instance: {"uri":"/a/b"}
+cjson instance: {"uri":"/a/b"}
+cjson singleton: {"uri":"/a/b"}
diff --git a/t/plugin/openid-connect-userinfo-array.t 
b/t/plugin/openid-connect-userinfo-array.t
new file mode 100644
index 0000000000..ce91e1d80e
--- /dev/null
+++ b/t/plugin/openid-connect-userinfo-array.t
@@ -0,0 +1,140 @@
+#
+# 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';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+        $block->set_value("no_error_log", "[error]");
+    }
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set up a route guarded by openid-connect with set_userinfo_header
+--- 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": {
+                            "openid-connect": {
+                                "client_id": 
"kbyuFDidLLm280LIwVFiazOqjO3ty8KH",
+                                "client_secret": 
"60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa",
+                                "discovery": 
"http://127.0.0.1:1980/.well-known/openid-configuration";,
+                                "redirect_uri": 
"http://127.0.0.1:1984/callback";,
+                                "ssl_verify": false,
+                                "use_pkce": false,
+                                "set_userinfo_header": true,
+                                "renew_access_token_on_expiry": false,
+                                "session": {
+                                    "secret": 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK"
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/uri"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: an empty userinfo claim reaches the upstream as an array
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require("resty.http")
+            local session = require("resty.session")
+            local core = require("apisix.core")
+
+            -- Forge the session openid-connect writes after a successful 
login,
+            -- so the plugin takes the "already authenticated" path and 
restores
+            -- the userinfo from the session, exactly as it does on every 
request
+            -- after the callback. No IdP is contacted: with a non-expired 
access
+            -- token and renew_access_token_on_expiry disabled, openidc never
+            -- reaches for the discovery document.
+            local s = session.new({ secret = 
"jwcE5v3pM9VhqLxmxFOH9uZaLo8u7KQK" })
+            s:set("authenticated", true)
+            s:set("last_authenticated", ngx.time())
+            s:set("id_token", { sub = "a UID" })
+            s:set("access_token", "fake-access-token")
+            s:set("access_token_expiration", ngx.time() + 3600)
+            s:set("user", core.json.decode(
+                '{"sub":"a UID","name":"Testuser One","roles":[]}'))
+
+            local ok, err = s:save()
+            if not ok then
+                ngx.say("failed to save session: ", err)
+                return
+            end
+
+            local cookie = ngx.header["Set-Cookie"]
+            if type(cookie) == "table" then
+                cookie = cookie[1]
+            end
+            ngx.header["Set-Cookie"] = nil
+
+            local httpc = http.new()
+            local res, err = httpc:request_uri("http://127.0.0.1:1984/uri";, {
+                headers = { Cookie = cookie },
+            })
+            if not res then
+                ngx.say("request failed: ", err)
+                return
+            end
+
+            -- the upstream echoes back the request headers it received
+            local encoded = res.body:match("x%-userinfo: ([%w+/=]+)")
+            if not encoded then
+                ngx.say("no X-Userinfo reached the upstream")
+                return
+            end
+
+            local userinfo = ngx.decode_base64(encoded)
+            ngx.say(core.json.encode(core.json.decode(userinfo).roles))
+        }
+    }
+--- response_body
+[]

Reply via email to