-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Description
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/hostentry.
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
curlcommand arguments to match). - Place Caddy config in
/etc/caddy/Caddyfile(or changecaddycommand argument to match).
Start Caddy.
caddy run --config /etc/caddy/Caddyfile --adapter caddyfileUse 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
caddy/modules/caddyhttp/app.go
Lines 39 to 102 in 10b265d
// 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 caddy/modules/caddyhttp/replacer.go
Lines 344 to 437 in 223cbe3
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 } }