Custom Challenge

To facilitate users in implementing challenge mechanisms that meet their specific business requirements, OpenResty Edge introduced the Custom Challenge feature in version 25.9.

Currently, this feature can be enabled through the Edgelang interface. In future versions, we will provide corresponding configuration options in page rule actions.

Interface Description

See the Edgelang interface enable-custom-captcha for details.

Global Lua Module Description

Global Lua modules used for custom challenges must meet specific design requirements to work properly with OpenResty Edge. The specific requirements are as follows:

  1. Must export three interfaces: invoke, create, and verify, where invoke and verify are required, and create can be used as needed.
  2. Interface calls in HTML pages must use specified URLs and methods, as detailed in the subsequent interface descriptions.

invoke Interface Description

This interface is used to return an HTML page when a challenge is triggered.

function _M.invoke(params)
    -- return HTML page
end

The params parameter contains:

  • token: Encrypted data that needs to be rendered into the HTML page and passed to subsequent challenge interface calls
  • time: Timestamp when the challenge was triggered, can be used to reject expired challenges in subsequent interfaces. Unit is seconds, precision is milliseconds
  • prev_url: The URL originally requested by the client, which usually needs to be redirected back to after the challenge passes. Example: /hello.html
  • clearance_time: Valid time for verification results, in seconds. Example: 60

The return value is the final HTML page content to be responded.

create Interface Description

This interface is used to create challenge content or regenerate challenge content when refreshing. If your challenge does not need to support refresh functionality, you can directly render the challenge content during the invoke stage without triggering this interface.

function _M.create(params, uri_args)
    -- return challenge content
end

The params parameter contains:

  • time: Timestamp when the challenge was triggered, can be used to reject expired challenges. Unit is seconds, precision is milliseconds
  • clearance_time: Valid time for verification results, in seconds. Example: 60

The uri_args parameter must contain the token from the params parameter of the invoke interface.

The return value is the generated challenge content.

After this interface responds, it needs to be processed by your predefined HTML page.

You need to use a fixed URI to call the create interface: GET /.edge-waf/create-captcha?token=TOKEN

  • Request URI: /.edge-waf/create-captcha
  • Request method: GET
  • URL parameter: token=TOKEN

verify Interface Description

This interface is used to verify challenge results.

function _M.verify(params, post_args)
    local ok, err = result_verify(params, post_args)
    if not ok then
        return false
    end

    return true
end

The params parameter contains:

  • time: Timestamp when the challenge was triggered, can be used to reject expired challenges. Unit is seconds, precision is milliseconds
  • clearance_time: Valid time for verification results, in seconds. Example: 60

The post_args parameter must contain token and prev_url from the params parameter of the invoke interface. After verification passes, prev_url will be returned as the response body.

The return value is true or false. When false is returned, the request will respond with a 403 status code. You can also respond with other status codes in advance as needed in this interface.

After this interface responds, it needs to be processed by your predefined HTML page.

You need to use a specific request format to call the verify interface:

POST /.edge-waf/edge-recaptcha
Content-Type: application/x-www-form-urlencoded

token=TOKEN&prev_url=PREV_URL
  • Request URI: /.edge-waf/edge-recaptcha
  • Request method: POST
  • Request header: Content-Type: application/x-www-form-urlencoded
  • Request body: Must contain parameters token=TOKEN and prev_url=PREV_URL

Practical Application Example

The following demonstrates how to customize a CAPTCHA challenge.

Adding Global Lua Module

In Global Config > Global Lua Modules, create a global Lua module named example-custom-captcha:

Source code:

local resty_captcha = require "resty.captcha"
local ck = require "resty.cookie"
local resty_random = require "resty.random"
local resty_string = require "resty.string"
local font_path = ngx.config.prefix() .. "font/Vera.ttf"

local captcha_dict = ngx.shared.captcha_store
local str_lower = string.lower
local str_fmt = string.format

local _M = {}

