Configuring HAProxy SNI with Selective Client Certificate Verification for Different Domains


2 views

When implementing client certificate authentication in HAProxy, Safari's behavior differs significantly from other browsers. While Firefox and Chrome only prompt for certificates when accessing specific paths/domains, Safari persistently requests certificates for all HTTPS connections when any frontend has ca-file configured.

The solution involves leveraging Server Name Indication (SNI) to route traffic before SSL negotiation completes. Here's a working configuration:

frontend https_sni
    bind *:443 ssl crt /etc/mycert.pem no-sslv3
    mode tcp
    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }

    acl is_public ssl_fc_sni -i www.example.com
    acl is_private ssl_fc_sni -i private.example.com

    use_backend https_public if is_public
    use_backend https_private if is_private
    default_backend https_public

backend https_public
    mode http
    server public_server 127.0.0.1:8443

backend https_private
    mode http
    server private_server 127.0.0.1:8444 ssl ca-file /etc/myca.pem verify optional

You'll need two additional HAProxy instances (or other HTTP servers) listening on different ports:

# Public server config (port 8443)
frontend public_https
    bind *:8443 ssl crt /etc/mycert.pem no-sslv3
    default_backend public_app

# Private server config (port 8444)
frontend private_https
    bind *:8444 ssl crt /etc/mycert.pem ca-file /etc/myca.pem verify optional no-sslv3
    acl path_ghost path_beg /ghost/
    acl has_client_cert ssl_c_used
    http-request redirect location https://www.example.com if path_ghost !has_client_cert
    default_backend private_app

This approach adds minimal overhead since:

  • The SNI frontend operates at TCP layer
  • SSL termination happens only once (in the backend servers)
  • No additional SSL handshakes are required

If you prefer a single HAProxy instance, consider this Lua-based approach:

frontend https_all
    bind *:443 ssl crt /etc/mycert.pem no-sslv3 lua-load /etc/haproxy/ssl_options.lua
    http-request lua.set_ca if { hdr(host) -i private.example.com }
    http-request lua.set_ca if { path_beg /ghost/ }

    # Rest of your ACLs and rules...

With accompanying Lua script:

function set_ca(txn)
    txn:set_var("txn.ca_required", true)
end

core.register_action("set_ca", {"http-req"}, set_ca)

function ssl_options(txn)
    if txn:get_var("txn.ca_required") then
        txn:set_ssl("ca-file", "/etc/myca.pem")
        txn:set_ssl("verify", "optional")
    end
end

core.register_fetches("ssl_options", ssl_options)

Verify your configuration with these commands:

# Test SNI routing
openssl s_client -connect example.com:443 -servername www.example.com
openssl s_client -connect example.com:443 -servername private.example.com

# Verify certificate prompts
curl -vk https://www.example.com
curl -vk https://private.example.com --cert client.pem --key client.key

When implementing client certificate authentication in HAProxy, Safari behaves differently from other browsers. Unlike Firefox or Chrome that only prompt for certificates when accessing specific paths/subdomains, Safari persistently requests client certificates even on public-facing pages. This creates a poor user experience for Mac users browsing your main domain.

The current configuration uses ACLs to control certificate verification:

frontend mysite_https
  bind *.443 ssl crt /etc/mycert.pem ca-file /etc/myca.pem verify optional no-sslv3
  mode http
  acl domain_www     hdr_beg(host) -i www.
  acl domain_private hdr_beg(host) -i private.
  acl clientcert     ssl_c_used

While this works technically, it doesn't prevent Safari from requesting certificates unnecessarily. The root cause lies in how Safari handles the SSL handshake before processing HTTP headers.

We can leverage Server Name Indication (SNI) to create separate SSL contexts. Here's the working configuration:

frontend https_sni
  bind *:443 ssl crt /etc/mycert.pem no-sslv3
  mode tcp
  tcp-request inspect-delay 5s
  tcp-request content accept if { req.ssl_hello_type 1 }
  
  acl is_private ssl_fc_sni -i private.example.com
  acl is_ghost ssl_fc_sni -i www.example.com
  
  use_backend private_https if is_private
  use_backend public_https if is_ghost
  default_backend public_https

backend public_https
  mode tcp
  server public 127.0.0.1:1443

backend private_https
  mode tcp
  server private 127.0.0.1:2443

frontend public_http
  bind 127.0.0.1:1443 ssl crt /etc/mycert.pem no-sslv3
  mode http
  # Regular HTTP processing

frontend private_http
  bind 127.0.0.1:2443 ssl crt /etc/mycert.pem ca-file /etc/myca.pem verify optional no-sslv3
  mode http
  # Client cert required processing

For simpler deployments, HAProxy 2.0+ supports dynamic SSL parameters:

frontend unified_https
  bind *:443 ssl crt /etc/mycert.pem no-sslv3
  mode http
  
  acl is_private hdr(host) -i private.example.com
  acl is_ghost path_beg /ghost/
  
  # Dynamic SSL configuration
  http-request set-var(req.ssl_verify) bool(true) if is_private || is_ghost
  http-request set-var(req.ssl_cafile) str(/etc/myca.pem) if is_private || is_ghost
  
  # Process client cert verification
  http-request set-var(txn.client_cert_verified) bool(true) if { ssl_c_used }
  
  # Redirect logic remains similar to original
  redirect location https://www.example.com if is_ghost !{ var(txn.client_cert_verified) }

The SNI approach adds a small latency overhead due to the TCP proxy layer, but provides cleaner separation. The dynamic SSL method reduces complexity but requires newer HAProxy versions.