fix(json): make cjson instances inherit APISIX's cjson options#13680
Conversation
…serinfo
The shared cjson instance in lua-resty-session decodes JSON without the
array metatable, so an empty array loses its array identity across the
session save/load cycle and is re-encoded as an object. With the
openid-connect plugin this turns empty userinfo claims such as `roles`
into `{}`, and `X-Userinfo` carries `"roles":{}` instead of `"roles":[]`.
Switch to api7-lua-resty-session 4.1.6-0, which enables
decode_array_with_array_mt on that instance.
lua-resty-openidc and lua-resty-saml still depend on the upstream
lua-resty-session, so it is still pulled in transitively, but
api7-lua-resty-session sorts before it and therefore owns the
resty.session.* module paths -- the same arrangement already used for
api7-lua-resty-http and api7-lua-resty-jwt.
Fixes apache#13440
…nd-trip Reproduces the apache#13440 chain without an IdP: core.json.decode (as openidc parses userinfo) -> session encode/decode -> core.json.encode (as the plugin builds X-Userinfo). With the old lua-resty-session the last step yields `{}`; the test therefore fails if the dependency is reverted.
…ycle
Replace the direct session_utils check with an end-to-end test: a request
is routed through the openid-connect plugin to an upstream that echoes the
headers it received, and the test asserts the empty `roles` claim arrives
in X-Userinfo as `[]`.
The session is forged with the plugin's own secret so the plugin takes the
already-authenticated path and restores the userinfo from the session --
the same path every request takes after the callback, and the one where
the empty array used to be lost. A non-expired access token plus
renew_access_token_on_expiry=false keep openidc from reaching for the
discovery document, so no IdP is needed.
Against the old lua-resty-session the upstream receives `"roles":{}`.
|
Stepping back a bit: the root cause here is a pattern, not just lua-resty-session. lua-cjson options are per-instance, and So instead of chasing each library one by one (fork, pin, re-audit every new dependency), we could fix this globally in Quick check that the wrap works: local cjson_safe = require("cjson.safe")
local orig_new = cjson_safe.new
cjson_safe.new = function(...)
local inst = orig_new(...)
inst.decode_array_with_array_mt(true)
return inst
end
-- later, inside a dependency:
local dep = require("cjson.safe").new()
dep.encode(dep.decode('{"items":[]}')) --> {"items":[]}The trade-off is that it's a behavior change for all decoded arrays across every dependency (they now carry The upstream fix (bungle/lua-resty-session#207) is still worth pursuing either way. What do you think? Happy to submit a PR for the patch.lua approach if this direction makes sense. |
|
Agreed — your framing is the right one, and it supersedes this PR. I verified your claims against the current deps tree: (C) is the one that settles it — with the wrap in place, the stock lua-resty-session already round-trips correctly, so the fork buys nothing. And the private-instance list matches what you found: |
|
One more data point on the patch.lua idea: In the lua-cjson bundled by apisix-runtime (2.1.0.11), that setter mutates a shared static escape table, not per-instance config. I verified it: calling it on the But upstream changed this on 2026-06-14 (openresty/lua-cjson: "setting encode_escape_forward_slash on a cjson instance does not affect other instances" — So the patch.lua wrapper should set both options. For With that, patch.lua becomes the single source of truth for process-wide cjson defaults, and the two option lines in |
dece883
1ce0dd8 to
dece883
Compare
lua-cjson options are per-instance and cjson.new() starts from the compile-time defaults, so enabling decode_array_with_array_mt on the cjson.safe singleton in core/json.lua never reaches a dependency that keeps a private instance. Those dependencies decode arrays without the array metatable, and an empty array is re-encoded as an object. Enable the options on both cjson singletons and wrap cjson.new / cjson.safe.new so instances created afterwards inherit them. This replaces the api7-lua-resty-session dependency swap: the fix now covers every dependency with a private instance (lua-resty-healthcheck, lua-resty-worker-events, lua-resty-aws, ...), not just lua-resty-session. encode_escape_forward_slash is set as well. It looks redundant today because the bundled lua-cjson mutates a shared escape table, so setting it anywhere already applies process-wide -- but upstream has made it per-instance, and 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. Fixes apache#13440
dece883 to
e345c6b
Compare
Description
Fixes #13440
lua-cjsonoptions are per-instance, andcjson.new()starts from the compile-time defaults rather than inheriting whatever was set on the module singleton.core/json.luaonly configures thecjson.safesingleton, so every dependency holding a private instance escapes it —lua-resty-session,lua-resty-healthcheck,lua-resty-worker-eventsandlua-resty-awsall keep one, and the plaincjsonsingleton is a separate config that had no options set either.That is what corrupts OIDC userinfo.
openid-connectstores userinfo in the session and reads it back on later requests:cjson.safesingletonrolesgetsarray_mt✅[]✅array_mtlost ❌core.json.encode→X-Userinfo{}❌The array gets in but cannot get out:
array_mtis honoured on encode by any instance, but whether decode attaches it depends on that instance’s own flag.Fix
Set the options on both cjson singletons and wrap
cjson.new/cjson.safe.newso every instance created afterwards inherits them.init.luarunspatch()before any dependency is loaded, and these instances are created lazily on first use, so the wrapped factory catches all of them. Only the default changes — a library that sets an option explicitly on its own instance still wins.This replaces the earlier
api7-lua-resty-sessiondependency swap: rather than forking each escaping library in turn, it fixes the whole class, and the next dependency with a private instance just works.encode_escape_forward_slashis set too. It looks redundant today because in the bundled lua-cjson (2.1.0.11) that setter mutates a shared escape table, so setting it anywhere already applies process-wide — I verified an instance created before the call is retroactively affected. But upstream has made it per-instance (openresty/lua-cjson@fa418dc, 2026-06-14), 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.Approach suggested by @nic-6443.
Verification
With the stock
lua-resty-session(no fork), against the real test-nginx harness:t/core/json.t— new TEST 9/10 cover both options across thecjson.safeinstance, the plaincjsoninstance and the plain singleton.t/plugin/openid-connect-userinfo-array.t— plugin-level e2e: forges an authenticated session with the plugin secret, drives a request through routing →openid-connect→ upstream (which echoes the headers it received), and asserts the emptyrolesclaim arrives inX-Userinfoas[]. No IdP needed: the fix only manifests on the session load path, which every request after the callback takes.Both fail against the unpatched tree (
got: {}/expected: []) and pass with the patch.Note on scope
array_mtnow attaches to arrays decoded by every dependency. The hazard to watch for in a full-suite run: code that decodes a JSON array (especially an empty one) and then assigns string keys to that same table — it used to re-encode as an object, and would now encode as[], silently dropping the keys. Read-only use (pairs,#,next) is unaffected.