From 161e5919fbebd90a120cead02ee53ddab7068c90 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze Date: Sun, 22 Mar 2026 05:52:15 +0000 Subject: [PATCH 1/2] fix: return 405 Method Not Allowed and scope CORS methods When a request targets a valid path but uses an unsupported HTTP method, the server now returns 405 with an Allow header listing the actual methods registered for that path, instead of returning 404. The Access-Control-Allow-Methods CORS header now reflects only the methods registered for the requested path (on both preflight and 405 responses), rather than advertising all methods globally. Closes #2 --- mote/middleware.lua | 3 ++- mote/router.lua | 17 +++++++++++++++++ mote/server.lua | 37 ++++++++++++++++++++++++++++++++----- spec/router_spec.lua | 23 +++++++++++++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/mote/middleware.lua b/mote/middleware.lua index 9aabe23..f19e568 100644 --- a/mote/middleware.lua +++ b/mote/middleware.lua @@ -18,11 +18,12 @@ local cors_base = { ["Access-Control-Allow-Headers"] = "Content-Type, Authorization", } -function middleware.cors_headers() +function middleware.cors_headers(methods) local headers = {} for k, v in pairs(cors_base) do headers[k] = v end + if methods then headers["Access-Control-Allow-Methods"] = methods end return headers end diff --git a/mote/router.lua b/mote/router.lua index 19faeeb..dd0e503 100644 --- a/mote/router.lua +++ b/mote/router.lua @@ -94,6 +94,23 @@ function router.match(method, path) return nil end +function router.methods_for_path(path) + local methods = {} + local found = false + for _, route in ipairs(routes) do + local captures = route.pattern:match(path) + if captures then + found = true + if route.method == "*" then + return nil -- wildcard matches all methods, no restriction + end + methods[route.method] = true + end + end + if not found then return nil end + return methods +end + function router.clear() routes = {} middleware_stack = {} diff --git a/mote/server.lua b/mote/server.lua index d7fbd31..093f9a9 100644 --- a/mote/server.lua +++ b/mote/server.lua @@ -476,7 +476,18 @@ local function handle_request(wrapper, config) local query = req.location.query if middleware.is_preflight(req.method) then - local cors = middleware.cors_headers() + local path_methods = router.methods_for_path(path) + local allow_methods = nil + if path_methods then + local list = {} + for m in pairs(path_methods) do + list[#list + 1] = m + end + list[#list + 1] = "OPTIONS" + table.sort(list) + allow_methods = table.concat(list, ", ") + end + local cors = middleware.cors_headers(allow_methods) cors["Content-Length"] = "0" send_response(wrapper, 204, cors, nil, keep_alive) log.info("http", req.method .. " " .. path .. " 204") @@ -519,10 +530,26 @@ local function handle_request(wrapper, config) handler, params = router.match("GET", path) end if not handler then - local cors = middleware.cors_headers() - cors["Content-Type"] = "application/json" - send_response(wrapper, 404, cors, middleware.encode_json({ error = "not found" }), keep_alive) - log.info("http", req.method .. " " .. path .. " 404") + local path_methods = router.methods_for_path(path) + if path_methods then + local list = {} + for m in pairs(path_methods) do + list[#list + 1] = m + end + list[#list + 1] = "OPTIONS" + table.sort(list) + local allow = table.concat(list, ", ") + local cors = middleware.cors_headers(allow) + cors["Content-Type"] = "application/json" + cors["Allow"] = allow + send_response(wrapper, 405, cors, middleware.encode_json({ error = "method not allowed" }), keep_alive) + log.info("http", req.method .. " " .. path .. " 405") + else + local cors = middleware.cors_headers() + cors["Content-Type"] = "application/json" + send_response(wrapper, 404, cors, middleware.encode_json({ error = "not found" }), keep_alive) + log.info("http", req.method .. " " .. path .. " 404") + end return true, keep_alive, nil end diff --git a/spec/router_spec.lua b/spec/router_spec.lua index 2a21bfc..9e2169a 100644 --- a/spec/router_spec.lua +++ b/spec/router_spec.lua @@ -49,6 +49,29 @@ describe("router", function() assert.is_nil(router.match("GET", "/not-exists")) end) + describe("methods_for_path", function() + it("returns methods registered for a path", function() + router.get("/resource", function() end) + router.post("/resource", function() end) + + local methods = router.methods_for_path("/resource") + assert.is_truthy(methods) + assert.is_true(methods["GET"]) + assert.is_true(methods["POST"]) + assert.is_nil(methods["DELETE"]) + end) + + it("returns nil for unknown paths", function() + router.get("/exists", function() end) + assert.is_nil(router.methods_for_path("/not-exists")) + end) + + it("returns nil for wildcard routes", function() + router.all("/anything", function() end) + assert.is_nil(router.methods_for_path("/anything")) + end) + end) + describe("middleware", function() it("runs middleware before handler", function() local order = {} From 6d12672b4ab8c05678c13e93b31512cf46dcf7e0 Mon Sep 17 00:00:00 2001 From: Papuna Gagnidze Date: Sun, 22 Mar 2026 16:05:38 +0400 Subject: [PATCH 2/2] refactor(server): extract build_allow_header helper --- mote/server.lua | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/mote/server.lua b/mote/server.lua index 093f9a9..fe28e8e 100644 --- a/mote/server.lua +++ b/mote/server.lua @@ -13,6 +13,7 @@ local url_mod = require("mote.url") local concat = table.concat local insert = table.insert +local sort = table.sort local server = {} @@ -444,6 +445,17 @@ local function should_keep_alive(http_version, headers, wrapper, config) return tonumber(http_version) >= 1.1 end +local function build_allow_header(path_methods) + if not path_methods then return nil end + local list = {} + for m in pairs(path_methods) do + list[#list + 1] = m + end + list[#list + 1] = "OPTIONS" + sort(list) + return concat(list, ", ") +end + local function handle_request(wrapper, config) wrapper.request_count = wrapper.request_count + 1 local start_time = socket.gettime() @@ -476,18 +488,8 @@ local function handle_request(wrapper, config) local query = req.location.query if middleware.is_preflight(req.method) then - local path_methods = router.methods_for_path(path) - local allow_methods = nil - if path_methods then - local list = {} - for m in pairs(path_methods) do - list[#list + 1] = m - end - list[#list + 1] = "OPTIONS" - table.sort(list) - allow_methods = table.concat(list, ", ") - end - local cors = middleware.cors_headers(allow_methods) + local allow = build_allow_header(router.methods_for_path(path)) + local cors = middleware.cors_headers(allow) cors["Content-Length"] = "0" send_response(wrapper, 204, cors, nil, keep_alive) log.info("http", req.method .. " " .. path .. " 204") @@ -530,15 +532,8 @@ local function handle_request(wrapper, config) handler, params = router.match("GET", path) end if not handler then - local path_methods = router.methods_for_path(path) - if path_methods then - local list = {} - for m in pairs(path_methods) do - list[#list + 1] = m - end - list[#list + 1] = "OPTIONS" - table.sort(list) - local allow = table.concat(list, ", ") + local allow = build_allow_header(router.methods_for_path(path)) + if allow then local cors = middleware.cors_headers(allow) cors["Content-Type"] = "application/json" cors["Allow"] = allow