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 94f578a11 fix(cas-auth): harden session and callback handling (#13427)
94f578a11 is described below

commit 94f578a11b1d4c4e334555924871273aee42f1b9
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Wed Jun 3 16:05:40 2026 +0800

    fix(cas-auth): harden session and callback handling (#13427)
---
 apisix/plugins/cas-auth.lua | 147 ++++++++++++++++-----
 t/plugin/cas-auth.t         | 316 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 428 insertions(+), 35 deletions(-)

diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua
index 1aefa0c69..465054207 100644
--- a/apisix/plugins/cas-auth.lua
+++ b/apisix/plugins/cas-auth.lua
@@ -17,6 +17,8 @@
 local core = require("apisix.core")
 local http = require("resty.http")
 local openssl_mac = require("resty.openssl.mac")
+local resty_sha256 = require("resty.sha256")
+local resty_string = require("resty.string")
 local bit = require("bit")
 local ngx = ngx
 local ngx_re_match = ngx.re.match
@@ -24,12 +26,15 @@ 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_PREFIX = "CAS_SESSION_"
+local ENTRY_SEP = "|"
 local SESSION_LIFETIME = 3600
 local STORE_NAME = "cas_sessions"
 
 local store = ngx.shared[STORE_NAME]
 
+local session_opts_cache = {}
+
 
 local plugin_name = "cas-auth"
 local schema = {
@@ -131,14 +136,65 @@ local function uri_without_ticket(conf, ctx)
         ctx.var.server_port .. conf.cas_callback_uri
 end
 
-local function get_session_id(ctx)
-    return ctx.var["cookie_" .. COOKIE_NAME]
+-- Derive per-route cookie name and session-payload fingerprint from the
+-- fields that define a CAS trust context (idp_uri + cas_callback_uri).
+-- Memoised so the SHA-256 only runs once per distinct configuration.
+local function session_opts(conf)
+    local fp_input = conf.idp_uri .. ENTRY_SEP .. conf.cas_callback_uri
+    local cached = session_opts_cache[fp_input]
+    if cached then
+        return cached
+    end
+    local sha256 = resty_sha256:new()
+    sha256:update(fp_input)
+    local digest_hex = resty_string.to_hex(sha256:final())
+    cached = {
+        cookie_name = COOKIE_PREFIX .. digest_hex,
+        fingerprint = digest_hex,
+    }
+    session_opts_cache[fp_input] = cached
+    return cached
+end
+
+local function pack_entry(fingerprint, user)
+    return fingerprint .. ENTRY_SEP .. user
+end
+
+-- Returns (fingerprint, user) for entries written by pack_entry, or
+-- (nil, nil) for legacy entries that pre-date per-config binding.
+local function unpack_entry(entry)
+    if not entry then return nil, nil end
+    local sep = entry:find(ENTRY_SEP, 1, true)
+    if not sep then return nil, nil end
+    return entry:sub(1, sep - 1), entry:sub(sep + 1)
 end
 
 local function set_our_cookie(conf, name, val)
     core.response.add_header("Set-Cookie", name .. "=" .. val .. 
cookie_attrs(conf))
 end
 
+-- nginx's $cookie_<name> variable doesn't reliably expose cookies whose names
+-- exceed certain lengths in older OpenResty builds (the per-config cookie name
+-- is "CAS_SESSION_<sha256-hex>"). Parse the raw Cookie header as a fallback.
+local function get_cookie(ctx, name)
+    local val = ctx.var["cookie_" .. name]
+    if val ~= nil then
+        return val
+    end
+    local cookie_header = ctx.var.http_cookie
+    if not cookie_header then
+        return nil
+    end
+    local prefix = name .. "="
+    for piece in (cookie_header .. ";"):gmatch("([^;]+);") do
+        piece = piece:gsub("^%s+", "")
+        if piece:sub(1, #prefix) == prefix then
+            return piece:sub(#prefix + 1)
+        end
+    end
+    return nil
+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
@@ -208,27 +264,43 @@ local function first_access(conf, ctx)
     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)
-    if user == nil then
-        set_our_cookie(conf, COOKIE_NAME, "deleted; Max-Age=0")
+local function with_session_id(conf, ctx, opts, session_id)
+    -- Namespacing the store key with the per-config fingerprint keeps
+    -- ticket strings from different IdPs from colliding in cas_sessions.
+    local key = opts.fingerprint .. ":" .. session_id
+    local entry = store:get(key)
+    if entry == nil then
+        set_our_cookie(conf, opts.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
+
+    local stored_fp = unpack_entry(entry)
+    if stored_fp ~= opts.fingerprint then
+        -- session was issued under a different CAS configuration; do not 
honour
+        set_our_cookie(conf, opts.cookie_name, "deleted; Max-Age=0")
+        return first_access(conf, ctx)
+    end
+
+    local ok, err, forcible = store:set(key, entry, SESSION_LIFETIME)
+    if not ok then
+        core.log.error("cas-auth: failed to refresh session ttl: ", err or 
"unknown")
+        return
+    end
+    if forcible then
+        core.log.warn("cas-auth: session refresh caused forcible eviction")
+    end
+    core.log.info("cas-auth: session refreshed")
 end
 
-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)
+local function set_store_and_cookie(conf, opts, session_id, user)
+    local entry = pack_entry(opts.fingerprint, user)
+    local key = opts.fingerprint .. ":" .. session_id
+    local success, err, forcible = store:add(key, entry, SESSION_LIFETIME)
     if success then
         if forcible then
             core.log.info("CAS cookie store is out of memory")
         end
-        set_our_cookie(conf, COOKIE_NAME, session_id)
+        set_our_cookie(conf, opts.cookie_name, session_id)
     else
         if err == "no memory" then
             core.log.emerg("CAS cookie store is out of memory")
@@ -265,32 +337,34 @@ local function validate(conf, ctx, ticket)
 end
 
 local function validate_with_cas(conf, ctx, ticket)
+    local request_uri = verify_value(conf.cookie.secret,
+        ctx.var["cookie_" .. CAS_REQUEST_URI])
+    if not request_uri or not is_safe_redirect(request_uri) then
+        core.log.warn("cas-auth: callback rejected, missing or invalid 
initiation cookie")
+        return ngx.HTTP_UNAUTHORIZED, {message = "invalid callback state"}
+    end
+
     local user = validate(conf, ctx, ticket)
-    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])
+    local opts = session_opts(conf)
+    if user and set_store_and_cookie(conf, opts, ticket, user) then
         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
-        return ngx.HTTP_UNAUTHORIZED, {message = "invalid ticket"}
     end
+    return ngx.HTTP_UNAUTHORIZED, {message = "invalid ticket"}
 end
 
 local function logout(conf, ctx)
-    local session_id = get_session_id(ctx)
+    local opts = session_opts(conf)
+    local session_id = get_cookie(ctx, opts.cookie_name)
     if session_id == nil then
         return ngx.HTTP_UNAUTHORIZED
     end
 
     core.log.info("cas-auth: logout invoked")
-    store:delete(session_id)
-    set_our_cookie(conf, COOKIE_NAME, "deleted; Max-Age=0")
+    store:delete(opts.fingerprint .. ":" .. session_id)
+    set_our_cookie(conf, opts.cookie_name, "deleted; Max-Age=0")
 
     core.response.set_header("Location", conf.idp_uri .. "/logout")
     return ngx.HTTP_MOVED_TEMPORARILY
@@ -313,16 +387,19 @@ function _M.access(conf, ctx)
                 {message = "invalid logout request from IdP, no ticket"}
         end
         core.log.info("cas-auth: SLO request received from IdP")
-        local session_id = ticket
-        local user = store:get(session_id)
-        if user then
-            store:delete(session_id)
-            core.log.info("cas-auth: SLO session deleted for user=", user)
+        local opts = session_opts(conf)
+        local key = opts.fingerprint .. ":" .. ticket
+        local entry = store:get(key)
+        if entry then
+            store:delete(key)
+            local _, user = unpack_entry(entry)
+            core.log.info("cas-auth: SLO session deleted for user=", user or 
"<unknown>")
         end
     else
-        local session_id = get_session_id(ctx)
+        local opts = session_opts(conf)
+        local session_id = get_cookie(ctx, opts.cookie_name)
         if session_id ~= nil then
-            return with_session_id(conf, ctx, session_id)
+            return with_session_id(conf, ctx, opts, session_id)
         end
 
         local ticket = ctx.var.arg_ticket
diff --git a/t/plugin/cas-auth.t b/t/plugin/cas-auth.t
index ba07731e8..aec63ffd3 100644
--- a/t/plugin/cas-auth.t
+++ b/t/plugin/cas-auth.t
@@ -481,3 +481,319 @@ passed
 --- response_body_like
 ^302
 .*service=https%3A%2F%2Fapp\.example\.com%2Fcas_callback.*$
+
+
+
+=== TEST 14: add route for callback initiation-cookie gate
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+
+            local code, body = t('/apisix/admin/routes/cas-gate',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "methods": ["GET", "POST"],
+                        "host": "127.0.0.3",
+                        "plugins": {
+                            "cas-auth": {
+                                "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
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/*"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 15: callback without initiation cookie returns 401 and creates no 
session
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local httpc = http.new()
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+                .. "/cas_callback?ticket=ST-test"
+
+            local res, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {
+                    ["Host"] = "127.0.0.3",
+                }
+            })
+            if not res then
+                ngx.log(ngx.ERR, err)
+                ngx.exit(500)
+            end
+
+            ngx.say(res.status)
+
+            local set_cookie = res.headers['Set-Cookie']
+            local has_session = false
+            if type(set_cookie) == "string" then
+                if set_cookie:find("^CAS_SESSION_") then
+                    has_session = true
+                end
+            elseif type(set_cookie) == "table" then
+                for _, c in ipairs(set_cookie) do
+                    if c:find("^CAS_SESSION_") then
+                        has_session = true
+                        break
+                    end
+                end
+            end
+            ngx.say("session_cookie_set=", tostring(has_session))
+
+            -- No shared-dict entry should have been written for ST-test
+            -- under any configuration's fingerprint namespace.
+            local in_store = false
+            for _, k in ipairs(ngx.shared.cas_sessions:get_keys(0)) do
+                if k:find(":ST-test", 1, true) then
+                    in_store = true
+                    break
+                end
+            end
+            ngx.say("session_in_store=", tostring(in_store))
+        }
+    }
+--- response_body
+401
+session_cookie_set=false
+session_in_store=false
+
+
+
+=== TEST 16: callback with invalid initiation cookie returns 401 and creates 
no session
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local httpc = http.new()
+            local uri = "http://127.0.0.1:"; .. ngx.var.server_port
+                .. "/cas_callback?ticket=ST-test"
+
+            local res, err = httpc:request_uri(uri, {
+                method = "GET",
+                headers = {
+                    ["Host"] = "127.0.0.3",
+                    ["Cookie"] = "CAS_REQUEST_URI=not-a-valid-signed-value",
+                }
+            })
+            if not res then
+                ngx.log(ngx.ERR, err)
+                ngx.exit(500)
+            end
+
+            ngx.say(res.status)
+
+            local set_cookie = res.headers['Set-Cookie']
+            local has_session = false
+            if type(set_cookie) == "string" then
+                if set_cookie:find("^CAS_SESSION_") then
+                    has_session = true
+                end
+            elseif type(set_cookie) == "table" then
+                for _, c in ipairs(set_cookie) do
+                    if c:find("^CAS_SESSION_") then
+                        has_session = true
+                        break
+                    end
+                end
+            end
+            ngx.say("session_cookie_set=", tostring(has_session))
+
+            -- No shared-dict entry should have been written for ST-test
+            -- under any configuration's fingerprint namespace.
+            local in_store = false
+            for _, k in ipairs(ngx.shared.cas_sessions:get_keys(0)) do
+                if k:find(":ST-test", 1, true) then
+                    in_store = true
+                    break
+                end
+            end
+            ngx.say("session_in_store=", tostring(in_store))
+        }
+    }
+--- response_body
+401
+session_cookie_set=false
+session_in_store=false
+
+
+
+=== TEST 17: Add dedicated routes for the per-config scoping test
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+
+            -- Use priority=10 so these routes win over the no-host catch-all
+            -- registered in earlier tests (cas-abs), and unique hosts so they
+            -- don't collide with cas1/cas2.
+            local function put(id, host, cb)
+                local code, body = t('/apisix/admin/routes/' .. id,
+                     ngx.HTTP_PUT,
+                     string.format([[{
+                            "methods": ["GET", "POST"],
+                            "host": %q,
+                            "priority": 10,
+                            "plugins": {
+                                "cas-auth": {
+                                    "idp_uri": 
"http://127.0.0.1:8080/realms/test/protocol/cas";,
+                                    "cas_callback_uri": %q,
+                                    "logout_uri": "/logout",
+                                    "cookie": {
+                                        "secret": 
"0123456789abcdef0123456789abcdef",
+                                        "secure": false
+                                    }
+                                }
+                            },
+                            "upstream": {
+                                "nodes": {"127.0.0.1:1980": 1},
+                                "type": "roundrobin"
+                            },
+                            "uri": "/*"
+                    }]], host, cb))
+                if code >= 300 then
+                    ngx.status = code
+                    ngx.say(body)
+                    return false
+                end
+                return true
+            end
+
+            if not put("cas-scope-a", "127.0.0.10", "/cas_callback") then 
return end
+            if not put("cas-scope-b", "127.0.0.11", "/cas_callback_alt") then 
return end
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 18: sessions from one CAS configuration are not honoured under another
+--- config
+    location /t {
+        content_by_lua_block {
+            local http = require "resty.http"
+            local resty_sha256 = require("resty.sha256")
+            local str = require("resty.string")
+
+            -- Recompute the per-config fingerprint here rather than exposing
+            -- the plugin's session_opts helper. Algorithm matches the plugin.
+            local function fingerprint(idp, cb)
+                local s = resty_sha256:new()
+                s:update(idp .. "|" .. cb)
+                return str.to_hex(s:final())
+            end
+
+            local idp = "http://127.0.0.1:8080/realms/test/protocol/cas";
+            local fp_a = fingerprint(idp, "/cas_callback")
+            local fp_b = fingerprint(idp, "/cas_callback_alt")
+            assert(fp_a ~= fp_b, "two configs must yield different 
fingerprints")
+
+            -- Plant a session as the plugin would: store key namespaced by the
+            -- fingerprint, value of "<fp>|<user>". This exercises the plugin's
+            -- session-read path (with_session_id -> store:get -> unpack_entry
+            -- -> fingerprint check) on scope-a.
+            local ticket = "ST-scope-test-" .. tostring(ngx.now())
+            local key_a = fp_a .. ":" .. ticket
+            local ok, err = ngx.shared.cas_sessions:set(key_a, fp_a .. 
"|alice", 60)
+            assert(ok, "plant failed: " .. tostring(err))
+
+            local httpc = http.new()
+            local base = "http://127.0.0.1:"; .. ngx.var.server_port
+
+            -- Route scope-a (host 127.0.0.10) honours its own session.
+            local res, err2 = httpc:request_uri(base .. "/uri", {
+                method = "GET",
+                headers = {
+                    ["Host"] = "127.0.0.10",
+                    ["Cookie"] = "CAS_SESSION_" .. fp_a .. "=" .. ticket,
+                },
+            })
+            assert(res, "scope-a request failed: " .. tostring(err2))
+            assert(res.status == 200,
+                "scope-a should honour its own session, got status " .. 
res.status)
+
+            -- Same cookie sent to scope-b (different cas_callback_uri, 
different
+            -- fingerprint): scope-b looks for CAS_SESSION_<fp_b>, doesn't find
+            -- it, redirects to its own IdP.
+            res, err2 = httpc:request_uri(base .. "/uri", {
+                method = "GET",
+                headers = {
+                    ["Host"] = "127.0.0.11",
+                    ["Cookie"] = "CAS_SESSION_" .. fp_a .. "=" .. ticket,
+                },
+            })
+            assert(res, "scope-b request failed: " .. tostring(err2))
+            assert(res.status == 302,
+                "scope-b must not honour foreign cookie name, got "
+                .. res.status)
+
+            -- A forged cookie under scope-b's own name pointing at scope-a's
+            -- ticket: the namespaced store key under fp_b doesn't exist,
+            -- so the request still falls through to first_access.
+            res, err2 = httpc:request_uri(base .. "/uri", {
+                method = "GET",
+                headers = {
+                    ["Host"] = "127.0.0.11",
+                    ["Cookie"] = "CAS_SESSION_" .. fp_b .. "=" .. ticket,
+                },
+            })
+            assert(res, "scope-b forged-cookie request failed: " .. 
tostring(err2))
+            assert(res.status == 302,
+                "scope-b must not honour foreign session payload, got "
+                .. res.status)
+
+            -- Plant an entry under scope-b's namespaced key but with scope-a's
+            -- fingerprint inside the stored value. This is the only path that
+            -- reaches the in-value fingerprint check in with_session_id:
+            -- store:get finds the entry, but unpack_entry returns fp_a while
+            -- the route's opts.fingerprint is fp_b -> first_access (302).
+            local key_b_forged = fp_b .. ":" .. ticket
+            local ok2, err3 = ngx.shared.cas_sessions:set(key_b_forged,
+                fp_a .. "|alice", 60)
+            assert(ok2, "forged plant failed: " .. tostring(err3))
+
+            res, err2 = httpc:request_uri(base .. "/uri", {
+                method = "GET",
+                headers = {
+                    ["Host"] = "127.0.0.11",
+                    ["Cookie"] = "CAS_SESSION_" .. fp_b .. "=" .. ticket,
+                },
+            })
+            assert(res, "scope-b fingerprint-mismatch request failed: " .. 
tostring(err2))
+            assert(res.status == 302,
+                "scope-b must reject a stored entry whose fingerprint does not 
match, got "
+                .. res.status)
+
+            ngx.shared.cas_sessions:delete(key_a)
+            ngx.shared.cas_sessions:delete(key_b_forged)
+            ngx.say("passed")
+        }
+    }
+--- response_body
+passed

Reply via email to