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