function _M.invoke(params)
    local html_template = [[
<!doctype html>
<html>
<head>
    <title>Response</title>
</head>
<body>
    <img id="captcha_image" src="/.edge-waf/create-captcha?token=%s" alt="captcha" class="captcha_image">
    <form id="edge-recaptcha-form">
        <input type="text" id="edge-input-captcha" name="captcha" class="outline-input text-short" style=" border: 1px solid;">
        <input type="hidden" id="edge-input-prev_url" name="prev_url" value="%s">
        <input type="hidden" id="edge-input-token" name="token" value="%s">
        <input type="submit" style="border: 1px solid; padding: 0px 18px;">
        <p id="captcha_error" style="color: red; margin-top: 15px; line-height: 1.5;">
    <form>
    <script type="text/javascript">
        var URL = '/.edge-waf/edge-recaptcha';
        var form = document.getElementById('edge-recaptcha-form');
        form.addEventListener('submit', function (e) {
            e.preventDefault();
            var xhr = new XMLHttpRequest();
            var captcha = document.getElementById('edge-input-captcha').value;
            var prevUrl = document.getElementById('edge-input-prev_url').value;
            var token = document.getElementById('edge-input-token').value;
            if (window.XMLHttpRequest) {
                xhr = new XMLHttpRequest();
            } else if (window.ActiveXObject) {
                xhr = new ActiveXObject('Microsoft.XMLHTTP');
            }
            xhr.onreadystatechange = function () {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        window.location.replace(xhr.responseText);
                    } else if (xhr.status > 400 && xhr.status < 500) {
                        document.getElementById('captcha_error').innerText = 'Verification failed, please refresh and try again.';
                    } else if (xhr.status >= 500) {
                        var reqId = xhr.getResponseHeader('req-id');
                        document.getElementById('captcha_error').innerText = 'Verification failed, please refresh and try again. Status code: ' + xhr.status + ', Request ID: ' + reqId;
                    }
                }
            };
            xhr.open('POST', URL, true);
            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
            xhr.send('captcha=' + captcha + '&prev_url=' + prevUrl + '&token=' + token);
        });
    </script>
</body>
</html>
    ]]
    return str_fmt(html_template, params.token, params.prev_url, params.token)
end

local function generate_session_id()
    local bytes = resty_random.bytes(16)
    return resty_string.to_hex(bytes)
end

function _M.create(params, uri_args)
    local cap = resty_captcha.new()
    cap:font(font_path)
    cap:length(4)
    cap:scribble()
    cap:generate()

    local session_id = generate_session_id()
    local captcha_text = cap:getStr()

    local ttl = params.clearance_time or 60
    captcha_dict:set(session_id, str_lower(captcha_text), ttl)

    local cookie = ck:new()
    cookie:set({
        key = "captcha_session",
        value = session_id,
        path = "/",
        httponly = true,
        max_age = ttl
    })

    return cap:jpegStr(70)
end

function _M.verify(params, post_args)
    local cookie = ck:new()
    local session_id = cookie:get("captcha_session")

    if not session_id then
        ngx.log(ngx.ERR, "missing captcha session")
        ngx.exit(403)
    end

    local stored_captcha = captcha_dict:get(session_id)
    if not stored_captcha then
        ngx.log(ngx.ERR, "captcha session expired or invalid")
        ngx.exit(403)
    end

    local user_input = str_lower(post_args["captcha"] or "")

    captcha_dict:delete(session_id)

    return user_input == stored_captcha
end

return _M

Features of this Lua module:

  1. Defines and exports 3 required functions: invoke, create, verify
  2. In the HTML page, uses HTTP request formats that comply with design requirements to call /.edge-waf/create-captcha and /.edge-waf/edge-recaptcha
  3. Stores CAPTCHA information in shared memory captcha_store

Adding Shared Memory

In Global Config > Global Lua Modules > Custom Shared Memory Dictionaries, enter captcha_store and save:

Using Custom Challenge in Application

First, add a rule to enable custom challenge:

Then add a rule that returns Hello to simulate the page displayed after successful custom challenge verification:

Testing

Challenge page triggered:

After completing the challenge, automatically redirect to the protected resource page: