From 80d41e00de99f69a6aa5c936cc8c0a686d3fc4b7 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 27 Aug 2026 08:12:59 +0800 Subject: [PATCH 1/3] fix(jwe-decrypt): accept JWE tokens that authenticate the protected header RFC 7516 section 5.1 makes the encoded protected header the AES-GCM additional authenticated data, so every JWE library computes the tag over it. The plugin decrypts with no AAD, so a token produced by a compliant library never authenticates and is rejected with 400. Try the RFC 7516 form first and fall back to decrypting without AAD, so tokens generated the way APISIX itself used to generate them keep working. Authenticating the header also makes `kid` tamper-proof for compliant tokens: replacing it now breaks the tag even when the two Consumers share a secret. Also reject a header that asks for an `alg` or `enc` the plugin does not implement, instead of reporting a decryption failure for it. --- apisix/plugins/jwe-decrypt.lua | 30 +++++++- docs/en/latest/plugins/jwe-decrypt.md | 20 +++--- docs/zh/latest/plugins/jwe-decrypt.md | 6 +- t/plugin/jwe-decrypt.t | 98 +++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/apisix/plugins/jwe-decrypt.lua b/apisix/plugins/jwe-decrypt.lua index bdaee8425224..1d15a6052f3b 100644 --- a/apisix/plugins/jwe-decrypt.lua +++ b/apisix/plugins/jwe-decrypt.lua @@ -138,6 +138,17 @@ local function load_jwe_token(jwe_token) end +-- the plugin only implements direct encryption with A256GCM; reject a token +-- that asks for anything else instead of failing later with a decrypt error +local function unsupported_header(header_obj) + if header_obj.alg and header_obj.alg ~= "dir" then + return true + end + + return header_obj.enc and header_obj.enc ~= "A256GCM" +end + + local function jwe_decrypt_with_obj(o, consumer) local secret = get_secret(consumer.auth_conf) if not secret then @@ -160,7 +171,20 @@ local function jwe_decrypt_with_obj(o, consumer) return nil, err end - return aes_default:decrypt(ciphertext, tag) + -- RFC 7516 authenticates the encoded protected header as the AES-GCM + -- additional authenticated data, which is what JWE libraries produce + local decrypted, decrypt_err = aes_default:decrypt(ciphertext, tag, o.header) + if decrypted then + return decrypted + end + + -- tokens built the way APISIX used to build them carry no AAD + local plaintext, legacy_err = aes_default:decrypt(ciphertext, tag) + if not plaintext then + return nil, decrypt_err or legacy_err + end + + return plaintext end @@ -212,6 +236,10 @@ function _M.rewrite(conf, ctx) return 400, { message = "missing kid in JWE token" } end + if unsupported_header(jwe_obj.header_obj) then + return 400, { message = "unsupported alg or enc in JWE token" } + end + local consumer = get_consumer(jwe_obj.header_obj.kid) if not consumer then return 400, { message = "invalid kid in JWE token" } diff --git a/docs/en/latest/plugins/jwe-decrypt.md b/docs/en/latest/plugins/jwe-decrypt.md index 78a8947b3d6e..135090753d97 100644 --- a/docs/en/latest/plugins/jwe-decrypt.md +++ b/docs/en/latest/plugins/jwe-decrypt.md @@ -39,11 +39,11 @@ import TabItem from '@theme/TabItem'; The `jwe-decrypt` Plugin reads a five-part compact token from a request header, selects a [Consumer](../terminology/consumer.md) by the token's `kid`, decrypts the ciphertext with AES-256-GCM, and writes the plaintext to a configured header before proxying the request. You can enable the Plugin on APISIX [Routes](../terminology/route.md) or [Services](../terminology/service.md). -The token resembles [JWE Compact Serialization](https://datatracker.ietf.org/doc/html/rfc7516#section-3.1), but the current Plugin uses a Plugin-specific format. Configure a 32-byte decryption secret on the Consumer. +The token uses [JWE Compact Serialization](https://datatracker.ietf.org/doc/html/rfc7516#section-3.1) with the `dir` key management algorithm and the `A256GCM` content encryption algorithm, so a token produced by a standard JWE library is accepted. Configure a 32-byte decryption secret on the Consumer. :::warning -The current implementation reads `kid` from the decoded header but does not validate the `alg` or `enc` fields and does not use the protected-header segment as AES-GCM additional authenticated data (AAD). Standard RFC 7516 JWE libraries are therefore not directly interoperable. Generate tokens with the exact format described below, use a fixed trusted token generator, and do not treat header fields as authenticated. +For backward compatibility, the Plugin also accepts a token whose ciphertext was encrypted without the protected header as AES-GCM additional authenticated data (AAD), which is how APISIX itself used to generate them. The header of such a token, including its `kid`, is not authenticated. Use a trusted token generator, and prefer a JWE library that follows RFC 7516 so that the header is covered by the AAD. ::: @@ -69,7 +69,7 @@ The decrypted plaintext is forwarded in a request header. For sensitive plaintex | -------------- | ------- | -------- | ------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | | header | string | True | Authorization | | The header to get the token from. | | forward_header | string | True | Authorization | | Name of the header that passes the plaintext to the Upstream. | -| strict | boolean | False | true | | If true, return a 403 error when the encrypted plugin token is missing. If false, continue when the token is not found. | +| strict | boolean | False | true | | If true, return a 403 error when the JWE token is missing. If false, continue when the token is not found. | ## Examples @@ -87,7 +87,7 @@ admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"/ ### Create a Consumer with the Decryption Key -The following example demonstrates how to create a Consumer with the decryption key and generate an encrypted plugin token for it. +The following example demonstrates how to create a Consumer with the decryption key and generate a JWE token for it. Create a Consumer with `jwe-decrypt` and configure the decryption key: @@ -172,13 +172,15 @@ kubectl apply -f jwe-consumer-ic.yaml -To generate a token for the Consumer, encrypt the payload offline with AES-256-GCM without protected-header AAD, using the Consumer secret as the key. Standard RFC 7516 libraries normally authenticate the protected header as AAD and are not directly interoperable with this Plugin. Use the following exact token structure: +To generate a JWE token for the Consumer, use any JWE library that supports direct encryption with `A256GCM`, with the Consumer secret as the key. The token structure is: ```text base64url(header)..base64url(iv).base64url(ciphertext).base64url(tag) ``` -where the header is `{"alg":"dir","enc":"A256GCM","kid":""}`. The fields describe the intended algorithm and identify the Consumer, but the current Plugin does not authenticate or validate them. The IV must be unique and randomly generated for every token; never reuse an IV with the same key. +where the header is `{"alg":"dir","enc":"A256GCM","kid":""}`; `alg` and `enc` are rejected if they are set to anything else. The IV must be unique and randomly generated for every token; never reuse an IV with the same key. + +As [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516#section-5.1) requires, a JWE library authenticates the encoded protected header as the AES-GCM additional authenticated data (AAD), which makes the `kid` tamper-proof. Tokens encrypted without AAD, such as the ones APISIX itself used to generate, are still accepted for backward compatibility. For example, the following token encrypts the payload `{"uid":10000,"uname":"test"}` for the Consumer key `jack-key` with the secret configured above: @@ -186,9 +188,9 @@ For example, the following token encrypts the payload `{"uid":10000,"uname":"tes eyJraWQiOiJqYWNrLWtleSIsImFsZyI6ImRpciIsImVuYyI6IkEyNTZHQ00ifQ..vi29KBCQKcVmPwTT.VToyPMFbq-ZY05MIpntP1N3AmYeq3zELQ0B6iQ.vuTPG2ODc-DjUTjNCzfA2A ``` -### Decrypt Data from the Plugin Token +### Decrypt Data with JWE -The following example demonstrates how to decrypt the plugin token generated above. +The following example demonstrates how to decrypt the JWE token generated above. Create a Route with `jwe-decrypt` to decrypt the authorization header: @@ -320,7 +322,7 @@ kubectl apply -f jwe-decrypt-ic.yaml -Send a request to the Route with the encrypted plugin token in the `Authorization` header: +Send a request to the Route with the JWE encrypted data in the `Authorization` header: ```shell curl "http://127.0.0.1:9080/anything/jwe" -H 'Authorization: eyJraWQiOiJqYWNrLWtleSIsImFsZyI6ImRpciIsImVuYyI6IkEyNTZHQ00ifQ..vi29KBCQKcVmPwTT.VToyPMFbq-ZY05MIpntP1N3AmYeq3zELQ0B6iQ.vuTPG2ODc-DjUTjNCzfA2A' diff --git a/docs/zh/latest/plugins/jwe-decrypt.md b/docs/zh/latest/plugins/jwe-decrypt.md index eb6fcf9b9cd8..b0cbe51e966b 100644 --- a/docs/zh/latest/plugins/jwe-decrypt.md +++ b/docs/zh/latest/plugins/jwe-decrypt.md @@ -160,13 +160,15 @@ kubectl apply -f jwe-consumer-ic.yaml -要为消费者生成 JWE 令牌,可使用任意 AES-256-GCM 库离线加密 payload,加密密钥为消费者的 secret。令牌结构如下: +要为消费者生成 JWE 令牌,可使用任意支持 `A256GCM` 直接加密的 JWE 库,加密密钥为消费者的 secret。令牌结构如下: ```text base64url(header)..base64url(iv).base64url(ciphertext).base64url(tag) ``` -其中 header 为 `{"alg":"dir","enc":"A256GCM","kid":""}`。每个令牌的 IV 必须唯一且随机生成,切勿在同一密钥下复用 IV。 +其中 header 为 `{"alg":"dir","enc":"A256GCM","kid":""}`,`alg` 与 `enc` 若为其他值则会被拒绝。每个令牌的 IV 必须唯一且随机生成,切勿在同一密钥下复用 IV。 + +按 [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516#section-5.1) 的要求,JWE 库会将编码后的 protected header 作为 AES-GCM 的附加认证数据(AAD)参与认证,从而使 `kid` 不可篡改。为保持向后兼容,未使用 AAD 加密的令牌(例如 APISIX 早期自行生成的令牌)仍然可以正常解密。 例如,以下令牌使用上面配置的 secret,为消费者密钥 `jack-key` 加密了 payload `{"uid":10000,"uname":"test"}`: diff --git a/t/plugin/jwe-decrypt.t b/t/plugin/jwe-decrypt.t index 53f407c98244..e9b83f2f1dc6 100644 --- a/t/plugin/jwe-decrypt.t +++ b/t/plugin/jwe-decrypt.t @@ -762,3 +762,101 @@ status: 400 } --- response_body status: 400 + + + +=== TEST 31: RFC 7516 token authenticating the protected header is accepted +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + + -- generated with an independent JWE producer (python cryptography), + -- so the tag covers the encoded protected header as the AES-GCM AAD + local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0." + .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.KaxbSD-kuYBVck03POSk7w" + + local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, + { Authorization = "Bearer " .. token }) + ngx.say("status: ", code) + } + } +--- response_body +status: 200 + + + +=== TEST 32: token without AAD is still accepted +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + + -- same payload, encrypted the way APISIX used to generate tokens + local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0." + .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.rNt131nG5wMvUD1KXbwLGA" + + local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, + { Authorization = "Bearer " .. token }) + ngx.say("status: ", code) + } + } +--- response_body +status: 200 + + + +=== TEST 33: replacing the kid of an RFC 7516 token is rejected +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + + -- the TEST 26 token with its kid changed to another Consumer that + -- happens to share the secret: the tag no longer covers the header + local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoidXNlci1rZXkifQ." + .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.KaxbSD-kuYBVck03POSk7w" + + local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, + { Authorization = "Bearer " .. token }) + ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", ""))) + } + } +--- response_body +status: 400 body: {"message":"failed to decrypt JWE token"} + + + +=== TEST 34: unsupported alg is rejected +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local token = "eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiJqd2UtZmFpbC1rZXkifQ." + .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.7QVBNAw7GFOQRLCtZWtdsA" + + local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, + { Authorization = "Bearer " .. token }) + ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", ""))) + } + } +--- response_body +status: 400 body: {"message":"unsupported alg or enc in JWE token"} + + + +=== TEST 35: unsupported enc is rejected +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0." + .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.99OmOTEx2wPsqhsx0FjM8Q" + + local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, + { Authorization = "Bearer " .. token }) + ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", ""))) + } + } +--- response_body +status: 400 body: {"message":"unsupported alg or enc in JWE token"} From 005d1488acce11dc60e7c21437043a0aa9eb48da Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 27 Aug 2026 16:33:54 +0800 Subject: [PATCH 2/3] test(jwe-decrypt): pin that a header without alg or enc keeps working Only a header naming an algorithm the plugin does not implement is rejected; an absent alg or enc stays accepted, since such tokens decrypt today and nothing in the plugin branches on either field. --- t/plugin/jwe-decrypt.t | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/t/plugin/jwe-decrypt.t b/t/plugin/jwe-decrypt.t index e9b83f2f1dc6..3db8ee1e1e3e 100644 --- a/t/plugin/jwe-decrypt.t +++ b/t/plugin/jwe-decrypt.t @@ -860,3 +860,25 @@ status: 400 body: {"message":"unsupported alg or enc in JWE token"} } --- response_body status: 400 body: {"message":"unsupported alg or enc in JWE token"} + + + +=== TEST 36: token whose header omits alg and enc is still accepted +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + + -- the plugin never read alg or enc before, so a token minted with + -- a minimal header keeps working; only a header naming another + -- algorithm is rejected + local token = "eyJraWQiOiJqd2UtZmFpbC1rZXkifQ." + .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.rNt131nG5wMvUD1KXbwLGA" + + local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, + { Authorization = "Bearer " .. token }) + ngx.say("status: ", code) + } + } +--- response_body +status: 200 From 4121e57c61732b6c697c80417388fbeb2937c3cd Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Mon, 31 Aug 2026 18:12:49 +0800 Subject: [PATCH 3/3] fix(jwe-decrypt): treat a non-string alg or enc as unsupported A JSON false decodes to a Lua false, so the truthiness check let a header carrying "alg": false through the algorithm validation. Compare against nil so the backward compatible path only covers a genuinely absent field. Also make the AAD tamper test point at the token it is derived from and target a Consumer that really shares the secret, so the rejection can only come from the header no longer being authenticated. --- apisix/plugins/jwe-decrypt.lua | 4 +-- t/plugin/jwe-decrypt.t | 53 ++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apisix/plugins/jwe-decrypt.lua b/apisix/plugins/jwe-decrypt.lua index 1d15a6052f3b..3a7a150a1fd9 100644 --- a/apisix/plugins/jwe-decrypt.lua +++ b/apisix/plugins/jwe-decrypt.lua @@ -141,11 +141,11 @@ end -- the plugin only implements direct encryption with A256GCM; reject a token -- that asks for anything else instead of failing later with a decrypt error local function unsupported_header(header_obj) - if header_obj.alg and header_obj.alg ~= "dir" then + if header_obj.alg ~= nil and header_obj.alg ~= "dir" then return true end - return header_obj.enc and header_obj.enc ~= "A256GCM" + return header_obj.enc ~= nil and header_obj.enc ~= "A256GCM" end diff --git a/t/plugin/jwe-decrypt.t b/t/plugin/jwe-decrypt.t index 3db8ee1e1e3e..deb68a3fb2c0 100644 --- a/t/plugin/jwe-decrypt.t +++ b/t/plugin/jwe-decrypt.t @@ -551,6 +551,26 @@ fo4XKdZ1xSrIZyms4q2BwPrW5lMpls9qqy5tiAk2esc= return end + -- shares the secret of jwe_fail_user, so swapping a token kid to + -- this consumer isolates the AAD check from a key mismatch + code = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "jwe_fail_twin", + "plugins": { + "jwe-decrypt": { + "key": "jwe-fail-key-twin", + "secret": "12345678901234567890123456789012" + } + } + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say("failed to add consumer") + return + end + code = t('/apisix/admin/routes/10', ngx.HTTP_PUT, [[{ @@ -812,9 +832,10 @@ status: 200 content_by_lua_block { local t = require("lib.test_admin").test - -- the TEST 26 token with its kid changed to another Consumer that - -- happens to share the secret: the tag no longer covers the header - local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoidXNlci1rZXkifQ." + -- the TEST 31 token with its kid changed to jwe-fail-key-twin, + -- which holds the same secret: decryption can only fail because + -- the tag no longer covers the header + local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5LXR3aW4ifQ." .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.KaxbSD-kuYBVck03POSk7w" local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, @@ -882,3 +903,29 @@ status: 400 body: {"message":"unsupported alg or enc in JWE token"} } --- response_body status: 200 + + + +=== TEST 37: header whose alg is a JSON false is rejected +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local core = require("apisix.core") + local enc = require("ngx.base64").encode_base64url + + -- a present but non-string alg is not an omitted alg, so the + -- backward compatible path must not swallow it + local header = enc(core.json.encode({ + alg = false, enc = "A256GCM", kid = "jwe-fail-key", + })) + local token = header .. ".." .. enc("123456789012") .. "." + .. enc("undecryptable") .. "." .. enc("0123456789abcdef") + + local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil, + { Authorization = "Bearer " .. token }) + ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", ""))) + } + } +--- response_body +status: 400 body: {"message":"unsupported alg or enc in JWE token"}