Skip to content

CEL functions/macros errors with tls.client placeholders #5491

@ethack

Description

@ethack

I'm attempting to implement some simple authorization checks using CEL and client certificate fields subject, emails, and dns_names.

What I'd really like is a reusable snippet that lets me pass in a list of names that I can then validate against the current certificate.

Something like this is what I imagine as ideal:

@auth expression {http.request.tls.client.san.emails}.exists(email, email in {args})
# or
@auth expression {args}.exists(email, email in {http.request.tls.client.san.emails})

I haven't found that it's possible to use {args} as a list, however, so I'm trying cousins of what I want as a workaround.

I'm getting several different types of errors that I believe are bugs, but may be user error. Most of my attempts involve {http.request.tls.client.*}, but I'm including an example using {http.request.method} mainly to prove to myself that something similar works.

I don't believe the examples I'm including are exhaustive. I've tried some other variations and functions and gotten similar errors.

Repro steps

I'm running Caddy using docker (in case that's relevant), but my steps are for reproducing with just the Caddy binary.

caddy version
v2.6.4 h1:2hwYqiRwk1tf3VruhMpLcYTg+11fCdr8S3jhNAdnPy8=
  • Add a /etc/host entry.
127.0.0.1 getMethod.acme.corp subjectEquals.acme.corp subjectMatches.acme.corp subjectEndsWith.acme.corp emailExistsEquals.acme.corp emailIn.acme.corp domainExistsEquals.acme.corp domainExistsEndsWith.acme.corp
  • Place root cert in /etc/caddy/certs/root_ca.crt (or change Caddyfile path to match).
  • Place rest of certs/keys in current directory (or change curl command arguments to match).
  • Place Caddy config in /etc/caddy/Caddyfile (or change caddy command argument to match).

Start Caddy.

caddy run --config /etc/caddy/Caddyfile --adapter caddyfile

Use curl to access sites and submit client certificates.

# Works
❯ curl -k --cert user.crt --key user.key https://getMethod.acme.corp/
Authorized.

# Works as intended (POST method not authorized)
❯ curl -k --cert user.crt --key user.key https://getMethod.acme.corp/ -X POST

# Works
❯ curl -k --cert user.crt --key user.key https://subjectEquals.acme.corp/
Authorized.

# Works as intended (device cert not authorized)
❯ curl -k --cert device.crt --key device.key https://subjectEquals.acme.corp/

# Caddy produces errors
❯ curl -k --cert user.crt --key user.key https://subjectMatches.acme.corp/

