From 294534267d901abb3d3dbf78c8c723950ff46765 Mon Sep 17 00:00:00 2001 From: Nick Ramirez Date: Thu, 3 Sep 2020 00:57:52 -0400 Subject: [PATCH 1/4] This version depends on HAProxy 2.2, but allows the Lua action to reply directly to a CORS preflight request. --- example/docker-compose.yml | 2 +- example/haproxy/cors.lua | 121 ++++++++++++++++++++++++++---------- example/haproxy/haproxy.cfg | 4 +- lib/cors.lua | 118 ++++++++++++++++++++++++----------- 4 files changed, 173 insertions(+), 72 deletions(-) diff --git a/example/docker-compose.yml b/example/docker-compose.yml index ca56bae..7d67951 100644 --- a/example/docker-compose.yml +++ b/example/docker-compose.yml @@ -10,7 +10,7 @@ services: - "name=server1" haproxy: - image: haproxytech/haproxy-ubuntu:2.0 + image: haproxytech/haproxy-ubuntu:2.2 volumes: - "./haproxy/haproxy.cfg:/etc/haproxy/haproxy.cfg" - "./haproxy/cors.lua:/etc/haproxy/cors.lua" diff --git a/example/haproxy/cors.lua b/example/haproxy/cors.lua index 75af6a8..5d84514 100644 --- a/example/haproxy/cors.lua +++ b/example/haproxy/cors.lua @@ -20,59 +20,112 @@ function contains(items, test_str) return false end --- When invoked during a request, captures the Origin header if present --- and stores it in a private variable. -function cors_request(txn) - local headers = txn.http:req_get_headers() - local origin = headers["origin"] - +-- If the given origin is found within the allowed_origins string, it is returned. Otherwise, nil is returned. +-- origin: The value from the 'origin' request header +-- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) +function get_allowed_origin(origin, allowed_origins) if origin ~= nil then - core.Debug("CORS: Got 'Origin' header: " .. headers["origin"][0]) - txn:set_priv(headers["origin"][0]) + local allowed_origins = core.tokenize(allowed_origins, ",") + + -- Strip whitespace + for index, value in ipairs(allowed_origins) do + allowed_origins[index] = value:gsub("%s+", "") + end + + core.Debug("CORS - Origin: " .. origin) + + if contains(allowed_origins, "*") then + return "*" + elseif contains(allowed_origins, origin:match("//([^/]+)")) then + return origin + end end + + return nil end --- When invoked during a response, sets CORS headers so that the browser --- can read the response from permitted domains. --- txn: The current transaction object that gives access to response properties. +-- Add headers for CORS preflight request and then returns a 204 response. +-- txn: The current transaction object that gives access to response properties +-- method: The HTTP method +-- origin: The value from the 'origin' request header +-- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) +-- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) +function preflight_request(txn, method, origin, allowed_methods, allowed_origins) + core.Debug("CORS: preflight request OPTIONS") + + -- NOTE: The 'reply' function is available in HAProxy 2.2+ + local reply = txn:reply() + reply:set_status(204, "No Content") + reply:add_header("Content-Type", "text/html") + reply:add_header("Access-Control-Allow-Methods", allowed_methods) + reply:add_header("Access-Control-Max-Age", 600) + + local allowed_origin = get_allowed_origin(origin, allowed_origins) + + if allowed_origin == nil then + core.Debug("CORS: " .. origin .. " not allowed") + else + core.Debug("CORS: " .. origin .. " allowed") + reply:add_header("Access-Control-Allow-Origin", allowed_origin) + end + + core.Debug("CORS: Returning reply to CORS preflight request") + txn:done(reply) +end + +-- When invoked during a request, captures the origin header if present and stores it in a private variable. +-- If the request is OPTIONS, returns a preflight request reply. +-- txn: The current transaction object that gives access to response properties -- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) -- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) -function cors_response(txn, allowed_methods, allowed_origins) +function cors_request(txn, allowed_methods, allowed_origins) + local headers = txn.http:req_get_headers() + local origin = headers["origin"][0] + + local transaction_data = {} + + if origin ~= nil then + core.Debug("CORS: Got 'Origin' header: " .. headers["origin"][0]) + transaction_data["origin"] = origin + end + + transaction_data["allowed_methods"] = allowed_methods + transaction_data["allowed_origins"] = allowed_origins + + txn:set_priv(transaction_data) + local method = txn.sf:method() - local origin = txn:get_priv() - -- add headers for CORS preflight request if method == "OPTIONS" then - core.Debug("CORS: preflight request OPTIONS") - txn.http:res_add_header("Access-Control-Allow-Methods", allowed_methods) - txn.http:res_set_header("Allow", allowed_methods) - txn.http:res_add_header("Access-Control-Max-Age", 600) + preflight_request(txn, method, origin, allowed_methods, allowed_origins) end +end + +-- When invoked during a response, sets CORS headers so that the browser can read the response from permitted domains. +-- txn: The current transaction object that gives access to response properties. +function cors_response(txn) + local transaction_data = txn:get_priv() + local origin = transaction_data["origin"] + local allowed_origins = transaction_data["allowed_origins"] + + -- Always vary on the Origin + txn.http:res_add_header("Vary", "Accept-Encoding,Origin") -- Bail if client did not send an Origin if origin == nil or origin == '' then return end - local allowed_origins = core.tokenize(allowed_origins, ",") - - -- Strip whitespace - for index, value in ipairs(allowed_origins) do - allowed_origins[index] = value:gsub("%s+", "") - end + local allowed_origin = get_allowed_origin(origin, allowed_origins) - if contains(allowed_origins, "*") then - core.Debug("CORS: " .. "* allowed") - txn.http:res_add_header("Access-Control-Allow-Origin", "*") - elseif contains(allowed_origins, origin:match("//([^/]+)")) then - core.Debug("CORS: " .. origin .. " allowed") - txn.http:res_add_header("Access-Control-Allow-Origin", origin) - txn.http:res_add_header("Vary", "Accept-Encoding,Origin") - else + if allowed_origin == nil then core.Debug("CORS: " .. origin .. " not allowed") + else + core.Debug("CORS: " .. origin .. " allowed") + txn.http:res_add_header("Access-Control-Allow-Origin", allowed_origin) end end -- Register the actions with HAProxy -core.register_action("cors", {"http-req"}, cors_request, 0) -core.register_action("cors", {"http-res"}, cors_response, 2) \ No newline at end of file +core.register_action("cors", {"http-req"}, cors_request, 2) +core.register_action("cors", {"http-res"}, cors_response, 0) \ No newline at end of file diff --git a/example/haproxy/haproxy.cfg b/example/haproxy/haproxy.cfg index 331a632..2f8e65d 100644 --- a/example/haproxy/haproxy.cfg +++ b/example/haproxy/haproxy.cfg @@ -19,8 +19,8 @@ listen api bind :8080 # Invoke the CORS service on the request to capture the Origin header - http-request lua.cors + http-request lua.cors "GET,PUT,POST", "localhost" # Invoke the CORS service on the response to add CORS headers - http-response lua.cors "GET,PUT,POST" "localhost" + http-response lua.cors server s1 server1:80 check diff --git a/lib/cors.lua b/lib/cors.lua index 5a180d4..5d84514 100644 --- a/lib/cors.lua +++ b/lib/cors.lua @@ -20,35 +20,93 @@ function contains(items, test_str) return false end --- When invoked during a request, captures the Origin header if present --- and stores it in a private variable. -function cors_request(txn) +-- If the given origin is found within the allowed_origins string, it is returned. Otherwise, nil is returned. +-- origin: The value from the 'origin' request header +-- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) +function get_allowed_origin(origin, allowed_origins) + if origin ~= nil then + local allowed_origins = core.tokenize(allowed_origins, ",") + + -- Strip whitespace + for index, value in ipairs(allowed_origins) do + allowed_origins[index] = value:gsub("%s+", "") + end + + core.Debug("CORS - Origin: " .. origin) + + if contains(allowed_origins, "*") then + return "*" + elseif contains(allowed_origins, origin:match("//([^/]+)")) then + return origin + end + end + + return nil +end + +-- Add headers for CORS preflight request and then returns a 204 response. +-- txn: The current transaction object that gives access to response properties +-- method: The HTTP method +-- origin: The value from the 'origin' request header +-- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) +-- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) +function preflight_request(txn, method, origin, allowed_methods, allowed_origins) + core.Debug("CORS: preflight request OPTIONS") + + -- NOTE: The 'reply' function is available in HAProxy 2.2+ + local reply = txn:reply() + reply:set_status(204, "No Content") + reply:add_header("Content-Type", "text/html") + reply:add_header("Access-Control-Allow-Methods", allowed_methods) + reply:add_header("Access-Control-Max-Age", 600) + + local allowed_origin = get_allowed_origin(origin, allowed_origins) + + if allowed_origin == nil then + core.Debug("CORS: " .. origin .. " not allowed") + else + core.Debug("CORS: " .. origin .. " allowed") + reply:add_header("Access-Control-Allow-Origin", allowed_origin) + end + + core.Debug("CORS: Returning reply to CORS preflight request") + txn:done(reply) +end + +-- When invoked during a request, captures the origin header if present and stores it in a private variable. +-- If the request is OPTIONS, returns a preflight request reply. +-- txn: The current transaction object that gives access to response properties +-- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) +-- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) +function cors_request(txn, allowed_methods, allowed_origins) local headers = txn.http:req_get_headers() - local origin = headers["origin"] + local origin = headers["origin"][0] + + local transaction_data = {} if origin ~= nil then core.Debug("CORS: Got 'Origin' header: " .. headers["origin"][0]) - txn:set_priv(headers["origin"][0]) + transaction_data["origin"] = origin end -end --- Add headers for CORS preflight request -function preflight_request(txn, method, allowed_methods) + transaction_data["allowed_methods"] = allowed_methods + transaction_data["allowed_origins"] = allowed_origins + + txn:set_priv(transaction_data) + + local method = txn.sf:method() + if method == "OPTIONS" then - core.Debug("CORS: preflight request OPTIONS") - txn.http:res_add_header("Access-Control-Allow-Methods", allowed_methods) - txn.http:res_add_header("Access-Control-Max-Age", 600) + preflight_request(txn, method, origin, allowed_methods, allowed_origins) end end --- When invoked during a response, sets CORS headers so that the browser --- can read the response from permitted domains. +-- When invoked during a response, sets CORS headers so that the browser can read the response from permitted domains. -- txn: The current transaction object that gives access to response properties. --- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) --- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) -function cors_response(txn, allowed_methods, allowed_origins) - local method = txn.sf:method() - local origin = txn:get_priv() +function cors_response(txn) + local transaction_data = txn:get_priv() + local origin = transaction_data["origin"] + local allowed_origins = transaction_data["allowed_origins"] -- Always vary on the Origin txn.http:res_add_header("Vary", "Accept-Encoding,Origin") @@ -58,26 +116,16 @@ function cors_response(txn, allowed_methods, allowed_origins) return end - local allowed_origins = core.tokenize(allowed_origins, ",") - - -- Strip whitespace - for index, value in ipairs(allowed_origins) do - allowed_origins[index] = value:gsub("%s+", "") - end + local allowed_origin = get_allowed_origin(origin, allowed_origins) - if contains(allowed_origins, "*") then - core.Debug("CORS: " .. "* allowed") - txn.http:res_add_header("Access-Control-Allow-Origin", "*") - preflight_request(txn, method, allowed_methods) - elseif contains(allowed_origins, origin:match("//([^/]+)")) then - core.Debug("CORS: " .. origin .. " allowed") - txn.http:res_add_header("Access-Control-Allow-Origin", origin) - preflight_request(txn, method, allowed_methods) - else + if allowed_origin == nil then core.Debug("CORS: " .. origin .. " not allowed") + else + core.Debug("CORS: " .. origin .. " allowed") + txn.http:res_add_header("Access-Control-Allow-Origin", allowed_origin) end end -- Register the actions with HAProxy -core.register_action("cors", {"http-req"}, cors_request, 0) -core.register_action("cors", {"http-res"}, cors_response, 2) \ No newline at end of file +core.register_action("cors", {"http-req"}, cors_request, 2) +core.register_action("cors", {"http-res"}, cors_response, 0) \ No newline at end of file From f2b2f27acc49fb0e032008d52f11fd62830e44b7 Mon Sep 17 00:00:00 2001 From: Nick Ramirez Date: Thu, 3 Sep 2020 01:11:52 -0400 Subject: [PATCH 2/4] Updated README for changes to http-request and http-response lines that allow sharing allowed methods and allowed origins between the two stages by using a Lua private variable. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 573cd75..93c70e3 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,16 @@ global lua-load /path/to/cors.lua ``` -In your `frontend` or `listen` section, capture the client's *Origin* request header by adding `http-request lua.cors`: +In your `frontend` or `listen` section, capture the client's *Origin* request header by adding `http-request lua.cors` The first parameter is a comma-delimited list of HTTP methods that can be used. The second parameter is comma-delimited list of origins that are permitted to call your service. ``` -http-request lua.cors +http-request lua.cors "GET,PUT,POST" "example.com,localhost,localhost:8080" ``` -Within the same section, invoke the `http-response lua.cors` action. The first parameter is a a comma-delimited list of HTTP methods that can be used. The second parameter is comma-delimited list of origins that are permitted to call your service. +Within the same section, invoke the `http-response lua.cors` action to attach CORS headers to responses from backend servers. ``` -http-response lua.cors "GET,PUT,POST" "example.com,localhost,localhost:8080" +http-response lua.cors ``` You can also whitelist all domains by setting the second parameter to an asterisk: From 95e8c624b0af85cd6840237b982fbede75359f71 Mon Sep 17 00:00:00 2001 From: Nick Ramirez Date: Thu, 3 Sep 2020 01:13:03 -0400 Subject: [PATCH 3/4] Updated README to show how to allow all origins. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 93c70e3..04f6f21 100644 --- a/README.md +++ b/README.md @@ -44,5 +44,5 @@ http-response lua.cors You can also whitelist all domains by setting the second parameter to an asterisk: ``` -http-response lua.cors "GET,PUT,POST" "*" +http-request lua.cors "GET,PUT,POST" "*" ``` \ No newline at end of file From cd1ffb4d6f826a9bfa43623cd55a3229fedd1178 Mon Sep 17 00:00:00 2001 From: Nick Ramirez Date: Thu, 24 Sep 2020 23:53:07 -0400 Subject: [PATCH 4/4] Preflight requests now work with HAProxy 2.2 or with earlier versions, but in different ways. --- example/haproxy/cors.lua | 39 +++++++++++++++++++++++++++++---------- lib/cors.lua | 39 +++++++++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/example/haproxy/cors.lua b/example/haproxy/cors.lua index 5d84514..26d60f7 100644 --- a/example/haproxy/cors.lua +++ b/example/haproxy/cors.lua @@ -32,8 +32,6 @@ function get_allowed_origin(origin, allowed_origins) allowed_origins[index] = value:gsub("%s+", "") end - core.Debug("CORS - Origin: " .. origin) - if contains(allowed_origins, "*") then return "*" elseif contains(allowed_origins, origin:match("//([^/]+)")) then @@ -44,16 +42,29 @@ function get_allowed_origin(origin, allowed_origins) return nil end +-- Adds headers for CORS preflight request and then attaches them to the response +-- after it comes back from the server. This works with versions of HAProxy prior to 2.2. +-- The downside is that the OPTIONS request must be sent to the backend server first and can't +-- be intercepted and returned immediately. +-- txn: The current transaction object that gives access to response properties +-- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) +function preflight_request_ver1(txn, allowed_methods) + core.Debug("CORS: preflight request received") + txn.http:res_add_header("Access-Control-Allow-Methods", allowed_methods) + txn.http:res_add_header("Access-Control-Max-Age", 600) + core.Debug("CORS: attaching allowed methods to response") +end + -- Add headers for CORS preflight request and then returns a 204 response. +-- The 'reply' function used here is available in HAProxy 2.2+. It allows HAProxy to return +-- a reply without contacting the server. -- txn: The current transaction object that gives access to response properties --- method: The HTTP method -- origin: The value from the 'origin' request header -- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) -- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) -function preflight_request(txn, method, origin, allowed_methods, allowed_origins) - core.Debug("CORS: preflight request OPTIONS") +function preflight_request_ver2(txn, origin, allowed_methods, allowed_origins) + core.Debug("CORS: preflight request received") - -- NOTE: The 'reply' function is available in HAProxy 2.2+ local reply = txn:reply() reply:set_status(204, "No Content") reply:add_header("Content-Type", "text/html") @@ -69,12 +80,13 @@ function preflight_request(txn, method, origin, allowed_methods, allowed_origins reply:add_header("Access-Control-Allow-Origin", allowed_origin) end - core.Debug("CORS: Returning reply to CORS preflight request") + core.Debug("CORS: Returning reply to preflight request") txn:done(reply) end -- When invoked during a request, captures the origin header if present and stores it in a private variable. --- If the request is OPTIONS, returns a preflight request reply. +-- If the request is OPTIONS and it is a supported version of HAProxy, returns a preflight request reply. +-- Otherwise, the preflight request header is added to the response after it has returned from the server. -- txn: The current transaction object that gives access to response properties -- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) -- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) @@ -95,9 +107,10 @@ function cors_request(txn, allowed_methods, allowed_origins) txn:set_priv(transaction_data) local method = txn.sf:method() + transaction_data["method"] = method - if method == "OPTIONS" then - preflight_request(txn, method, origin, allowed_methods, allowed_origins) + if method == "OPTIONS" and txn.reply ~= nil then + preflight_request_ver2(txn, origin, allowed_methods, allowed_origins) end end @@ -107,6 +120,8 @@ function cors_response(txn) local transaction_data = txn:get_priv() local origin = transaction_data["origin"] local allowed_origins = transaction_data["allowed_origins"] + local allowed_methods = transaction_data["allowed_methods"] + local method = transaction_data["method"] -- Always vary on the Origin txn.http:res_add_header("Vary", "Accept-Encoding,Origin") @@ -121,6 +136,10 @@ function cors_response(txn) if allowed_origin == nil then core.Debug("CORS: " .. origin .. " not allowed") else + if method == "OPTIONS" and txn.reply == nil then + preflight_request_ver1(txn, allowed_methods) + end + core.Debug("CORS: " .. origin .. " allowed") txn.http:res_add_header("Access-Control-Allow-Origin", allowed_origin) end diff --git a/lib/cors.lua b/lib/cors.lua index 5d84514..26d60f7 100644 --- a/lib/cors.lua +++ b/lib/cors.lua @@ -32,8 +32,6 @@ function get_allowed_origin(origin, allowed_origins) allowed_origins[index] = value:gsub("%s+", "") end - core.Debug("CORS - Origin: " .. origin) - if contains(allowed_origins, "*") then return "*" elseif contains(allowed_origins, origin:match("//([^/]+)")) then @@ -44,16 +42,29 @@ function get_allowed_origin(origin, allowed_origins) return nil end +-- Adds headers for CORS preflight request and then attaches them to the response +-- after it comes back from the server. This works with versions of HAProxy prior to 2.2. +-- The downside is that the OPTIONS request must be sent to the backend server first and can't +-- be intercepted and returned immediately. +-- txn: The current transaction object that gives access to response properties +-- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) +function preflight_request_ver1(txn, allowed_methods) + core.Debug("CORS: preflight request received") + txn.http:res_add_header("Access-Control-Allow-Methods", allowed_methods) + txn.http:res_add_header("Access-Control-Max-Age", 600) + core.Debug("CORS: attaching allowed methods to response") +end + -- Add headers for CORS preflight request and then returns a 204 response. +-- The 'reply' function used here is available in HAProxy 2.2+. It allows HAProxy to return +-- a reply without contacting the server. -- txn: The current transaction object that gives access to response properties --- method: The HTTP method -- origin: The value from the 'origin' request header -- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) -- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) -function preflight_request(txn, method, origin, allowed_methods, allowed_origins) - core.Debug("CORS: preflight request OPTIONS") +function preflight_request_ver2(txn, origin, allowed_methods, allowed_origins) + core.Debug("CORS: preflight request received") - -- NOTE: The 'reply' function is available in HAProxy 2.2+ local reply = txn:reply() reply:set_status(204, "No Content") reply:add_header("Content-Type", "text/html") @@ -69,12 +80,13 @@ function preflight_request(txn, method, origin, allowed_methods, allowed_origins reply:add_header("Access-Control-Allow-Origin", allowed_origin) end - core.Debug("CORS: Returning reply to CORS preflight request") + core.Debug("CORS: Returning reply to preflight request") txn:done(reply) end -- When invoked during a request, captures the origin header if present and stores it in a private variable. --- If the request is OPTIONS, returns a preflight request reply. +-- If the request is OPTIONS and it is a supported version of HAProxy, returns a preflight request reply. +-- Otherwise, the preflight request header is added to the response after it has returned from the server. -- txn: The current transaction object that gives access to response properties -- allowed_methods: Comma-delimited list of allowed HTTP methods. (e.g. GET,POST,PUT,DELETE) -- allowed_origins: Comma-delimited list of allowed origins. (e.g. localhost,localhost:8080,test.com) @@ -95,9 +107,10 @@ function cors_request(txn, allowed_methods, allowed_origins) txn:set_priv(transaction_data) local method = txn.sf:method() + transaction_data["method"] = method - if method == "OPTIONS" then - preflight_request(txn, method, origin, allowed_methods, allowed_origins) + if method == "OPTIONS" and txn.reply ~= nil then + preflight_request_ver2(txn, origin, allowed_methods, allowed_origins) end end @@ -107,6 +120,8 @@ function cors_response(txn) local transaction_data = txn:get_priv() local origin = transaction_data["origin"] local allowed_origins = transaction_data["allowed_origins"] + local allowed_methods = transaction_data["allowed_methods"] + local method = transaction_data["method"] -- Always vary on the Origin txn.http:res_add_header("Vary", "Accept-Encoding,Origin") @@ -121,6 +136,10 @@ function cors_response(txn) if allowed_origin == nil then core.Debug("CORS: " .. origin .. " not allowed") else + if method == "OPTIONS" and txn.reply == nil then + preflight_request_ver1(txn, allowed_methods) + end + core.Debug("CORS: " .. origin .. " allowed") txn.http:res_add_header("Access-Control-Allow-Origin", allowed_origin) end