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

shreemaan-abhishek 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 dc7a8d460 feat(cas-auth): sign request URI cookie and tighten cookie 
attributes (#13331)
dc7a8d460 is described below

commit dc7a8d460fb17dd8ccd3731df8ca2f8998fd7f01
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Thu May 21 14:20:48 2026 +0800

    feat(cas-auth): sign request URI cookie and tighten cookie attributes 
(#13331)
---
 apisix/plugins/cas-auth.lua        | 148 ++++++++++++++++++++++++++++------
 docs/en/latest/plugins/cas-auth.md |  20 +++--
 t/lib/keycloak_cas.lua             |   4 +
 t/plugin/cas-auth.t                | 161 +++++++++++++++++++++++++++++++++++++
 t/plugin/security-warning.t        |  10 ++-
 5 files changed, 309 insertions(+), 34 deletions(-)

diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua
index d949636f5..7466b080d 100644
--- a/apisix/plugins/cas-auth.lua
+++ b/apisix/plugins/cas-auth.lua
@@ -16,12 +16,15 @@
 ----
 local core = require("apisix.core")
 local http = require("resty.http")
+local openssl_mac = require("resty.openssl.mac")
+local bit = require("bit")
 local ngx = ngx
 local ngx_re_match = ngx.re.match
+local ngx_encode_base64 = ngx.encode_base64
+local ngx_decode_base64 = ngx.decode_base64
 
 local CAS_REQUEST_URI = "CAS_REQUEST_URI"
 local COOKIE_NAME = "CAS_SESSION"
-local COOKIE_PARAMS = "; Path=/; HttpOnly"
 local SESSION_LIFETIME = 3600
 local STORE_NAME = "cas_sessions"
 
@@ -35,9 +38,19 @@ local schema = {
         idp_uri = {type = "string"},
         cas_callback_uri = {type = "string"},
         logout_uri = {type = "string"},
+        cookie = {
+            type = "object",
+            properties = {
+                secret = {type = "string", minLength = 32},
+                secure = {type = "boolean", default = true},
+                samesite = {type = "string", enum = {"Lax", "None"}, default = 
"Lax"},
+            },
+            required = {"secret"},
+        },
     },
+    encrypt_fields = {"cookie.secret"},
     required = {
-        "idp_uri", "cas_callback_uri", "logout_uri"
+        "idp_uri", "cas_callback_uri", "logout_uri", "cookie"
     }
 }
 
@@ -45,13 +58,34 @@ local _M = {
     version = 0.1,
     priority = 2597,
     name = plugin_name,
-    schema = schema
+    schema = schema,
 }
 
+local function cookie_attrs(conf)
+    -- core.schema.check() validates but does not apply JSONSchema defaults, so
+    -- conf.cookie.secure/samesite may be nil at runtime. Default defensively.
+    local secure = conf.cookie.secure ~= false
+    local samesite = conf.cookie.samesite or "Lax"
+    local attrs = "; Path=/; HttpOnly"
+    if secure then
+        attrs = attrs .. "; Secure"
+    end
+    attrs = attrs .. "; SameSite=" .. samesite
+    return attrs
+end
+
 function _M.check_schema(conf)
     local check = {"idp_uri"}
     core.utils.check_https(check, conf, plugin_name)
-    return core.schema.check(schema, conf)
+    local ok, err = core.schema.check(schema, conf)
+    if not ok then
+        return false, err
+    end
+    if conf.cookie.samesite == "None" and conf.cookie.secure == false then
+        return false,
+            "cookie.secure must be true when cookie.samesite is \"None\""
+    end
+    return true
 end
 
 local function uri_without_ticket(conf, ctx)
@@ -63,41 +97,99 @@ local function get_session_id(ctx)
     return ctx.var["cookie_" .. COOKIE_NAME]
 end
 
-local function set_our_cookie(name, val)
-    core.response.add_header("Set-Cookie", name .. "=" .. val .. COOKIE_PARAMS)
+local function set_our_cookie(conf, name, val)
+    core.response.add_header("Set-Cookie", name .. "=" .. val .. 
cookie_attrs(conf))
+end
+
+local function compute_hmac(secret, val)
+    local m, err = openssl_mac.new(secret, "HMAC", nil, "sha256")
+    if not m then return nil, err end
+    return m:final(val)
+end
+
+local function eq_const_time(a, b)
+    if #a ~= #b then return false end
+    local diff = 0
+    for i = 1, #a do
+        diff = bit.bor(diff, bit.bxor(a:byte(i), b:byte(i)))
+    end
+    return diff == 0
+end
+
+local function sign_value(secret, val)
+    local sig, err = compute_hmac(secret, val)
+    if not sig then
+        core.log.error("cas-auth: hmac sign failed: ", err)
+        return nil
+    end
+    return ngx_encode_base64(val, true) .. "." .. ngx_encode_base64(sig, true)
+end
+
+local function verify_value(secret, signed)
+    if not signed then return nil end
+    local dot = signed:find(".", 1, true)
+    if not dot then return nil end
+    local val = ngx_decode_base64(signed:sub(1, dot - 1))
+    local sig = ngx_decode_base64(signed:sub(dot + 1))
+    if not val or not sig then return nil end
+    local expected, err = compute_hmac(secret, val)
+    if not expected then
+        core.log.error("cas-auth: hmac verify failed: ", err)
+        return nil
+    end
+    if not eq_const_time(sig, expected) then return nil end
+    return val
 end
 
+local function is_safe_redirect(uri)
+    if not uri or uri == "" then return false end
+    if uri:sub(1, 1) ~= "/" then return false end
+    if uri:sub(1, 2) == "//" then return false end
+    if uri:find("\\", 1, true) then return false end
+    if uri:find("[\r\n]") then return false end
+    return true
+end
+
+-- Exposed for unit tests; not part of the plugin's public API.
+_M._test_helpers = {
+    sign_value = sign_value,
+    verify_value = verify_value,
+    is_safe_redirect = is_safe_redirect,
+}
+
 local function first_access(conf, ctx)
     local login_uri = conf.idp_uri .. "/login?" ..
         ngx.encode_args({ service = uri_without_ticket(conf, ctx) })
-    core.log.info("first access: ", login_uri,
-        ", cookie: ", ctx.var.http_cookie, ", request_uri: ", 
ctx.var.request_uri)
-    set_our_cookie(CAS_REQUEST_URI, ctx.var.request_uri)
+    core.log.info("cas-auth: redirecting unauthenticated request to IdP")
+    local signed = sign_value(conf.cookie.secret, ctx.var.request_uri)
+    if signed then
+        set_our_cookie(conf, CAS_REQUEST_URI, signed)
+    end
     core.response.set_header("Location", login_uri)
     return ngx.HTTP_MOVED_TEMPORARILY
 end
 
 local function with_session_id(conf, ctx, session_id)
     -- does the cookie exist in our store?
-    local user = store:get(session_id);
-    core.log.info("ticket=", session_id, ", user=", user)
+    local user = store:get(session_id)
     if user == nil then
-        set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0")
+        set_our_cookie(conf, COOKIE_NAME, "deleted; Max-Age=0")
         return first_access(conf, ctx)
     else
         -- refresh the TTL
         store:set(session_id, user, SESSION_LIFETIME)
+        core.log.info("cas-auth: session refreshed")
     end
 end
 
-local function set_store_and_cookie(session_id, user)
+local function set_store_and_cookie(conf, session_id, user)
     -- place cookie into cookie store
     local success, err, forcible = store:add(session_id, user, 
SESSION_LIFETIME)
     if success then
         if forcible then
             core.log.info("CAS cookie store is out of memory")
         end
-        set_our_cookie(COOKIE_NAME, session_id)
+        set_our_cookie(conf, COOKIE_NAME, session_id)
     else
         if err == "no memory" then
             core.log.emerg("CAS cookie store is out of memory")
@@ -119,12 +211,12 @@ local function validate(conf, ctx, ticket)
 
     if res and res.status == ngx.HTTP_OK and res.body ~= nil then
         if core.string.find(res.body, "<cas:authenticationSuccess>") then
-            local m = ngx_re_match(res.body, "<cas:user>(.*?)</cas:user>", 
"jo");
+            local m = ngx_re_match(res.body, "<cas:user>(.*?)</cas:user>", 
"jo")
             if m then
                 return m[1]
             end
         else
-            core.log.info("CAS serviceValidate failed: ", res.body)
+            core.log.info("CAS serviceValidate did not return 
authenticationSuccess")
         end
     else
         core.log.error("validate ticket failed: status=", (res and res.status),
@@ -135,11 +227,15 @@ end
 
 local function validate_with_cas(conf, ctx, ticket)
     local user = validate(conf, ctx, ticket)
-    if user and set_store_and_cookie(ticket, user) then
-        local request_uri = ctx.var["cookie_" .. CAS_REQUEST_URI]
-        set_our_cookie(CAS_REQUEST_URI, "deleted; Max-Age=0")
-        core.log.info("ticket: ", ticket,
-            ", cookie: ", ctx.var.http_cookie, ", request_uri: ", request_uri, 
", user=", user)
+    if user and set_store_and_cookie(conf, ticket, user) then
+        local request_uri = verify_value(conf.cookie.secret,
+            ctx.var["cookie_" .. CAS_REQUEST_URI])
+        set_our_cookie(conf, CAS_REQUEST_URI, "deleted; Max-Age=0")
+        if not is_safe_redirect(request_uri) then
+            core.log.warn("cas-auth: rejected unsafe redirect target, falling 
back to /")
+            request_uri = "/"
+        end
+        core.log.info("cas-auth: validation succeeded for user=", user)
         core.response.set_header("Location", request_uri)
         return ngx.HTTP_MOVED_TEMPORARILY
     else
@@ -153,9 +249,9 @@ local function logout(conf, ctx)
         return ngx.HTTP_UNAUTHORIZED
     end
 
-    core.log.info("logout: ticket=", session_id, ", cookie=", 
ctx.var.http_cookie)
+    core.log.info("cas-auth: logout invoked")
     store:delete(session_id)
-    set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0")
+    set_our_cookie(conf, COOKIE_NAME, "deleted; Max-Age=0")
 
     core.response.set_header("Location", conf.idp_uri .. "/logout")
     return ngx.HTTP_MOVED_TEMPORARILY
@@ -176,12 +272,12 @@ function _M.access(conf, ctx)
             return ngx.HTTP_BAD_REQUEST,
                 {message = "invalid logout request from IdP, no ticket"}
         end
-        core.log.info("Back-channel logout (SLO) from IdP: LogoutRequest: ", 
data)
+        core.log.info("cas-auth: SLO request received from IdP")
         local session_id = ticket
-        local user = store:get(session_id);
+        local user = store:get(session_id)
         if user then
             store:delete(session_id)
-            core.log.info("SLO: user=", user, ", tocket=", ticket)
+            core.log.info("cas-auth: SLO session deleted for user=", user)
         end
     else
         local session_id = get_session_id(ctx)
diff --git a/docs/en/latest/plugins/cas-auth.md 
b/docs/en/latest/plugins/cas-auth.md
index 37d23b068..0078e5eab 100644
--- a/docs/en/latest/plugins/cas-auth.md
+++ b/docs/en/latest/plugins/cas-auth.md
@@ -35,11 +35,15 @@ to do authentication, from the SP (service provider) 
perspective.
 
 ## Attributes
 
-| Name      | Type | Required      | Description |
-| ----------- | ----------- | ----------- | ----------- |
-| `idp_uri`      | string       | True      | URI of IdP.       |
-| `cas_callback_uri`      | string       | True      | redirect uri used to 
callback the SP from IdP after login or logout.       |
-| `logout_uri`      | string       | True      | logout uri to trigger logout. 
      |
+| Name      | Type | Required      | Default | Description |
+| ----------- | ----------- | ----------- | ----------- | ----------- |
+| `idp_uri`      | string       | True      | | URI of IdP.       |
+| `cas_callback_uri`      | string       | True      | | redirect uri used to 
callback the SP from IdP after login or logout.       |
+| `logout_uri`      | string       | True      | | logout uri to trigger 
logout.       |
+| `cookie`      | object       | True      | | configuration for the cookies 
the plugin issues during the CAS login flow. |
+| `cookie.secret`      | string       | True      | | secret (32+ characters) 
used to sign the request URI cookie. The same value must be configured on every 
APISIX node. Generate with e.g. `openssl rand -base64 48`.       |
+| `cookie.secure`      | boolean       | False      | `true` | whether to set 
the `Secure` attribute on the issued cookies. Set to `false` only for 
deployments where the protected route is not served over HTTPS (e.g. 
internal-only or development environments).       |
+| `cookie.samesite`      | string       | False      | `"Lax"` | value for the 
`SameSite` cookie attribute. Allowed values are `"Lax"` and `"None"`; 
`"Strict"` is intentionally not supported because it breaks the IdP→SP redirect 
when the IdP is on a different site.       |
 
 ## Enable Plugin
 
@@ -64,7 +68,11 @@ curl http://127.0.0.1:9180/apisix/admin/routes/cas1 -H 
"X-API-KEY: $admin_key" -
           "cas-auth": {
               "idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas";,
               "cas_callback_uri": "/anything/cas_callback",
-              "logout_uri": "/anything/logout"
+              "logout_uri": "/anything/logout",
+              "cookie": {
+                  "secret": "please-replace-with-a-32+-char-random-secret",
+                  "secure": false
+              }
           }
     },
     "upstream": {
diff --git a/t/lib/keycloak_cas.lua b/t/lib/keycloak_cas.lua
index 7e578014c..a24be01c3 100644
--- a/t/lib/keycloak_cas.lua
+++ b/t/lib/keycloak_cas.lua
@@ -22,6 +22,10 @@ local default_opts = {
     idp_uri = "http://127.0.0.1:8080/realms/test/protocol/cas";,
     cas_callback_uri = "/cas_callback",
     logout_uri = "/logout",
+    cookie = {
+        secret = "0123456789abcdef0123456789abcdef",
+        secure = false,
+    },
 }
 
 function _M.get_default_opts()
diff --git a/t/plugin/cas-auth.t b/t/plugin/cas-auth.t
index 4a2bfe7e8..c7d8839a2 100644
--- a/t/plugin/cas-auth.t
+++ b/t/plugin/cas-auth.t
@@ -221,3 +221,164 @@ passed
             assert(res.status == 200)
         }
     }
+
+
+
+=== TEST 5: schema rejects missing cookie.secret
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.cas-auth")
+            local ok, err = plugin.check_schema({
+                idp_uri = "http://127.0.0.1:8080";,
+                cas_callback_uri = "/cas_callback",
+                logout_uri = "/logout",
+                cookie = {},
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like
+.*property "secret" is required.*
+
+
+
+=== TEST 6: schema rejects cookie.secret shorter than 32 chars
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.cas-auth")
+            local ok, err = plugin.check_schema({
+                idp_uri = "http://127.0.0.1:8080";,
+                cas_callback_uri = "/cas_callback",
+                logout_uri = "/logout",
+                cookie = { secret = "tooshort" },
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like
+.*string too short.*
+
+
+
+=== TEST 7: schema rejects cookie.samesite=Strict
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.cas-auth")
+            local ok, err = plugin.check_schema({
+                idp_uri = "http://127.0.0.1:8080";,
+                cas_callback_uri = "/cas_callback",
+                logout_uri = "/logout",
+                cookie = {
+                    secret = "0123456789abcdef0123456789abcdef",
+                    samesite = "Strict",
+                },
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like
+.*samesite.*
+
+
+
+=== TEST 8: schema rejects samesite=None with secure=false
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.cas-auth")
+            local ok, err = plugin.check_schema({
+                idp_uri = "http://127.0.0.1:8080";,
+                cas_callback_uri = "/cas_callback",
+                logout_uri = "/logout",
+                cookie = {
+                    secret = "0123456789abcdef0123456789abcdef",
+                    samesite = "None",
+                    secure = false,
+                },
+            })
+            ngx.say(ok and "passed" or err)
+        }
+    }
+--- response_body_like
+.*cookie.secure must be true when cookie.samesite is "None".*
+
+
+
+=== TEST 9: is_safe_redirect rejects external and protocol-relative URLs
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.cas-auth")
+            local h = plugin._test_helpers
+            local cases = {
+                {"/foo",                 true},
+                {"/foo?bar=baz",         true},
+                {"https://evil.com/x";,   false},
+                {"//evil.com/x",         false},
+                {"\\\\evil.com",         false},
+                {"/foo\r\nLocation: x", false},
+                {"",                     false},
+                {nil,                    false},
+            }
+            for _, c in ipairs(cases) do
+                local got = h.is_safe_redirect(c[1])
+                if got ~= c[2] then
+                    ngx.say("FAIL ", tostring(c[1]), " expected ", 
tostring(c[2]),
+                            " got ", tostring(got))
+                    return
+                end
+            end
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 10: sign and verify roundtrip + tamper detection
+--- config
+    location /t {
+        content_by_lua_block {
+            local plugin = require("apisix.plugins.cas-auth")
+            local h = plugin._test_helpers
+            local secret = "0123456789abcdef0123456789abcdef"
+            local signed = h.sign_value(secret, "/foo?bar=baz")
+            assert(signed, "sign_value returned nil")
+
+            local got = h.verify_value(secret, signed)
+            if got ~= "/foo?bar=baz" then
+                ngx.say("FAIL roundtrip got=", tostring(got))
+                return
+            end
+
+            -- flip last char of the signature segment
+            local tampered = signed:sub(1, -2) ..
+                (signed:sub(-1) == "A" and "B" or "A")
+            if h.verify_value(secret, tampered) ~= nil then
+                ngx.say("FAIL tampered signature accepted")
+                return
+            end
+
+            -- a different secret must not validate
+            if h.verify_value("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", signed) ~= 
nil then
+                ngx.say("FAIL wrong secret accepted")
+                return
+            end
+
+            -- nil and malformed inputs
+            if h.verify_value(secret, nil) ~= nil
+                or h.verify_value(secret, "no-dot-here") ~= nil
+                or h.verify_value(secret, "abc.def") ~= nil then
+                ngx.say("FAIL malformed cookie accepted")
+                return
+            end
+
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
diff --git a/t/plugin/security-warning.t b/t/plugin/security-warning.t
index f2d2e3ea6..0dca62e7b 100644
--- a/t/plugin/security-warning.t
+++ b/t/plugin/security-warning.t
@@ -164,7 +164,10 @@ Using authz-keycloak access_denied_redirect_uri with no 
TLS is a security risk
             local ok, err = plugin.check_schema({
                 idp_uri = "http://a.com";,
                 cas_callback_uri = "/a/b",
-                logout_uri = "/c/d"
+                logout_uri = "/c/d",
+                cookie = {
+                    secret = "0123456789abcdef0123456789abcdef",
+                },
             })
 
             if not ok then
@@ -189,7 +192,10 @@ risk
             local ok, err = plugin.check_schema({
                 idp_uri = "https://a.com";,
                 cas_callback_uri = "/a/b",
-                logout_uri = "/c/d"
+                logout_uri = "/c/d",
+                cookie = {
+                    secret = "0123456789abcdef0123456789abcdef",
+                },
             })
             if not ok then
                 ngx.say(err)

Reply via email to