Client Cert Request by URI with OCSP Checking

Problem this snippet solves:

This iRule requests a client cert for specific URIs and then validates the client cert against the client SSL profile's trusted CA cert bundle. If that succeeds, then the client cert is validated against an OCSP server (or pool of servers). Invalid certs are redirected to a URL with the Openssl verify code appended.

Note: See below for a sample bigip.conf showing the profile and virtual server definitions.

Warning: This is an anonymized version of an OCSP iRule that was tested in a customer implementation. I have not done any testing of the iRule since anonymizing it. So test, test, test!

Code :

# hooleya_auth_ssl_cc_ocsp_rule
# https://devcentral.f5.com/s/Wiki/default.aspx/iRules/client_cert_request_by_uri_with_ocsp_checking
#
# Requires 9.4.8 and hotfix 3 for:
#   CR125264 - HTTP::respond should be allowed in CLIENTSSL_HANDSHAKE
#   CR126501 - OCSP AUTH iRules need to detect server down vs. bad cert
#   CR111646: Connections are no longer rejected when clients fail to send a 
#             certificate to a virtual server with a clientssl profile configured to "request" one.
# v0.9.9 - 2010-02-22

# Description:
#
# This iRule requests a client cert for specific URIs and then validates the client cert against the client SSL profile's 
# trusted CA cert bundle.  If that succeeds, then the client cert is validated against an OCSP server (or pool of servers).
# Invalid certs are redirected to a URL with the Openssl verify code appended.
#
# Configuration requirements:
#
# 0. This iRule will only work for 9.4.8 with hotfix 3.  It cannot work for any lower LTM version.
#       It could be updated for 10.0.x.  In 10.1, you don't need to use the session table to store the cert details,
#       so this iRule is probably not worth updating for 10.1.
# 1. Configure the URIs to request a client cert for in a datagroup named ocsp_pages_to_require_cert_class.
# 2. You might also need to customize the cert parsing based on your requirements for which headers to insert.
# 3. Add this iRule to an OCSP auth profile.
# 4. Ideally, configure an OCSP server pool and VIP to use in the OCSP responder field
#       and consider uncommenting the code in this iRule to check the state of the OCSP server pool
#       before attempting the OCSP validation of a client cert.
# 5. Test, test test! This is an anonymized version of an OCSP iRule that was tested in a customer implementation.
#       I have not done any testing of the iRule since anonymizing it.

