@marcel91200 / caddy Mirror · read-only / commits / b1694e7a
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 03:30 UTC

caddyhttp: add CEL expression support to the tls matcher (#8088)

* caddyhttp: add CEL expression support to the tls matcher MatchTLS was the only core HTTP matcher without a CELLibrary() implementation, so it could not be used inside an expression matcher. Every sibling matcher (host, path, path_regexp, method, query, header, header_regexp, protocol, remote_ip, client_ip, url_pattern, vars, vars_regexp, and the file-server's file matcher) already has this. Adds CELLibrary() to MatchTLS, exposing tls_handshake_complete('true') / tls_handshake_complete('false') in CEL expressions. Mirrors MatchProtocol's existing string-argument-macro pattern: CELMatcherImpl with cel.StringType, parsing the argument with strconv.ParseBool and constructing a MatchTLS{HandshakeComplete: &b} that reuses the matcher's own MatchWithError. Adds the corresponding CELLibraryProducer interface guard. Adds 6 cases to celmatcher_test.go's existing table-driven TestMatchExpressionMatch/BenchmarkMatchExpressionMatch: match on 'true'/'false' against a complete/incomplete handshake, and three invocation-error cases (no args, wrong arg type, non-boolean string). Required adding a tlsHandshakeComplete *bool field to the shared test table and wiring it into req.TLS.HandshakeComplete in both the test and benchmark request-construction helpers. Fixes #8087 * address review: use a native bool arg, rename to match the matcher's own name francislavoie's review on #8088: - "A boolean as a string argument is very strange." CELMatcherImpl only supported string/list(string)/map argument types, so the original implementation parsed a string literal with strconv.ParseBool to work around that. Instead, extends CELMatcherImpl itself with a cel.BoolType case (celMatcherBoolMacroExpander + isCELBoolExpr, mirroring the existing string equivalents), so a matcher can accept a real CEL bool literal. This is shared infrastructure, so any other matcher needing a bool argument in the future gets it for free. - "I'm not sure why tls_handshake_complete was chosen when the matcher is named tls." Renamed the CEL function to tls(...), matching every other matcher's naming convention (the function name mirrors the matcher's own directive/module name: host(), path(), method(), protocol(), etc.). Usage is now 'expression tls(true)' / 'expression tls(false)' instead of 'expression tls_handshake_complete(\'true\')'. Updated the 6 existing test cases accordingly; still covers match/no-match on both true/false and the two invocation-error cases (no args, wrong arg type -- now a string instead of a bool, confirming the new type check). * address review: cover tls() against a plain non-TLS request steadytao asked for a test proving tls(false) doesn't treat the absence of TLS as an incomplete handshake. MatchWithError already handles this correctly (r.TLS == nil returns false regardless of HandshakeComplete), this just adds the missing coverage for both tls(true) and tls(false) against a plain http:// request. Confirmed these tests catch a real regression: removing the r.TLS == nil guard causes a nil pointer dereference panic, not just a wrong result. * address review: redesign tls() CEL matcher to preserve all three Caddyfile states The Caddyfile tls matcher has three states: bare `tls` (any TLS connection, regardless of handshake completion), `tls early_data` (handshake not yet complete), and the implicit "handshake complete" case. The previous tls(true)/tls(false) design could only express the latter two, with no way to express "any TLS, don't care about handshake state" from CEL. Redesigns the macro to: tls() -- any TLS connection (mirrors bare `tls`) tls('complete') -- handshake complete tls('early_data') -- handshake not yet complete (mirrors `tls early_data`) tls() is implemented as sugar for tls('any'): a zero-arg macro that expands to the same 2-arg call the one-arg string macro produces, just with the literal injected by the macro itself. Both macros share one function registration and one matcher factory, so there's a single source of truth for the string-to-MatchTLS mapping. This replaces the native-bool design from the prior commit, so also removes the generic bool-argument support (celMatcherBoolMacroExpander, isCELBoolExpr, and the CELMatcherImpl case for cel.BoolType) added for it, since nothing else in the codebase used it and it would otherwise ship as unreviewed, unused surface area. Rewrites the existing tls(...) test cases for the new string-based calls, and adds coverage for the new tls() form: matches regardless of handshake state (both complete and incomplete), and still correctly fails to match a plain non-TLS request. Also covers the new invocation-error cases: a boolean argument is no longer valid, and an unrecognized string (anything other than 'complete'/'early_data') is now a Provision-time error instead of silently doing the wrong thing. Verified: full caddyhttp test suite passes, and confirmed each new/changed test fails when its corresponding implementation piece is reverted (the zero-arg macro, and the complete/early_data boolean mapping). * address review: document and test tls('any') as a supported literal form steadytao pointed out that the zero-arg tls() macro expands internally to the same tls_request_string(req, "any") call that the one-arg form uses, which means tls('any') was already silently reachable as a literal string even though it was never documented or tested as part of the public API. Rather than special-casing the macro to bypass the shared string factory (which would need a second function/overload just for the zero-arg case), document and test tls('any') as a real, supported third spelling alongside tls() -- they're already fully equivalent, so there's nothing to hide.

Aradhya Jain committed Oct 24, 2026 at 17:31 UTC b1694e7a4c4cedd3c30712a5edf3d0fbb754b06e
2 files changed +200 -8
modules/caddyhttp/celmatcher_test.go
+142 -8
@@ -44,14 +44,15 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
44 -----END CERTIFICATE-----`)
45
46 matcherTests = []struct {
47 - name string
48 - expression *MatchExpression
49 - urlTarget string
50 - httpMethod string
51 - httpHeader *http.Header
52 - wantErr bool
53 - wantResult bool
54 - clientCertificate []byte
47 + name string
48 + expression *MatchExpression
49 + urlTarget string
50 + httpMethod string
51 + httpHeader *http.Header
52 + wantErr bool
53 + wantResult bool
54 + clientCertificate []byte
55 + tlsHandshakeComplete *bool
56 }{
57 {
58 name: "boolean matches succeed for placeholder http.request.tls.client.subject",
@@ -334,6 +335,124 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
335 },
336 wantErr: true,
337 },
338 + {
339 + name: "tls('complete') matches when handshake is complete (MatchTLS)",
340 + expression: &MatchExpression{
341 + Expr: `tls('complete')`,
342 + },
343 + urlTarget: "https://example.com",
344 + tlsHandshakeComplete: boolPtr(true),
345 + wantResult: true,
346 + },
347 + {
348 + name: "tls('complete') does not match when handshake is incomplete (MatchTLS)",
349 + expression: &MatchExpression{
350 + Expr: `tls('complete')`,
351 + },
352 + urlTarget: "https://example.com",
353 + tlsHandshakeComplete: boolPtr(false),
354 + wantResult: false,
355 + },
356 + {
357 + name: "tls('early_data') matches an incomplete handshake (MatchTLS)",
358 + expression: &MatchExpression{
359 + Expr: `tls('early_data')`,
360 + },
361 + urlTarget: "https://example.com",
362 + tlsHandshakeComplete: boolPtr(false),
363 + wantResult: true,
364 + },
365 + {
366 + name: "tls('early_data') does not match a complete handshake (MatchTLS)",
367 + expression: &MatchExpression{
368 + Expr: `tls('early_data')`,
369 + },
370 + urlTarget: "https://example.com",
371 + tlsHandshakeComplete: boolPtr(true),
372 + wantResult: false,
373 + },
374 + {
375 + name: "tls() matches any TLS connection regardless of handshake state, complete (MatchTLS)",
376 + expression: &MatchExpression{
377 + Expr: `tls()`,
378 + },
379 + urlTarget: "https://example.com",
380 + tlsHandshakeComplete: boolPtr(true),
381 + wantResult: true,
382 + },
383 + {
384 + name: "tls() matches any TLS connection regardless of handshake state, incomplete (MatchTLS)",
385 + expression: &MatchExpression{
386 + Expr: `tls()`,
387 + },
388 + urlTarget: "https://example.com",
389 + tlsHandshakeComplete: boolPtr(false),
390 + wantResult: true,
391 + },
392 + {
393 + name: "tls('any') is equivalent to tls(), complete (MatchTLS)",
394 + expression: &MatchExpression{
395 + Expr: `tls('any')`,
396 + },
397 + urlTarget: "https://example.com",
398 + tlsHandshakeComplete: boolPtr(true),
399 + wantResult: true,
400 + },
401 + {
402 + name: "tls('any') is equivalent to tls(), incomplete (MatchTLS)",
403 + expression: &MatchExpression{
404 + Expr: `tls('any')`,
405 + },
406 + urlTarget: "https://example.com",
407 + tlsHandshakeComplete: boolPtr(false),
408 + wantResult: true,
409 + },
410 + {
411 + name: "tls invocation error wrong arg type (MatchTLS)",
412 + expression: &MatchExpression{
413 + Expr: `tls(true)`,
414 + },
415 + wantErr: true,
416 + },
417 + {
418 + name: "tls invocation error unrecognized string value (MatchTLS)",
419 + expression: &MatchExpression{
420 + Expr: `tls('bogus')`,
421 + },
422 + wantErr: true,
423 + },
424 + {
425 + name: "tls('early_data') does not match a plain non-TLS request (MatchTLS)",
426 + expression: &MatchExpression{
427 + Expr: `tls('early_data')`,
428 + },
429 + urlTarget: "http://example.com",
430 + wantResult: false,
431 + },
432 + {
433 + name: "tls('complete') does not match a plain non-TLS request (MatchTLS)",
434 + expression: &MatchExpression{
435 + Expr: `tls('complete')`,
436 + },
437 + urlTarget: "http://example.com",
438 + wantResult: false,
439 + },
440 + {
441 + name: "tls() does not match a plain non-TLS request (MatchTLS)",
442 + expression: &MatchExpression{
443 + Expr: `tls()`,
444 + },
445 + urlTarget: "http://example.com",
446 + wantResult: false,
447 + },
448 + {
449 + name: "tls('any') does not match a plain non-TLS request (MatchTLS)",
450 + expression: &MatchExpression{
451 + Expr: `tls('any')`,
452 + },
453 + urlTarget: "http://example.com",
454 + wantResult: false,
455 + },
456 {
457 name: "query does not match against a specific value (MatchQuery)",
458 expression: &MatchExpression{
@@ -523,6 +642,13 @@ func TestMatchExpressionMatch(t *testing.T) {
642 }
643 }
644
645 + if tc.tlsHandshakeComplete != nil {
646 + if req.TLS == nil {
647 + req.TLS = &tls.ConnectionState{}
648 + }
649 + req.TLS.HandshakeComplete = *tc.tlsHandshakeComplete
650 + }
651 +
652 matches, err := tc.expression.MatchWithError(req)
653 if err != nil {
654 t.Errorf("MatchExpression.Match() error = %v", err)
@@ -568,6 +694,12 @@ func BenchmarkMatchExpressionMatch(b *testing.B) {
694 PeerCertificates: []*x509.Certificate{cert},
695 }
696 }
697 + if tc.tlsHandshakeComplete != nil {
698 + if req.TLS == nil {
699 + req.TLS = &tls.ConnectionState{}
700 + }
701 + req.TLS.HandshakeComplete = *tc.tlsHandshakeComplete
702 + }
703 b.ResetTimer()
704 for b.Loop() {
705 tc.expression.MatchWithError(req)
@@ -624,3 +756,5 @@ func TestMatchExpressionUnmarshalCaddyfileRepeated(t *testing.T) {
756 }
757 }
758 }
759 +
760 +func boolPtr(b bool) *bool { return &b }
modules/caddyhttp/matchers.go
+58
@@ -31,8 +31,11 @@ import (
31 "strings"
32
33 "cel.dev/cel-go/cel"
34 + "cel.dev/cel-go/common"
35 + "cel.dev/cel-go/common/ast"
36 "cel.dev/cel-go/common/types"
37 "cel.dev/cel-go/common/types/ref"
38 + "cel.dev/cel-go/parser"
39 "golang.org/x/net/idna"
40
41 "github.com/caddyserver/caddy/v2"
@@ -1494,6 +1497,60 @@ func (m *MatchTLS) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
1497 return nil
1498 }
1499
1500 +// CELLibrary produces options that expose this matcher for use in CEL
1501 +// expression matchers. It mirrors the three states the Caddyfile `tls`
1502 +// matcher itself supports: bare `tls` (any TLS connection, regardless of
1503 +// handshake completion), `tls early_data` (handshake not yet complete), and
1504 +// the otherwise-implicit "handshake complete" case. `tls()` is sugar for
1505 +// `tls('any')`; both spellings are equivalent and supported.
1506 +//
1507 +// Example:
1508 +//
1509 +// expression tls() && !tls('early_data')
1510 +func (MatchTLS) CELLibrary(ctx caddy.Context) (cel.Library, error) {
1511 + const funcName = "tls_request_string"
1512 + factory := func(data ref.Val) (RequestMatcherWithError, error) {
1513 + str, ok := data.(types.String)
1514 + if !ok {
1515 + return nil, errors.New("tls argument was not a string")
1516 + }
1517 + switch string(str) {
1518 + case "any":
1519 + // Also reachable directly as tls('any'), not just via the
1520 + // zero-arg tls() macro below -- both are valid and equivalent.
1521 + return MatchTLS{}, nil
1522 + case "complete":
1523 + complete := true
1524 + return MatchTLS{HandshakeComplete: &complete}, nil
1525 + case "early_data":
1526 + complete := false
1527 + return MatchTLS{HandshakeComplete: &complete}, nil
1528 + default:
1529 + return nil, fmt.Errorf("tls argument must be 'any', 'complete', or 'early_data', got %q", str)
1530 + }
1531 + }
1532 +
1533 + // tls() is sugar for tls('any'): it expands to the same 2-arg call the
1534 + // one-argument form produces, just with the argument injected by the
1535 + // macro itself instead of typed out by the caller.
1536 + zeroArgMacro := parser.NewGlobalMacro("tls", 0,
1537 + func(eh cel.MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *common.Error) {
1538 + return eh.NewCall(funcName, eh.NewIdent(CELRequestVarName), eh.NewLiteral(types.String("any"))), nil
1539 + })
1540 + oneArgMacro := parser.NewGlobalMacro("tls", 1, celMatcherStringMacroExpander(funcName))
1541 +
1542 + envOptions := []cel.EnvOption{
1543 + cel.Macros(zeroArgMacro, oneArgMacro),
1544 + cel.Function(funcName,
1545 + cel.Overload(funcName, []*cel.Type{cel.ObjectType("http.Request"), cel.StringType}, cel.BoolType),
1546 + cel.SingletonBinaryBinding(CELMatcherRuntimeFunction(funcName, factory))),
1547 + }
1548 + programOptions := []cel.ProgramOption{
1549 + cel.CustomDecorator(CELMatcherDecorator(funcName, factory)),
1550 + }
1551 + return NewMatcherCELLibrary(envOptions, programOptions), nil
1552 +}
1553 +
1554 // CaddyModule returns the Caddy module information.
1555 func (MatchNot) CaddyModule() caddy.ModuleInfo {
1556 return caddy.ModuleInfo{
@@ -1799,6 +1856,7 @@ var (
1856 _ CELLibraryProducer = (*MatchHeader)(nil)
1857 _ CELLibraryProducer = (*MatchHeaderRE)(nil)
1858 _ CELLibraryProducer = (*MatchProtocol)(nil)
1859 + _ CELLibraryProducer = (*MatchTLS)(nil)
1860 _ CELLibraryProducer = (*VarsMatcher)(nil)
1861 _ CELLibraryProducer = (*MatchVarsRE)(nil)
1862