{"level":"error","ts":1681344942.8707256,"logger":"http.matchers.expression","msg":"evaluating expression","error":"no such overload"}
{"level":"error","ts":1681344942.870744,"logger":"http.log.error","msg":"no such overload","request":{"remote_ip":"172.23.0.1","remote_port":"55492","proto":"HTTP/2.0","method":"GET","host":"subjectMatches.acme.corp","uri":"/","headers":{"User-Agent":["curl/7.81.0"],"Accept":["*/*"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"subjectmatches.acme.corp","client_common_name":"user@acme.corp","client_serial":"269261576516162278322897936109726082507"}},"duration":0.000040554}

# Caddy produces errors
❯ curl -k --cert user.crt --key user.key https://subjectEndsWith.acme.corp/

{"level":"error","ts":1681344989.5696084,"logger":"http.matchers.expression","msg":"evaluating expression","error":"internal error: interface conversion: caddyhttp.celPkixName is not traits.Receiver: missing method Receive"}
{"level":"error","ts":1681344989.5696514,"logger":"http.log.error","msg":"internal error: interface conversion: caddyhttp.celPkixName is not traits.Receiver: missing method Receive","request":{"remote_ip":"172.23.0.1","remote_port":"47960","proto":"HTTP/2.0","method":"GET","host":"subjectEndsWith.acme.corp","uri":"/","headers":{"User-Agent":["curl/7.81.0"],"Accept":["*/*"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"subjectendswith.acme.corp","client_common_name":"user@acme.corp","client_serial":"269261576516162278322897936109726082507"}},"duration":0.00008581}

# Works
❯ curl -k --cert user.crt --key user.key https://emailIn.acme.corp/
Authorized. 

The commented blocks in the Caddyfile all produce errors when I start the server and Caddy parses the config. They all basically say the same thing. I admit I could be doing something wrong, but if I try replacing the placeholder with a literal list then it works.

Error: loading initial config: loading new config: loading http app module: provision http: server srv0: setting up route handlers: route 0: loading handler modules: position 0: loading module 'subroute': provision http.handlers.subroute: setting up subroutes: route 0: loading matcher modules: module name 'expression': provision http.matchers.expression: compiling CEL program: ERROR: <input>:1:17: expression of type 'any' cannot be range of a comprehension (must be list, map, or dynamic)
 | caddyPlaceholder(request, "http.request.tls.client.san.emails").exists(email, email == "user@acme.corp" )
 | ................^

Error: loading initial config: loading new config: loading http app module: provision http: server srv0: setting up route handlers: route 0: loading handler modules: position 0: loading module 'subroute': provision http.handlers.subroute: setting up subroutes: route 0: loading matcher modules: module name 'expression': provision http.matchers.expression: compiling CEL program: ERROR: <input>:1:17: expression of type 'any' cannot be range of a comprehension (must be list, map, or dynamic)
 | caddyPlaceholder(request, "http.request.tls.client.san.dns_names").exists(domain, domain == "device.acme.corp" )
 | ................^

Error: loading initial config: loading new config: loading http app module: provision http: server srv0: setting up route handlers: route 0: loading handler modules: position 0: loading module 'subroute': provision http.handlers.subroute: setting up subroutes: route 0: loading matcher modules: module name 'expression': provision http.matchers.expression: compiling CEL program: ERROR: <input>:1:17: expression of type 'any' cannot be range of a comprehension (must be list, map, or dynamic)
 | caddyPlaceholder(request, "http.request.tls.client.san.dns_names").exists(domain, domain.endsWith("device.acme.corp"))
 | ................^

Supporting Files

certs.zip (I just realized these expire in 24 hours. Ask if you'd like new ones.)

Caddyfile

(enforce_mtls) {
	tls internal {
		client_auth {
			mode require_and_verify
			trusted_ca_cert_file /etc/caddy/certs/root_ca.crt
		}
	}

	@getMethod expression {http.request.method}.startsWith("GET") # works
	@subjectEquals expression {http.request.tls.client.subject} == "CN=user@acme.corp" # works
	@subjectMatches expression {http.request.tls.client.subject}.matches("@acme\\.corp$") # doesn't work
	@subjectEndsWith expression {http.request.tls.client.subject}.endsWith("@acme.corp") # doesn't work
	@emailIn expression "user@acme.corp" in {http.request.tls.client.san.emails} # works
	# @emailExistsEquals expression {http.request.tls.client.san.emails}.exists(email, email == "user@acme.corp") # error
	# @domainExistsEquals expression {http.request.tls.client.san.dns_names}.exists(domain, domain == "device.acme.corp") # error
	# @domainExistsEndsWith expression {http.request.tls.client.san.dns_names}.exists(domain, domain.endsWith("device.acme.corp")) # error

	# @emailExistsEquals expression ["user@acme.corp", "user2@acme.corp"].exists(email, email == "user@acme.corp") # literal list works
}

getMethod.acme.corp {
	import enforce_mtls
	respond @getMethod "Authorized."
}

subjectEquals.acme.corp {
	import enforce_mtls
	respond @subjectEquals "Authorized."
}

subjectMatches.acme.corp {
	import enforce_mtls
	respond @subjectMatches "Authorized."
}

subjectEndsWith.acme.corp {
	import enforce_mtls
	respond @subjectEndsWith "Authorized."
}

emailIn.acme.corp {
	import enforce_mtls
	respond @emailIn "Authorized."
}

# emailExistsEquals.acme.corp {
# 	import enforce_mtls
# 	respond @emailExistsEquals "Authorized."
# }

# domainExistsEquals.acme.corp {
# 	import enforce_mtls
# 	respond @domainExistsEquals "Authorized."
# }

# domainExistsEndsWith.acme.corp {
# 	import enforce_mtls
# 	respond @domainExistsEndsWith "Authorized."
# }

References

  • https://github.com/google/cel-spec/blob/master/doc/langdef.md#macros
  • https://github.com/google/cel-spec/blob/master/doc/langdef.md#list-of-standard-definitions
  • https://cloud.google.com/certificate-authority-service/docs/using-cel
  • // App is a robust, production-ready HTTP server.
    //
    // HTTPS is enabled by default if host matchers with qualifying names are used
    // in any of routes; certificates are automatically provisioned and renewed.
    // Additionally, automatic HTTPS will also enable HTTPS for servers that listen
    // only on the HTTPS port but which do not have any TLS connection policies
    // defined by adding a good, default TLS connection policy.
    //
    // In HTTP routes, additional placeholders are available (replace any `*`):
    //
    // Placeholder | Description
    // ------------|---------------
    // `{http.request.body}` | The request body (⚠️ inefficient; use only for debugging)
    // `{http.request.cookie.*}` | HTTP request cookie
    // `{http.request.duration}` | Time up to now spent handling the request (after decoding headers from client)
    // `{http.request.duration_ms}` | Same as 'duration', but in milliseconds.
    // `{http.request.uuid}` | The request unique identifier
    // `{http.request.header.*}` | Specific request header field
    // `{http.request.host}` | The host part of the request's Host header
    // `{http.request.host.labels.*}` | Request host labels (0-based from right); e.g. for foo.example.com: 0=com, 1=example, 2=foo
    // `{http.request.hostport}` | The host and port from the request's Host header
    // `{http.request.method}` | The request method
    // `{http.request.orig_method}` | The request's original method
    // `{http.request.orig_uri}` | The request's original URI
    // `{http.request.orig_uri.path}` | The request's original path
    // `{http.request.orig_uri.path.*}` | Parts of the original path, split by `/` (0-based from left)
    // `{http.request.orig_uri.path.dir}` | The request's original directory
    // `{http.request.orig_uri.path.file}` | The request's original filename
    // `{http.request.orig_uri.query}` | The request's original query string (without `?`)
    // `{http.request.port}` | The port part of the request's Host header
    // `{http.request.proto}` | The protocol of the request
    // `{http.request.remote.host}` | The host (IP) part of the remote client's address
    // `{http.request.remote.port}` | The port part of the remote client's address
    // `{http.request.remote}` | The address of the remote client
    // `{http.request.scheme}` | The request scheme
    // `{http.request.tls.version}` | The TLS version name
    // `{http.request.tls.cipher_suite}` | The TLS cipher suite
    // `{http.request.tls.resumed}` | The TLS connection resumed a previous connection
    // `{http.request.tls.proto}` | The negotiated next protocol
    // `{http.request.tls.proto_mutual}` | The negotiated next protocol was advertised by the server
    // `{http.request.tls.server_name}` | The server name requested by the client, if any
    // `{http.request.tls.client.fingerprint}` | The SHA256 checksum of the client certificate
    // `{http.request.tls.client.public_key}` | The public key of the client certificate.
    // `{http.request.tls.client.public_key_sha256}` | The SHA256 checksum of the client's public key.
    // `{http.request.tls.client.certificate_pem}` | The PEM-encoded value of the certificate.
    // `{http.request.tls.client.certificate_der_base64}` | The base64-encoded value of the certificate.
    // `{http.request.tls.client.issuer}` | The issuer DN of the client certificate
    // `{http.request.tls.client.serial}` | The serial number of the client certificate
    // `{http.request.tls.client.subject}` | The subject DN of the client certificate
    // `{http.request.tls.client.san.dns_names.*}` | SAN DNS names(index optional)
    // `{http.request.tls.client.san.emails.*}` | SAN email addresses (index optional)
    // `{http.request.tls.client.san.ips.*}` | SAN IP addresses (index optional)
    // `{http.request.tls.client.san.uris.*}` | SAN URIs (index optional)
    // `{http.request.uri}` | The full request URI
    // `{http.request.uri.path}` | The path component of the request URI
    // `{http.request.uri.path.*}` | Parts of the path, split by `/` (0-based from left)
    // `{http.request.uri.path.dir}` | The directory, excluding leaf filename
    // `{http.request.uri.path.file}` | The filename of the path, excluding directory
    // `{http.request.uri.query}` | The query string (without `?`)
    // `{http.request.uri.query.*}` | Individual query string value
    // `{http.response.header.*}` | Specific response header field
    // `{http.vars.*}` | Custom variables in the HTTP handler chain
    // `{http.shutting_down}` | True if the HTTP app is shutting down
    // `{http.time_until_shutdown}` | Time until HTTP server shutdown, if scheduled
  • if strings.HasPrefix(field, "client.") {
    cert := getTLSPeerCert(req.TLS)
    if cert == nil {
    return nil, false
    }
    // subject alternate names (SANs)
    if strings.HasPrefix(field, "client.san.") {
    field = field[len("client.san."):]
    var fieldName string
    var fieldValue any
    switch {
    case strings.HasPrefix(field, "dns_names"):
    fieldName = "dns_names"
    fieldValue = cert.DNSNames
    case strings.HasPrefix(field, "emails"):
    fieldName = "emails"
    fieldValue = cert.EmailAddresses
    case strings.HasPrefix(field, "ips"):
    fieldName = "ips"
    fieldValue = cert.IPAddresses
    case strings.HasPrefix(field, "uris"):
    fieldName = "uris"
    fieldValue = cert.URIs
    default:
    return nil, false
    }
    field = field[len(fieldName):]
    // if no index was specified, return the whole list
    if field == "" {
    return fieldValue, true
    }
    if len(field) < 2 || field[0] != '.' {
    return nil, false
    }
    field = field[1:] // trim '.' between field name and index
    // get the numeric index
    idx, err := strconv.Atoi(field)
    if err != nil || idx < 0 {
    return nil, false
    }
    // access the indexed element and return it
    switch v := fieldValue.(type) {
    case []string:
    if idx >= len(v) {
    return nil, true
    }
    return v[idx], true
    case []net.IP:
    if idx >= len(v) {
    return nil, true
    }
    return v[idx], true
    case []*url.URL:
    if idx >= len(v) {
    return nil, true
    }
    return v[idx], true
    }
    }
    switch field {
    case "client.fingerprint":
    return fmt.Sprintf("%x", sha256.Sum256(cert.Raw)), true
    case "client.public_key", "client.public_key_sha256":
    if cert.PublicKey == nil {
    return nil, true
    }
    pubKeyBytes, err := marshalPublicKey(cert.PublicKey)
    if err != nil {
    return nil, true
    }
    if strings.HasSuffix(field, "_sha256") {
    return fmt.Sprintf("%x", sha256.Sum256(pubKeyBytes)), true
    }
    return fmt.Sprintf("%x", pubKeyBytes), true
    case "client.issuer":
    return cert.Issuer, true
    case "client.serial":
    return cert.SerialNumber, true
    case "client.subject":
    return cert.Subject, true
    case "client.certificate_pem":
    block := pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
    return pem.EncodeToMemory(&block), true
    case "client.certificate_der_base64":
    return base64.StdEncoding.EncodeToString(cert.Raw), true
    default:
    return nil, false
    }
    }

Metadata

Metadata

Assignees

No one assigned

    Labels

    discussion 💬The right solution needs to be found

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions