Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
message: "Fixed traditional router to consider all matching path regexes for a request URI instead of only the first. When multiple path patterns matched the same URI, the router now unions candidates from every matching path and sorts by regex_priority (desc) and created_at (asc), so higher-priority routes (e.g. `/api/v1/login`) are no longer skipped in favor of a lower-priority fallback when an auth route's path matched first."
type: bugfix
scope: Core
160 changes: 156 additions & 4 deletions kong/router/traditional.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,58 @@ end
do
local reducers = {
[MATCH_RULES.HOST] = function(category, ctx)
local matching = ctx.hits.matching_host_values
if matching and next(matching) then
-- Union of routes for every host pattern that matched, then sort by
-- submatch_weight (desc, so port-specific wildcard wins), then
-- regex_priority (desc), then created_at (asc).
local seen = {}
local seen_ref = {}
local list = {}
for host_value, _ in pairs(matching) do
local candidates = category.routes_by_hosts[host_value]
if candidates then
for j = 1, candidates[0] do
local rt = candidates[j]
local id = rt.route and rt.route.id
if id then
if not seen[id] then
seen[id] = true
list[#list + 1] = rt
end
else
if not seen_ref[rt] then
seen_ref[rt] = true
list[#list + 1] = rt
end
end
end
end
end
if #list == 0 then
return nil
end
sort(list, function(a, b)
local sw1 = a.submatch_weight or 0
local sw2 = b.submatch_weight or 0
if sw1 ~= sw2 then
return sw1 > sw2
end
local rp1 = a.route and (a.route.regex_priority or 0) or 0
local rp2 = b.route and (b.route.regex_priority or 0) or 0
if rp1 ~= rp2 then
return rp1 > rp2
end
local ca = a.route and a.route.created_at
local cb = b.route and b.route.created_at
if ca and cb then
return ca < cb
end
return false
end)
list[0] = #list
return list
end
return category.routes_by_hosts[ctx.hits.host or ctx.req_host]
end,

Expand All @@ -1135,6 +1187,53 @@ do
[MATCH_RULES.URI] = function(category, ctx)
-- no ctx.req_uri indexing since regex URIs have a higher priority than
-- plain URIs
local matching = ctx.hits.matching_uri_values
if matching and next(matching) then
-- Union of routes for every path that matched the request URI, then
-- sort by regex_priority (desc) then created_at (asc) so more specific
-- routes are tried first.
local seen = {}
local seen_ref = {}
local list = {}
for path_value, _ in pairs(matching) do
local candidates = category.routes_by_uris[path_value]
if candidates then
for j = 1, candidates[0] do
local rt = candidates[j]
local id = rt.route and rt.route.id
if id then
if not seen[id] then
seen[id] = true
list[#list + 1] = rt
end
else
if not seen_ref[rt] then
seen_ref[rt] = true
list[#list + 1] = rt
end
end
end
end
end
if #list == 0 then
return nil
end
sort(list, function(a, b)
local rp1 = a.route and (a.route.regex_priority or 0) or 0
local rp2 = b.route and (b.route.regex_priority or 0) or 0
if rp1 ~= rp2 then
return rp1 > rp2
end
local ca = a.route and a.route.created_at
local cb = b.route and b.route.created_at
if ca and cb then
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be and or ~=

Copy link
Copy Markdown
Author

@bakjos bakjos Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if ca and cb then means: it only compare by created_at when both routes have a created_at. If either is nil, you skip the comparison and fall through to return false (no swap). So you only order by time when both values exist.

if ca ~= cb then would mean: When the two created_at values are different, do return ca < cb. So the resulting order could be similar, but the meaning is different: you’re not explicitly requiring both must be set

return ca < cb
end
return false
end)
list[0] = #list
return list
end
return category.routes_by_uris[ctx.hits.uri]
end,

Expand Down Expand Up @@ -1379,7 +1478,11 @@ function _M.new(routes, cache, cache_neg)
-- or IP ranges comparison functions
local prefix_uris = { [0] = 0 } -- will be sorted by length
local regex_uris = { [0] = 0 }
-- luacheck: ignore 311
local num_distinct_regex_uris = 0 -- set in do block below, read in find_route closure
local wildcard_hosts = { [0] = 0 }
-- luacheck: ignore 311
local num_distinct_wildcard_hosts = 0 -- set in do block below, read in find_route closure
local src_trust_funcs = { [0] = 0 }
local dst_trust_funcs = { [0] = 0 }

Expand Down Expand Up @@ -1462,6 +1565,34 @@ function _M.new(routes, cache, cache_neg)
index_route_t(route_t, plain_indexes, prefix_uris, regex_uris,
wildcard_hosts, src_trust_funcs, dst_trust_funcs)
end

-- Deduplicate regex_uris by path value so we run each regex at most once
-- per request, and know how many distinct patterns exist (for early exit).
local regex_uris_dedup = { [0] = 0 }
local seen_uri_value = {}
for i = 1, regex_uris[0] do
local uri_t = regex_uris[i]
if not seen_uri_value[uri_t.value] then
seen_uri_value[uri_t.value] = true
append(regex_uris_dedup, uri_t)
end
end
regex_uris = regex_uris_dedup
num_distinct_regex_uris = regex_uris[0]

-- Deduplicate wildcard_hosts by host value so we run each regex at most once
-- per request, and know how many distinct patterns exist (for early exit).
local wildcard_hosts_dedup = { [0] = 0 }
local seen_host_value = {}
for i = 1, wildcard_hosts[0] do
local host_t = wildcard_hosts[i]
if not seen_host_value[host_t.value] then
seen_host_value[host_t.value] = true
append(wildcard_hosts_dedup, host_t)
end
end
wildcard_hosts = wildcard_hosts_dedup
num_distinct_wildcard_hosts = wildcard_hosts[0]
end


Expand Down Expand Up @@ -1655,9 +1786,19 @@ function _M.new(routes, cache, cache_neg)
end

if from then
hits.host = host.value
if num_distinct_wildcard_hosts > 1 then
if not hits.matching_host_values then
hits.matching_host_values = {}
end
hits.matching_host_values[host.value] = true
end
if not hits.host then
hits.host = host.value
end
req_category = bor(req_category, MATCH_RULES.HOST)
break
if num_distinct_wildcard_hosts <= 1 then
break
end
end
end
end
Expand All @@ -1666,6 +1807,7 @@ function _M.new(routes, cache, cache_neg)
-- uri match

if match_regex_uris then
hits.matching_uri_values = nil
for i = 1, regex_uris[0] do
local from, _, err = re_find(req_uri, regex_uris[i].regex, "ajo")
if err then
Expand All @@ -1674,9 +1816,19 @@ function _M.new(routes, cache, cache_neg)
end

if from then
hits.uri = regex_uris[i].value
if num_distinct_regex_uris > 1 then
if not hits.matching_uri_values then
hits.matching_uri_values = {}
end
hits.matching_uri_values[regex_uris[i].value] = true
end
if not hits.uri then
hits.uri = regex_uris[i].value
end
req_category = bor(req_category, MATCH_RULES.URI)
break
if num_distinct_regex_uris <= 1 then
break
end
end
end
end
Expand Down
110 changes: 110 additions & 0 deletions spec/01-unit/08-router_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,116 @@ for _, flavor in ipairs({ "traditional", "traditional_compatible", "expressions"
end)
end)

describe("multiple regex paths matching same URI and regex_priority", function()
it_trad_only("selects higher regex_priority route when multiple regex paths match and first match has headers", function()
local use_case = {
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8181",
paths = { "~/api(/.*)" },
headers = { ["X-API-Key"] = { "secret" } },
regex_priority = 0,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8182",
paths = { "~/api/v1/login" },
regex_priority = 200,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8183",
paths = { "~/api(/.*)" },
regex_priority = 0,
},
},
}

local router = assert(new_router(use_case))

local match_t = router:select("GET", "/api/v1/login", "example.com", "http", nil, nil, nil, nil, nil, {})
assert.truthy(match_t)
assert.same(use_case[2].route, match_t.route)
if flavor == "traditional" then
assert.same("/api/v1/login", match_t.matches.uri)
end
end)
end)

describe("multiple wildcard hosts matching same host and regex_priority", function()
it_trad_only("selects higher regex_priority route when multiple wildcard hosts match", function()
local use_case = {
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8191",
hosts = { "*.com" },
regex_priority = 0,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8192",
hosts = { "*.example.com" },
regex_priority = 200,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8193",
hosts = { "*.com" },
regex_priority = 0,
},
},
}

local router = assert(new_router(use_case))

-- api.example.com matches both *.com and *.example.com; higher regex_priority wins
local match_t = router:select("GET", "/", "api.example.com")
assert.truthy(match_t)
assert.same(use_case[2].route, match_t.route)
assert.same("*.example.com", match_t.matches.host)
end)

it_trad_only("selects by created_at when regex_priority ties and multiple wildcard hosts match", function()
local use_case = {
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8194",
hosts = { "*.com" },
regex_priority = 100,
created_at = 2000,
},
},
{
service = service,
route = {
id = "e8fb37f1-102d-461e-9c51-6608a6bb8195",
hosts = { "*.example.com" },
regex_priority = 100,
created_at = 1000,
},
},
}

local router = assert(new_router(use_case))

-- both match api.example.com, same regex_priority; earlier created_at wins
local match_t = router:select("GET", "/", "api.example.com")
assert.truthy(match_t)
assert.same(use_case[2].route, match_t.route)
end)
end)

describe("[wildcard host]", function()
local use_case, router

Expand Down
Loading