when RULE_INIT {

   # URL to redirect clients to for failed authentication
   # The error code is appended to the URL in the iRule
   set ::auth_failure_url "https://example.com/error.asp?errCode="

   # Session timeout. Length of time (in seconds) to store the client cert in the session table.
   set ::ocsp_session_timeout 1800

   # Log debug messages? (0=none, 1=minimal, 2=verbose, 3=everything)
   set ::ocsp_debug 3

   # Enable audit logging? (0=none, 1=unvalidated requests only, 2=all requests)
   set ::ocsp_audit_log_level 2

   # Pages to require a client cert for (replace with datagroup post-testing)
   #   This is now configured in the ocsp_pages_to_require_cert_class datagroup

   # Prefix to use when inserting the certificate details in the HTTP headers
   set ::header_prefix "CRT_"

   # SSL::sessionid returns 64 0's if the session ID doesn't exist, so set a variable to check for this
   set ::ocsp_null_sessionid [string repeat 0 64]
}
when CLIENT_ACCEPTED {

   # Initialise the TMM session id and variables tracking the auth status on each new connection
   set tmm_auth_ssl_ocsp_sid 0
   set invalidate_session 0
   set need_cert 0
   set inserted_headers 0

   # Save the client IP:port and VIP name to shorten the log lines
   set log_prefix "client IP:port=[IP::client_addr]:[TCP::client_port]; VIP=[virtual name]"
   if {$::ocsp_debug > 0}{log local0. "$log_prefix: New TCP connection to [IP::local_addr]:[TCP::local_port]"}
}
when CLIENTSSL_CLIENTCERT {

   # This event is triggered when LTM requests/requires a cert, even if the client doesn't present a cert.
   if {$::ocsp_debug > 0}{log local0. "$log_prefix: Cert count: [SSL::cert count], SSL sessionid: [SSL::sessionid]"}

   # Exit this event if we didn't request a cert
   if {$need_cert == 0}{
      if {$::ocsp_debug > 2}{log local0. "$log_prefix: Exiting event as \$need_cert is 0"}
      return
   }
   # Check if client presented a cert after it was requested
   if {[SSL::cert count] == 0}{

      # No client cert received.  Use -1 to track this (0 will be used to indicate no error by SSL::verify_result)
      set ssl_status_code "-1"

      # $ssl_status_desc is only used in this rule for debug logging.
      set ssl_status_desc "Required client certificate not present for resource."

      if {$::ocsp_debug > 0}{log local0. "$log_prefix: No cert for protected resource. Invalidating session."}
      set invalidate_session 1

      # Audit logging
      if {$::ocsp_audit_log_level > 0}{catch {log -noname local0. "cc_audit: $log_prefix; status_text=No cert for secured URI; URI=$requested_uri;"}}

   } else {

      # Client presented at least one cert.  The actual client cert should always be first.
      if {$::ocsp_debug > 1}{

         # Loop through each cert and log the cert subject
         for {set i 0} {$i < [SSL::cert count]} {incr i}{

            log local0. "$log_prefix: cert $i, subject: [X509::subject [SSL::cert $i]],\
               issuer: [X509::issuer [SSL::cert $i]], cert_serial=[X509::serial_number [SSL::cert $i]]"
         }
      }
      if {$::ocsp_debug > 2}{log local0. "$log_prefix: Received cert with SSL session ID: [SSL::sessionid]. Base64 encoded cert: [b64encode [SSL::cert 0]]"}

      # Save the SSL status code (defined here: http://www.openssl.org/docs/apps/verify.html#DIAGNOSTICS)
      set ssl_status_code [SSL::verify_result]
      set ssl_status_desc [X509::verify_cert_error_string [SSL::verify_result]]

      # Check if there was no error in validating the client cert against LTM's server cert
      if { $ssl_status_code == 0 }{
         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Certificate validation against root cert OK. status: $ssl_status_desc. Checking against OCSP."}

         ######################################################################################################
 ##### TODO:
         ##### If the OCSP responder is an LTM VIP (used for load balancing multiple OCSP servers)
 ##### you could add a check here of the OCSP server pool before attempting the OCSP validation.
 ##### Just change my_ocsp_http_pool to the actual OCSP server pool name.


         ## Check if the OCSP server pool does not have any
 #if {[active_members my_ocsp_http_pool] == 0}{

         #   # OCSP servers are not available!!
         #   log local0.emerg "$log_prefix: OCSP auth pool is down! Resuming SSL handshake and blocking HTTP request."

         #   # Audit logging
         #   if {$::ocsp_audit_log_level > 0}{
         #      catch {log -noname local0. "cc_audit: $log_prefix; status_text=OCSP server pool is unavailable. Blocking request."}
         #   }

         #   # We could send an HTTP response from this event, but it doesn't actually get sent until
         #   # the CLIENTSSL_HANDSHAKE event anyhow.  So track that this is an invalid request and set the app auth status code
         #   # to indicate OCSP validation of the cert failed.
         #   set invalidate_session 1
         #   SSL::handshake resume
 #   return
 #}
 ##### TODO END:
         ######################################################################################################

 # Check if there isn't already a TMM authentication OCSP session ID
         if {$tmm_auth_ssl_ocsp_sid == 0} {

            # [AUTH::start pam default_ssl_ocsp] returns an authentication session ID
            set tmm_auth_ssl_ocsp_sid [AUTH::start pam default_ssl_ocsp]

            if {$::ocsp_debug > 2}{log local0. "$log_prefix: \$tmm_auth_ssl_ocsp_sid was 0, \$tmm_auth_ssl_ocsp_sid: $tmm_auth_ssl_ocsp_sid"}

            if {[info exists tmm_auth_subscription]} {
               if {$::ocsp_debug > 1}{log local0. "$log_prefix: Subscribing to \$tmm_auth_ssl_ocsp_sid: $tmm_auth_ssl_ocsp_sid"}
               AUTH::subscribe $tmm_auth_ssl_ocsp_sid
            }
         }
         AUTH::cert_credential $tmm_auth_ssl_ocsp_sid [SSL::cert 0]
         AUTH::cert_issuer_credential $tmm_auth_ssl_ocsp_sid [SSL::cert issuer 0]
         AUTH::authenticate $tmm_auth_ssl_ocsp_sid

         # Hold the SSL handshake until the auth result is returned from OCSP
 # The AUTH::authenticate command triggers an OCSP lookup and then the AUTH_RESULT event.
 # In AUTH_RESULT, SSL::handshake resume triggers CLIENTSSL_HANDSHAKE.
         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Holding SSL handshake for OCSP check"}
         SSL::handshake hold

      } else {

         # Client cert validation against the CA's root server cert failed.

         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Certificate validation not ok. Status: $ssl_status_code, $ssl_status_desc"}

         # Audit logging
         if {$::ocsp_audit_log_level > 0}{catch {log -noname local0. "cc_audit: $log_prefix; \
            status_text=Invalid cert for secured URI; openssl_code=$ssl_status_code; openssl_desc=$ssl_status_desc;\
            cert_subject=[X509::subject [SSL::cert 0]]; cert_issuer=[X509::issuer [SSL::cert 0]];\
            cert_serial=[X509::serial_number [SSL::cert 0]]; URI=$requested_uri"}}

         # Delete the SSL session from the session table
         if {$::ocsp_debug > 1}{log local0. "$log_prefix: Invalidating SSL session [SSL::sessionid]"}
         session delete ssl [SSL::sessionid]
         SSL::session invalidate
         set invalidate_session 1

         # Release the request flow as we want to send an HTTP response to clients who don't send a valid cert
         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Invalid cert. Releasing HTTP."}
      }
   }
}
when AUTH_RESULT {

   # AUTH::status values:
   # https://devcentral.f5.com/s/wiki/default.aspx/iRules/AUTH__status.html
   #  0 = success
   #  1 = failure
   # -1 = error
   #  2 = not-authed

   if {$::ocsp_debug > 0}{log local0. "$log_prefix: \[AUTH::status\]: [AUTH::status]; (0=success, 1=failure, -1=error, 2=not-authed)"}

   # Check if there is an existing TMM SSL OCSP session ID
   if {[info exists tmm_auth_ssl_ocsp_sid] and ($tmm_auth_ssl_ocsp_sid == [AUTH::last_event_session_id])} {

      # Save the auth status
      set tmm_auth_status [AUTH::status]

      # TESTING ONLY: If you want to take any response from the OCSP server as valid, 
      # uncomment this line and set tmm_auth_status to 0.
      #set tmm_auth_status 0

      # Check if auth was successful
      if {$tmm_auth_status == 0 } {

 # OCSP auth was successful, so resume the SSL handshake.  This will trigger the CLIENTSSL_HANDSHAKE event next.
         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Valid cert per OCSP. Resuming SSL handshake"}
         SSL::handshake resume

      } else {

         # OCSP auth failed

         # Audit logging
         if {$::ocsp_audit_log_level > 0}{
            catch {log -noname local0. "cc_audit: status=403.13. $log_prefix; status_text=Invalid cert per OCSP for secured URI; URI=$requested_uri"}
         }

         # We could send an HTTP response from this event, but it doesn't actually get sent until
         # the CLIENTSSL_HANDSHAKE event anyhow.  So track that this is an invalid request and set the app auth status code
         # to indicate OCSP validation of the cert failed.
         set invalidate_session 1

         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Invalid cert per OCSP. \[AUTH::response_data\]: [AUTH::response_data]. Resuming SSL handshake."}
         SSL::handshake resume
      }
   }
}
when CLIENTSSL_HANDSHAKE {

   # This event is triggered when the SSL handshake with the client completes

   # Log SSL cipher details
   if {$::ocsp_debug > 2}{log local0. "$log_prefix: Cipher name, version, bits: [SSL::cipher name], [SSL::cipher version], [SSL::cipher bits]"}

   # Exit this event if cert isn't required
   if {$need_cert == 0}{
      if {$::ocsp_debug > 2}{log local0. "$log_prefix: \$need_cert is 0, exiting event."}
      return
   }

   # Check if OCSP auth was already successful
   if {[info exists tmm_auth_status] and $tmm_auth_status == 0}{

      if {$::ocsp_debug > 1}{log local0. "$log_prefix: Auth succeeded, parsing cert fields and adding session table entry."}

      # The parsing of the cert can be customized based on the application's requirements
      # For this particular implementation, the customer wanted the following fields inserted into the request HTTP headers:
      #
      #        Issuer
      #        Serial number
      #        Valid from date
      #        Valid to date
      #        Subject

      # Add the client cert fields as a list to the session table
      if {$::ocsp_debug > 0}{log local0. "$log_prefix: Saving client cert details in session using SSL sessionid [SSL::sessionid]."}

      session add ssl [SSL::sessionid] [list \
         ${::header_prefix}Issuer [X509::issuer [SSL::cert 0]] \
         ${::header_prefix}SerialNumber [X509::serial_number [SSL::cert 0]] \
         ${::header_prefix}ValidFrom [X509::not_valid_before [SSL::cert 0]] \
         ${::header_prefix}ValidUntil [X509::not_valid_after [SSL::cert 0]] \
         ${::header_prefix}Subject [X509::subject [SSL::cert 0]]
      ] $::ocsp_session_timeout

      # Audit logging
      if {$::ocsp_audit_log_level > 1}{
         catch {log -noname local0. "cc_audit: status=okay; $log_prefix; status_text=Valid cert per OCSP for secured URI (new SSL session);\
            cert_subject=[X509::subject [SSL::cert 0]]; cert_issuer=[X509::issuer [SSL::cert 0]]; cert_serial=[X509::serial_number [SSL::cert 0]]; \
    URI=$requested_uri"}
      }

      if {$::ocsp_debug > 1}{log local0. "$log_prefix: Auth was successful, releasing HTTP"}
      HTTP::release

   } elseif {$invalidate_session}{

      if {$::ocsp_debug > 0}{log local0. "$log_prefix: No/invalid cert received, sending block response."}

      # Send response to client for invalid request
      HTTP::respond 302 Location "${::auth_failure_url}${ssl_status_code}" Connection Close Cache-Control No-Cache Pragma No-Cache
      HTTP::release
      session delete ssl [SSL::sessionid]
      SSL::session invalidate
      TCP::close
   } else {
      if {$::ocsp_debug > 2}{log local0. "$log_prefix: default case."}
   }
}
when HTTP_REQUEST {

   if {$::ocsp_debug > 1}{log local0. "$log_prefix: URI: [HTTP::uri], SSL session ID: [SSL::sessionid],\
      session lookup llength: [llength [session lookup ssl [SSL::sessionid]]],\
      string len: [string length [session lookup ssl [SSL::sessionid]]]\
      User-Agent: [HTTP::header User-Agent]"}

   # Double check that the session is valid
   if {[info exists invalidate_session] and $invalidate_session == 1}{

      if {$::ocsp_debug > 0}{log local0. "$log_prefix: Invalidating SSL session ID: [SSL::sessionid]"}
      session delete ssl [SSL::sessionid]
      SSL::session invalidate
   }
   # Check if request is to a page which requires a client SSL certificate
   if {[matchclass [string tolower [HTTP::path]] starts_with $::ocsp_pages_to_require_cert_class]}{

      # Save the requested URI for logging in subsequent events
      set requested_uri [HTTP::uri]

      # Track that this is a request for a restricted URI
      set need_cert 1
      if {$::ocsp_debug > 0}{log local0. "$log_prefix: Request to restricted path: [HTTP::path]. \$need_cert: $need_cert"}

      # Check if there is an existing SSL session ID and if the cert is in the session table
      #   This condition should only be true on resumed SSL sessions.
      if {[SSL::sessionid] ne $::ocsp_null_sessionid and [session lookup ssl [SSL::sessionid]] ne ""}{

         if {$::ocsp_debug > 0}{
            log local0. "$log_prefix: Allowed request to [HTTP::host][HTTP::uri]. Inserting SSL cert details in HTTP headers."

            # Debug logging of each session table list item
            if {$::ocsp_debug > 2}{
               foreach session_element [session lookup ssl [SSL::sessionid]] {
                  log local0. "$log_prefix: $session_element"
               }
            }
         }

         # Remove any HTTP header which starts with "crt_"
         foreach a_header [HTTP::header names] {

            # Check if this header name starts with "crt_"
            if {[string match -nocase ${::header_prefix}* $a_header]}{
               HTTP::header remove $a_header

               # If there is a header which starts with crt_, it is probably someone attacking the application!
               log local0.emerg "$log_prefix: Client with possible spoofed client cert header [HTTP::request]"
            }
         }

         # Insert SSL cert details in the HTTP headers
         HTTP::header insert [session lookup ssl [SSL::sessionid]]

         # Track that we've inserted the HTTP headers, so we don't do it again in HTTP_REQUEST_SEND
         set inserted_headers 1

         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Valid request"}

         # Audit logging
         if {$::ocsp_audit_log_level > 1}{

            # Get the cert details from the session table for the audit logging
            set session_list [session lookup ssl [SSL::sessionid]]

            catch {log -noname local0. "cc_audit: status=okay; $log_prefix; status_text=Valid cert per OCSP for secured URI (resumed SSL session);\
               cert_subject=[lindex $session_list 9]; cert_issuer=[lindex $session_list 1]; cert_serial=[lindex $session_list 3]; URI=$requested_uri"}
         }

      } else {

         # Hold the HTTP request until the SSL re-negotiation is complete
         HTTP::collect

         # Force renegotiation of the SSL connection with a cert requested
         SSL::session invalidate
         SSL::authenticate always
         SSL::authenticate depth 9
         SSL::cert mode request
         SSL::renegotiate

         if {$::ocsp_debug > 0}{log local0. "$log_prefix: Restricted path, [HTTP::uri], with no client cert. Collecting HTTP and renegotiating SSL"}
      }
   } else {
      if {$::ocsp_debug > 1}{log local0. "$log_prefix: Request to unrestricted path: [HTTP::path]"}
      set need_cert 0
   }
}
when HTTP_REQUEST_SEND {

   # This event is relevant only on the initial request of a secured URI (non-resumed SSL sessions).
   # The insertion of cert details for resumed SSL sessions is handled in HTTP_REQUEST.

   # Force evaluation in clientside context as HTTP_REQUEST_SEND is a serverside event
   clientside {

      if {$::ocsp_debug > 0}{log local0. "$log_prefix: \$invalidate_session: $invalidate_session,\
         \$need_cert: $need_cert, \[SSL::sessionid\]: [SSL::sessionid], \[session lookup ssl \[SSL::sessionid\]\]: [session lookup ssl [SSL::sessionid]],\
         URI: [clientside {HTTP::uri}]"}

      # Check if request was to a restricted URI and the headers weren't inserted already in HTTP_REQUEST
      if {$need_cert==1 and $inserted_headers==0}{

         # Check if the session is still valid, there is an existing SSL session ID and that the cert is in the session table
         if {$invalidate_session == 0 and [SSL::sessionid] ne $::ocsp_null_sessionid and [session lookup ssl [SSL::sessionid]] ne ""}{

            # Remove any HTTP header which starts with "crt_"
            foreach a_header [HTTP::header names] {

               # Check if this header name starts with "crt_"
               if {[string match -nocase ${::header_prefix}* $a_header]}{

                  HTTP::header remove $a_header

                  # If there is a header which starts with crt_, it is probably someone attacking the application!
                  log local0.emerg "$log_prefix: Client with possible spoofed client cert header [HTTP::host][HTTP::uri], [HTTP::header User-Agent"]"
               }
            }

            if {$::ocsp_debug > 0}{log local0. "$log_prefix: Inserting SSL cert details in HTTP headers."}

            # Insert SSL cert details from the session table in the HTTP headers
            HTTP::header insert [session lookup ssl [SSL::sessionid]]

         } else {

             # Client request for secured URI wasn't valid
             log local0. "$log_prefix: Rejecting connection for invalid request to [HTTP::host][HTTP::uri] ([IP::local_addr]:[TCP::local_port])\
                with session ID: [SSL::sessionid]"

            # Reject the connection as we should never get here
            reject
         }
      }
   }
}
Published Mar 16, 2015
Version 1.0

Was this article helpful?

3 Comments

  • Is there any chance this to be updated to work on version 12.1 ? I am trying to adopt it, but facing a lot of issues/ errors.

     

  • We would like to have F5 configured to not always request client certificate authentication, but to request it only when the path matches specific URL