I set up Roundcube webmail on a freshly configured OpenBSD mail server and looked for ways to secure it beyond the classic fail2ban.

After spending some time online, I found the available captcha plugins to be really outdated, so I decided to build a simple one on my own.

I chose Cloudflare Turnstile over Google reCAPTCHA to deliver instant, privacy-first bot protection without frustrating users with image puzzles or tracking cookies.

Roundcube login with cloudflare turnstile

Requirements

This solution should work on any modern Linux/OpenBSD installation. I specifically tested it with OpenBSD 7.9

General Requirements

  • Roundcube Webmail 1.5.x or 1.6.x+
  • PHP 8.x with json and curl or openssl stream wrappers enabled.
  • Cloudflare Turnstile Site Key & Secret Key (obtained for free from Cloudflare Dashboard).

OpenBSD Specific System Requirements

On OpenBSD, php-fpm operates inside the /var/www chroot sandbox. Outbound HTTPS API verification requires:

  1. Root CA Certificate Bundle: /var/www/etc/ssl/cert.pem (copied from /etc/ssl/cert.pem).
  2. DNS Resolution: /var/www/etc/resolv.conf (copied from /etc/resolv.conf).
  3. Hosts File: /var/www/etc/hosts (copied from /etc/hosts).
  4. cURL Extension: Package php-curl installed and linked in /etc/php-8.3/curl.ini.
  5. URL Fopen: allow_url_fopen = On configured in /etc/php-8.3.ini.

Installation & Configuration

Plugin Source Code

Create the file <path-to-roundcubemail>/plugins/turnstile/turnstile.php

For OpenBSD the full path will be /var/www/roundcubemail/plugins/turnstile/turnstile.php

<?php
/**
 * Cloudflare Turnstile Security Plugin for Roundcube Webmail
 * Compatible with OpenBSD(including chroot) and most modern Linux distributions.
 */
class turnstile extends rcube_plugin
{
    public function init()
    {
        $this->add_hook('render_page', array($this, 'render_page'));
        $this->add_hook('authenticate', array($this, 'verify_turnstile'));
    }

    public function render_page($args)
    {
        if(empty($args['template']) || $args['template'] !== 'login') {
            return $args;
        }

        $rcmail = rcube::get_instance();
        $site_key = $rcmail->config->get('turnstile_site_key');

        if(empty($site_key)) {
            return $args;
        }

        $script = '<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>';
        $widget = '<div style="margin: 15px 0 10px 0;" class="cf-turnstile" data-sitekey="' . htmlspecialchars($site_key) . '"></div>';

        // Inject script tag into <head>
        if(strpos($args['content'], '</head>') !== false) {
            $args['content'] = str_replace('</head>', $script . '</head>', $args['content']);
        }

        // Inject widget before submit button or before </form>
        if(preg_match('/(<button[^>]*type="submit"[^>]*>)/i', $args['content'])) {
            $args['content'] = preg_replace('/(<button[^>]*type="submit"[^>]*>)/i', $widget . '$1', $args['content'], 1);
        } elseif(strpos($args['content'], '</form>') !== false) {
            $args['content'] = str_replace('</form>', $widget . '</form>', $args['content']);
        } else {
            $args['content'] .= $widget;
        }

        return $args;
    }

    public function verify_turnstile($args)
    {
        $rcmail = rcube::get_instance();
        $secret_key = $rcmail->config->get('turnstile_secret_key');

        if(empty($secret_key)) {
            return $args;
        }

        $token = rcube_utils::get_input_value('cf-turnstile-response', rcube_utils::INPUT_POST);

        if(empty($token)) {
            $args['abort'] = true;
            $args['error'] = 'CAPTCHA verification required. Please complete the security check.';
            return $args;
        }

        $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
        $post_data = http_build_query(array(
            'secret'   => $secret_key,
            'response' => $token,
            'remoteip' => rcube_utils::remote_ip()
        ));

        $response = false;

        // Detect CA bundle path across OpenBSD and Linux distros
        $ca_bundle = null;
        foreach(array('/etc/ssl/cert.pem', '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/ca-bundle.pem') as $path) {
            if(file_exists($path)) {
                $ca_bundle = $path;
                break;
            }
        }

        // 1. Primary verification via cURL
        if(function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
            curl_setopt($ch, CURLOPT_TIMEOUT, 5);
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
            if($ca_bundle !== null) {
                curl_setopt($ch, CURLOPT_CAINFO, $ca_bundle);
            }
            $response = curl_exec($ch);
            curl_close($ch);
        }

        // 2. Fallback verification via native PHP stream context
        if($response === false) {
            $ssl_opts = array(
                'verify_peer'      => true,
                'verify_peer_name' => true
            );
            if($ca_bundle !== null) {
                $ssl_opts['cafile'] = $ca_bundle;
            }
            $context = stream_context_create(array(
                'http' => array(
                    'method'  => 'POST',
                    'header'  => "Content-Type: application/x-www-form-urlencoded\r\n",
                    'content' => $post_data,
                    'timeout' => 5
                ),
                'ssl' => $ssl_opts
            ));
            $response = @file_get_contents($url, false, $context);
        }

        $result = json_decode($response, true);

        if(!$result || empty($result['success'])) {
            rcube::raise_error(array(
                'code' => 500,
                'file' => __FILE__,
                'line' => __LINE__,
                'message' => "Turnstile Verification Failed. Raw Response: '" . var_export($response, true) . "' | Parsed: " . var_export($result, true)
            ), true, false);

            $args['abort'] = true;
            $args['error'] = 'Security check failed. Please try logging in again.';
            return $args;
        }

        return $args;
    }
}

Roundcube Main Configuration

Edit <path-to-roundcubemail>/config/config.inc.php

Add 'turnstile' to the $config['plugins'] array and define keys:

// Enable the plugin
$config['plugins'] = array('archive', 'zipdownload', 'turnstile');

// Cloudflare Turnstile API Keys
$config['turnstile_site_key']   = 'YOUR_CLOUDFLARE_TURNSTILE_SITE_KEY';
$config['turnstile_secret_key'] = 'YOUR_CLOUDFLARE_TURNSTILE_SECRET_KEY';

Testing & Verification

  1. Open Webmail in your browser: https://your-domain.com/.
  2. Inspect the login form: The Cloudflare Turnstile widget will appear bellow the login submit button.
  3. Submit a login attempt: Check browser DevTools network tab for POST payload containing cf-turnstile-response.
  4. Check logs if an error occurs