HAProxy ACL With Variable In Substring

Hi,

I am attempting an ACL rule that will check if my src value from the client’s request or txn.source_ip can be found within my txn.token_payload variable if possible. Hardcoded values seems to be working, however variables seems to not be working.

I am currently using HAProxy 2.6 and I am not able to add a new LUA script at this point, so would like to perform this task via simple ACLs if possible.

Config that works:

frontend test-443
  # Binding
  bind *:443

  ## Validate Allowed Origins If Exists
  ### Decode Token Payload
  http-request set-var(txn.token_payload) req.hdr(Authorization),word(2,.),ub64dec
  ### Check If allowed-origins is available in token_payload packet
  acl allowed_origins_in_payload var(txn.token_payload) -m found -m sub allowed-origins
  ### Check if the src field can be found within the token_payload variable if the allowed-origins field exists
  acl src_found_in_allowed_origins var(txn.token_payload) -m found -m sub 172.1.1.1 if allowed_origins_in_payload
  ### Deny the request if the allowed-origins field exists but the client source can't be found within it
  http-request deny content-type 'text/html' string 'Host was not found in allowed_origins' if allowed_origins_in_payload !src_found_in_allowed_origins

  # Backend Endpoint
  use_backend backup
  
backend backup

  # Server Endpoint
  default-server check
  server test backup:12345

Config that does not work:

frontend test-443
  # Binding
  bind *:443

  ## Validate Allowed Origins If Exists
  ### Decode Token Payload
  http-request set-var(txn.token_payload) req.hdr(Authorization),word(2,.),ub64dec
  ### Check If allowed-origins is available in token_payload packet
  acl allowed_origins_in_payload var(txn.token_payload) -m found -m sub allowed-origins
  ### Check if the src field can be found within the token_payload variable if the allowed-origins field exists
  acl src_found_in_allowed_origins var(txn.token_payload) -m found -m sub src if allowed_origins_in_payload
  ### Deny the request if the allowed-origins field exists but the client source can't be found within it
  http-request deny content-type 'text/html' string 'Host was not found in allowed_origins' if allowed_origins_in_payload !src_found_in_allowed_origins

  # Backend Endpoint
  use_backend backup
  
backend backup

  # Server Endpoint
  default-server check
  server test backup:12345

Example Client Source:

172.1.1.1

Example JWT Token Payload:

{
  "exp": 1713905722,
  "iat": 1713905422,
  "jti": "12345",
  "iss": "http://172.2.2.2:443/realms/test",
  "aud": "/api/test",
  "sub": "wvwcwecwc-fbevopm-vwv-vwevew-vwvwevwev",
  "typ": "Bearer",
  "azp": "test",
  "allowed-origins": [
    "192.168.1.1",
    "172.1.1.1"
  ],
  "scope": "test-api",
  "clientHost": "172.2.2.2",
  "clientAddress": "172.2.2.2",
  "client_id": "test"
}

HAProxy Version:

2.6.5-987a4e2

Hi,

You cannot specify multiple -m on the same acl line: it gets rewritten each time you use it, thus only the last one will be considered.

For instance:

acl allowed_origins_in_payload var(txn.token_payload) -m found -m sub allowed-origins

Will be simply be evaluated as:

acl allowed_origins_in_payload var(txn.token_payload) -m sub allowed-origins

Moreover, -m sub will interpret the given patterns as raw strings, ie: it will not evaluate fetches, so if you use this:

acl src_found_in_allowed_origins var(txn.token_payload) -m sub src 

It will try to look for “src” keyword in txn.token_payload, not for the actual IP address that src sample fetch points to.

Unfortunately I can’t think of a trivial way to do this from pure config. Perhaps strcmp(), jwt_query() and json_query() converters could give you a hand, but it looks quite complicated to do this from pure config IMO.

If you’re not afraid about performance implications, another option could be to leverage Lua to implement your own fetch. Since Lua fetches have access to the TXN context, it should be pretty straightforward to extract allowed IP origins from the txn.token_payload variable and compare then with client’s IP address so that the fetch returns 1 on success and 0 on failure for instance.

match.lua:

function extract_allowed_ips_from_jwt(arg)
  local ips = { } -- array of strings ip
   -- your code to populate ips
  return ips
end

core.register_fetches("check_allowed", function(txn)
    -- Get source IP
    local clientip = txn.f:src()
    -- Get allowed ips
    local allowedips = extract_allowed_ips_from_jwt(txn:get_var("txn.token_payload"))

  for key,value in pairs(allowedips) do
      if string.match(value, clientip) then
          return true
      end
  end
  return false
end)

haproxy.conf:

global
  lua-load match.lua

frontend test-443
  # Binding
  bind *:443

  acl src_found_in_allowed_origins lua.check_allowed -m bool eq true
  http-request deny content-type 'text/html' string 'Host was not found in allowed_origins' if !src_found_in_allowed_origins

I ended up building the following setup to solve this issue.

data_extractor.lua

-- Function to traverse the table and get a value based on the key.
-- @param input_tbl table: Table to traverse.
-- @param input_field_key string: Input string key to search for.
-- @param input_field_value string: Input string key value to search for.
-- @param input_key_ignore_case boolean: Match the key, regardless of the case of the strings.
-- @param input_value_ignore_case boolean: Match the value, regardless of the case of the strings.
-- @return True or False if key and value can be found
local function traverse_table_keys(input_tbl, input_field_key, input_field_value, input_key_ignore_case, input_value_ignore_case)

    -- Loop through key value pairs in table
    for key, value in pairs(input_tbl) do

        if (input_key_ignore_case) then
            key = string.lower(key)
            input_field_key = string.lower(input_field_key)
        end

        -- If the key and the input field key matches then proceed with checking the type of the value
        if (key ~= nil and key == input_field_key) then

            -- If the value is not of type table and the value matches the input field value, then return true
            if (type(value) ~= "table") then
                if (input_value_ignore_case) then
                    value = string.lower(value)
                    input_field_value = string.lower(input_field_value)
                end

                if (value == input_field_value) then
                    return true
                end
            -- If the value is of type table, the proceed with decoding the table
            else

                -- Loop through key value pairs in table
                for keyTbl, valueTbl in pairs(value) do

                    if (input_value_ignore_case) then
                        valueTbl = string.lower(valueTbl)
                        input_field_value = string.lower(input_field_value)
                    end

                    -- If the value in the table matches the input field value, then return true
                    if (valueTbl ~= nil and valueTbl == input_field_value) then
                        return true
                    end
                end
            end
        -- If the value is of type table, run the table through this function again
        elseif type(value) == "table" then
            if (traverse_table_keys(value, input_field_key, input_field_value, input_key_ignore_case, input_value_ignore_case)) then
                return true
            end
        end
    end

    -- If nothing found then return false
    return false
end

-- Function to parse input object, retrieve specific key and value and compare it to the validation field
-- @param txn HaProxyObject: Object passed from HaProxy to get fetch samples.
-- @param input_object string: The variable containing the JSON packet that should be traveresed.
-- @param input_key string: The variable containing the key name that should be retrieved.
-- @param input_validation string: The variable containing the validation value that should exist within the value for the input_key.
-- @param input_key_ignore_case boolean: Match the key, regardless of the case of the strings.
-- @param input_value_ignore_case boolean: Match the value, regardless of the case of the strings.
-- @param input_debug boolean: If additional debug logging should be printed or not.
-- @return string: Boolean to confirm whether input_validation could be found within the value of the input_key field within the input_object JSON object.
local function json_extract_and_compare(txn, input_object, input_key, input_validation, input_key_ignore_case, input_value_ignore_case, input_debug)
    local fe_name = txn.sf:fe_name()
    local luaId = (txn.get_var(txn, "txn.luaId") or "N/A")
    local inputObject = (txn:get_var(input_object) or "N/A")
    local inputKey = (txn:get_var(input_key) or "N/A")
    local inputValidation = (txn:get_var(input_validation) or "N/A")

    -- Validate inputs
    -- Argument 1: input_object / inputObject
    if (inputObject == nil or type(inputObject) ~= "string" or string.len(inputObject) == 0) then
        print("Argument 1 (input_object) is mandatory and has to be of type string and has to be a well formed JSON packet [" .. fe_name .. "]")
        return ""
    end

    -- Argument 2: input_key / inputKey
    if (inputKey == nil or type(inputKey) ~= "string") then
        print("Argument 2 (input_key) is mandatory and has to be of type string [" .. fe_name .. "]")
        return ""
    end

    -- Argument 3: input_validation / inputValidation
    if (inputValidation == nil or type(inputValidation) ~= "string") then
        print("Argument 3 (input_validation) is mandatory and has to be of type string [" .. fe_name .. "]")
        return ""
    end

    -- Argument 4: input_key_ignore_case
    if (input_key_ignore_case ~= nil) then
        -- Convert from string to boolean representation
        if (input_key_ignore_case == "true") then
            input_key_ignore_case = true
        elseif (input_key_ignore_case == "false") then
            input_key_ignore_case = false
        else
            print("Argument 4 (input_key_ignore_case) has to be of type boolean [" .. fe_name .. "]")
            return ""
        end
    else
        -- Default to false
        input_key_ignore_case = false
    end

    -- Argument 5: input_value_ignore_case
    if (input_value_ignore_case ~= nil) then
        -- Convert from string to boolean representation
        if (input_value_ignore_case == "true") then
            input_value_ignore_case = true
        elseif (input_value_ignore_case == "false") then
            input_value_ignore_case = false
        else
            print("Argument 5 (input_value_ignore_case) has to be of type boolean [" .. fe_name .. "]")
            return ""
        end
    else
        -- Default to false
        input_value_ignore_case = false
    end

    -- Argument 6: input_debug
    if (input_debug ~= nil) then
        -- Convert from string to boolean representation
        if (input_debug == "true") then
            input_debug = true
        elseif (input_debug == "false") then
            input_debug = false
        else
            print("Argument 6 (input_debug) has to be of type boolean [" .. fe_name .. "]")
            return ""
        end
    else
        -- Default to false
        input_debug = false
    end

    -- Print debug if required
    if (input_debug) then
        print("Frontend: " .. fe_name)
        print("Input Object: " .. inputObject)
        print("Input Key: " .. inputKey)
        print("Input Validation: " .. inputValidation)
        print("Input Key Ignore Case: " .. tostring(input_key_ignore_case))
        print("Input Value Ignore Case: " .. tostring(input_value_ignore_case))
    end

    -- Decode as JSON and traverse table key and values
    local extract_and_compare_output = traverse_table_keys(json.decode(inputObject), inputKey, inputValidation, input_key_ignore_case,
    input_value_ignore_case)

    -- Print debug if required
    if (input_debug) then
        print("Extract And Compare Output: " .. tostring(extract_and_compare_output))
    end

    return extract_and_compare_output
end

core.register_fetches("json_extract_and_compare", json_extract_and_compare)

haproxy.cfg:

global
  lua-load-per-thread /opt/haproxy/lua/data_extractor.lua

frontend test-8080
  bind *:8080
  
  http-request set-var(txn.jwt_payload) req.hdr(Authorization),word(2,.),ub64dec

  acl allowed_origins_in_payload var(txn.jwt_payload) -m found -m sub '"allowed-origins":'

  http-request set-var(txn.jwt_payload_key) str("allowed-origins") if allowed_origins_in_payload

  http-request set-var(txn.jwt_source_validation) src if allowed_origins_in_payload
  
  acl wildcard_in_allowed_origins http_auth_bearer,jwt_payload_query('$.allowed-origins[0]') -m str '*' '/*'

  http-request set-var(txn.allowed_origins_passed) lua.json_extract_and_compare(txn.jwt_payload,txn.jwt_payload_key,txn.jwt_source_validation,false) if allowed_origins_in_payload !wildcard_in_allowed_origins

  http-request deny deny_status 403 content-type 'application/json' string '{ "error": "Access Forbidden"}' if allowed_origins_in_payload !{ var(txn.allowed_origins_passed) -m bool } !wildcard_in_allowed_origins