Index | Thread | Search

From:
Rafael Sadowski <rafael@sizeofvoid.org>
Subject:
relayd 1/3: Add new matching syntax to control the matching rules
To:
tech@openbsd.org
Date:
Fri, 9 Oct 2026 14:18:30 +0200

Download raw body.

Thread
Hi relayd users,

This is one of three diff in a row. The basic idea is described in the
commit message below. During working on the pattern matching, I noticed
that the pattern matching depends on the type and is not documented.

The documentation error has been fixed, but I was still annoyed by the
situation. 1. I want it to be done explicitly rather than implicitly based on
the type. 2. I want to control what I want to match, and I want
the entire pattern log

I think this is how one should deal with it: be explicit rather than
implicit!

Please test it with your setup and test the new keywords.

This manpage changes also include the other two diffs.

Also looking for OKs on the code base!

Rafael

commit f1b77253c65820103f976b4edfd2e6865232c708
Author: Rafael Sadowski <rafael@sizeofvoid.org>
Date:   Fri Sep 18 01:00:15 2026 +0200

    relayd: Add new matching syntax to control the matching rules
    
    Besides "pattern", the cookie, header, path, query and url keys and
    values now accept "glob" and "glob ignorecase".  Without a keyword
    the old per-field defaults stay as they were.
    
    Header names are case-insensitive (RFC 9110), so "glob" on a header
    name is treated as "glob ignorecase".  "pattern" stays case-sensitive.

diff --git a/parse.y b/parse.y
index b21ae26..a317f84 100644
--- a/parse.y
+++ b/parse.y
@@ -125,7 +125,8 @@ static enum key_type	 keytype = KEY_TYPE_NONE;
 static enum direction	 dir = RELAY_DIR_ANY;
 static char		*rulefile = NULL;
 static union hashkey	*hashkey = NULL;
-static int		 value_pattern = 0;
+static u_int16_t	 val_flag = 0;
+static u_int16_t	 key_flag = 0;
 
 struct address	*host_ip(const char *);
 int		 host_dns(const char *, struct addresslist *,
@@ -144,6 +145,7 @@ struct relay	*relay_inherit(struct relay *, struct relay *);
 int		 getservice(char *);
 int		 is_if_in_group(const char *, const char *);
 static struct keyname *proto_keyname(char *);
+static void 	 rule_kv_setvalue(struct kv *, const char *, u_int16_t);
 
 typedef struct {
 	union {
@@ -180,8 +182,9 @@ typedef struct {
 %token	DEMOTE DESTINATION DIGEST DISABLE
 %token	ECDHE EDH ERROR ERRORS EXPECT EXTERNAL
 %token	FILENAME FORWARD FROM
+%token	GLOB
 %token	HASH HEADER HEADERLEN HOST HTTP
-%token	ICMP INCLUDE INET INET6 INTERFACE INTERVAL IP
+%token	ICMP IGNORECASE INCLUDE INET INET6 INTERFACE INTERVAL IP
 %token	KEY KEYPAIR
 %token	LABEL LEASTSTATES LISTEN LOADBALANCE LOG LOOKUP
 %token	MATCH METHOD MODE NAT NO NODELAY NOTHING
@@ -1603,14 +1606,13 @@ ruleopts	: METHOD STRING					{
 			keytype = KEY_TYPE_COOKIE;
 			rule->rule_kv[keytype].kv_key = strdup($4);
 			rule->rule_kv[keytype].kv_option = $2;
-			if ($3)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_KEY_PATTERN;
-			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
-			    strdup($5) : strdup("*"));
-			if (value_pattern)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_VAL_PATTERN;
+
+			/* default (no keyword): literal, case-insensitive. */
+			rule->rule_kv[keytype].kv_flags |= key_flag;
+
+			rule_kv_setvalue(&rule->rule_kv[keytype], $5,
+			    KV_FLAG_VAL_GLOBBING_ICASE);
+
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
@@ -1626,21 +1628,26 @@ ruleopts	: METHOD STRING					{
 		}
 		| HEADER key_option optpattern STRING value		{
 			keytype = KEY_TYPE_HEADER;
-			memset(&rule->rule_kv[keytype], 0,
-			    sizeof(rule->rule_kv[keytype]));
 			rule->rule_kv[keytype].kv_option = $2;
-			if ($3)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_KEY_PATTERN;
 			rule->rule_kv[keytype].kv_key = strdup($4);
-			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
-			    strdup($5) : strdup("*"));
+
+			if (key_flag == KV_FLAG_KEY_GLOBBING)
+				log_warnx("header \"%s\": names are "
+				"case-insensitive (RFC 9110), using glob "
+				"ignorecase", rule->rule_kv[keytype].kv_key);
+
+			if (key_flag == 0 || key_flag == KV_FLAG_KEY_GLOBBING)
+				key_flag = KV_FLAG_KEY_GLOBBING_ICASE;
+
+			rule->rule_kv[keytype].kv_flags |= key_flag;
+
+			rule_kv_setvalue(&rule->rule_kv[keytype], $5,
+			    KV_FLAG_VAL_GLOBBING);
+
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
-			if (value_pattern)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_VAL_PATTERN;
+
 			free($4);
 			if ($5)
 				free($5);
@@ -1654,15 +1661,15 @@ ruleopts	: METHOD STRING					{
 		| PATH key_option optpattern STRING value	{
 			keytype = KEY_TYPE_PATH;
 			rule->rule_kv[keytype].kv_option = $2;
-			if ($3)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_KEY_PATTERN;
+			/* default: path matching is case-sensitive. */
+			if (key_flag == 0)
+				key_flag = KV_FLAG_KEY_GLOBBING;
+			rule->rule_kv[keytype].kv_flags |= key_flag;
 			rule->rule_kv[keytype].kv_key = strdup($4);
-			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
-			    strdup($5) : strdup("*"));
-			if (value_pattern)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_VAL_PATTERN;
+
+			rule_kv_setvalue(&rule->rule_kv[keytype], $5,
+			    KV_FLAG_VAL_GLOBBING_ICASE);
+
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
@@ -1705,15 +1712,15 @@ ruleopts	: METHOD STRING					{
 			}
 			keytype = KEY_TYPE_QUERY;
 			rule->rule_kv[keytype].kv_option = $2;
-			if ($3)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_KEY_PATTERN;
+			/* default: query name matching is case-sensitive. */
+			if (key_flag == 0)
+				key_flag = KV_FLAG_KEY_GLOBBING;
+			rule->rule_kv[keytype].kv_flags |= key_flag;
 			rule->rule_kv[keytype].kv_key = strdup($4);
-			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
-			    strdup($5) : strdup("*"));
-			if (value_pattern)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_VAL_PATTERN;
+
+			rule_kv_setvalue(&rule->rule_kv[keytype], $5,
+			    KV_FLAG_VAL_GLOBBING);
+
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
@@ -1750,16 +1757,14 @@ ruleopts	: METHOD STRING					{
 			}
 			keytype = KEY_TYPE_URL;
 			rule->rule_kv[keytype].kv_option = $2;
-			if ($3)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_KEY_PATTERN;
+			/* default (no keyword): literal, case-insensitive. */
+			rule->rule_kv[keytype].kv_flags |= key_flag;
 			rule->rule_kv[keytype].kv_key = strdup($4.digest);
 			rule->rule_kv[keytype].kv_digest = $4.type;
-			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
-			    strdup($5) : strdup("*"));
-			if (value_pattern)
-				rule->rule_kv[keytype].kv_flags |=
-				    KV_FLAG_VAL_PATTERN;
+
+			rule_kv_setvalue(&rule->rule_kv[keytype], $5,
+			    KV_FLAG_VAL_GLOBBING_ICASE);
+
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
@@ -1919,21 +1924,37 @@ ruleopts	: METHOD STRING					{
 		}
 		;
 
-optpattern	: /* empty */		{ $$ = 0; }
-		| PATTERN		{ $$ = 1; }
-		;
-
-value		: /* empty */		{ $$ = NULL;
-					  value_pattern = 0;
+optpattern	: /* empty */		{
+					  key_flag = 0;
+					}
+		| PATTERN		{
+					  key_flag = KV_FLAG_KEY_PATTERN;
 					}
-		| VALUE STRING		{ $$ = $2;
-					  value_pattern = 0;
+		| GLOB			{
+					  key_flag = KV_FLAG_KEY_GLOBBING;
 					}
-		| VALUE PATTERN STRING	{ $$ = $3;
-					  value_pattern = 1;
+		| GLOB IGNORECASE	{
+					  key_flag = KV_FLAG_KEY_GLOBBING_ICASE;
 					}
 		;
 
+value		: /* empty */			{ $$ = NULL;
+						  val_flag = 0;
+						}
+		| VALUE STRING			{ $$ = $2;
+						  val_flag = 0;
+						}
+		| VALUE PATTERN STRING		{ $$ = $3;
+						  val_flag = KV_FLAG_VAL_PATTERN;
+						}
+		| VALUE GLOB STRING		{ $$ = $3;
+						  val_flag = KV_FLAG_VAL_GLOBBING;
+						}
+		| VALUE GLOB IGNORECASE STRING	{ $$ = $4;
+						  val_flag = KV_FLAG_VAL_GLOBBING_ICASE;
+						}
+		;
+
 key_option	: /* empty */		{ $$ = KEY_OPTION_NONE; }
 		| APPEND		{ $$ = KEY_OPTION_APPEND; }
 		| SET			{ $$ = KEY_OPTION_SET; }
@@ -2607,12 +2628,14 @@ lookup(char *s)
 		{ "file",		FILENAME },
 		{ "forward",		FORWARD },
 		{ "from",		FROM },
+		{ "glob",		GLOB },
 		{ "hash",		HASH },
 		{ "header",		HEADER },
 		{ "headerlen",		HEADERLEN },
 		{ "host",		HOST },
 		{ "http",		HTTP },
 		{ "icmp",		ICMP },
+		{ "ignorecase",		IGNORECASE },
 		{ "include",		INCLUDE },
 		{ "inet",		INET },
 		{ "inet6",		INET6 },
@@ -3798,3 +3821,16 @@ proto_keyname(char *name)
 	TAILQ_INSERT_TAIL(&proto->tlscerts, kname, entry);
 	return kname;
 }
+
+/*
+ * Set the value of a rule kv. An omitted value matches anything,
+ * otherwise use the matching keyword or the per-type default.
+ */
+static void
+rule_kv_setvalue(struct kv *kv, const char *val, u_int16_t dflag)
+{
+	kv->kv_value = strdup(val != NULL ? val : "*");
+	if (kv->kv_value == NULL)
+		fatal("out of memory");
+	kv->kv_flags |= (val_flag != 0) ? val_flag : dflag;
+}
diff --git a/relay_http.c b/relay_http.c
index 043d78e..a802ebe 100644
--- a/relay_http.c
+++ b/relay_http.c
@@ -82,6 +82,7 @@ void		 relay_httpdesc_free(struct http_descriptor *);
 char		*server_root_strip(char *, int);
 int		 relay_http_parse_startline(struct ctl_relay_event *, char *,
     enum httpmethod *);
+static int	 relay_url_log_match(const struct kv *, const char *);
 
 static struct relayd	*env = NULL;
 
@@ -910,15 +911,10 @@ _relay_lookup_url(struct ctl_relay_event *cre, char *host, char *path,
 	log_debug("%s: session %d: %s, %s", __func__, con->se_id,
 	    str, kv->kv_key);
 
-	if (kv->kv_flags & KV_FLAG_KEY_PATTERN) {
-		if (kv_match_key(kv, str, 0)) {
-			log_debug("%s: session %d: pattern \"%s\" matched "
-			    "url \"%s\"",
-			    __func__, con->se_id, kv->kv_key, str);
-			ret = RES_DROP;
-			goto done;
-		}
-	} else if (strcasecmp(kv->kv_key, str) == 0) {
+	if (kv_match_key(kv, str)) {
+		log_debug("%s: session %d: %s key \"%s\" matched url \"%s\"",
+		    __func__, con->se_id, kv_key_flag_str(kv->kv_flags),
+		    kv->kv_key, str);
 		ret = RES_DROP;
 		goto done;
 	}
@@ -1046,11 +1042,8 @@ relay_lookup_cookie(struct ctl_relay_event *cre, const char *str,
 		if (value[strlen(value) - 1] == '"')
 			value[strlen(value) - 1] = '\0';
 
-		if (((kv->kv_flags & KV_FLAG_KEY_PATTERN) ?
-		    kv_match_key(kv, key, 0) :
-		    strcasecmp(kv->kv_key, key) == 0) &&
-		    (kv->kv_value == NULL ||
-		     kv_match_val(kv, value, FNM_CASEFOLD))) {
+		if ((kv_match_key(kv, key)) &&
+		    (kv->kv_value == NULL || kv_match_val(kv, value))) {
 			log_debug("%s: matched cookie \"%s\" value \"%s\" "
 			    "(rule value \"%s\")",
 			    __func__, key, value,
@@ -1095,8 +1088,8 @@ relay_lookup_query(struct ctl_relay_event *cre, struct kv *kv)
 			continue;
 		*tmpval++ = '\0';
 
-		if (kv_match_key(kv, tmpkey, 0) &&
-		    (kv->kv_value == NULL || kv_match_val(kv, tmpval, 0))) {
+		if (kv_match_key(kv, tmpkey) &&
+		    (kv->kv_value == NULL || kv_match_val(kv, tmpval))) {
 			log_debug("%s: matched query key \"%s\" value \"%s\" "
 			    "(rule key \"%s\" value \"%s\")",
 			    __func__, tmpkey, tmpval, kv->kv_key,
@@ -1537,7 +1530,7 @@ relay_httpheader_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 		return (-1);
 	} else {
 		if (kv->kv_value != NULL && match->kv_value != NULL &&
-		    !kv_match_val(kv, match->kv_value, 0)) {
+		    !kv_match_val(kv, match->kv_value)) {
 			log_debug("%s: rule %d: header \"%s\" value mismatch: "
 			    "rule \"%s\" vs. actual \"%s\"",
 			    __func__, rule->rule_id, kv->kv_key,
@@ -1571,7 +1564,7 @@ relay_httppath_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 	else if (kv->kv_option != KEY_OPTION_STRIP) {
 		if (kv->kv_key == NULL)
 			return (0);
-		else if (!kv_match_key(kv, desc->http_path, 0)) {
+		else if (!kv_match_key(kv, desc->http_path)) {
 			log_debug("%s: rule %d: path \"%s\" does not match "
 			    "rule key \"%s\"",
 			    __func__, rule->rule_id, desc->http_path,
@@ -1581,7 +1574,7 @@ relay_httppath_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 		    kv->kv_option == KEY_OPTION_NONE) {
 			query = desc->http_query == NULL ? "" :
 			    desc->http_query;
-			if (!kv_match_val(kv, query, FNM_CASEFOLD)) {
+			if (!kv_match_val(kv, query)) {
 				log_debug("%s: rule %d: query \"%s\" does not "
 				    "match rule value \"%s\"",
 				    __func__, rule->rule_id, query,
@@ -1621,9 +1614,9 @@ relay_httpurl_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 		return (RES_BAD);
 	else if (rule->rule_action != RULE_ACTION_BLOCK &&
 	    kv->kv_option == KEY_OPTION_LOG &&
-	    kv_match_key(kv, match->kv_key, FNM_CASEFOLD)) {
-		log_info("%s: rule %d: url \"%s\" matched",
-		    __func__, rule->rule_id,
+	    relay_url_log_match(kv, match->kv_key)) {
+		log_info("%s: rule %d: url \"%s\" matched", __func__,
+		    rule->rule_id,
 		    match->kv_key ? match->kv_key : "");
 		/* match url only for logging */
 	} else if ((res = relay_lookup_url(cre, host->kv_value, kv)) != 0)
@@ -2242,3 +2235,13 @@ fail:
 	relay_abort_http(con, 500, strerror(errno), 0);
 	return (-1);
 }
+
+static int
+relay_url_log_match(const struct kv *kv, const char *str)
+{
+	if (kv->kv_flags & (KV_FLAG_KEY_PATTERN | KV_FLAG_KEY_GLOBBING |
+	    KV_FLAG_KEY_GLOBBING_ICASE))
+		return (kv_match_key(kv, str));
+	/* url log keys have always been case-insensitive glob(7) patterns */
+	return (fnmatch(kv->kv_key, str, FNM_CASEFOLD) != FNM_NOMATCH);
+}
diff --git a/relayd.c b/relayd.c
index 1c3f682..a906d13 100644
--- a/relayd.c
+++ b/relayd.c
@@ -850,35 +850,63 @@ kv_log(struct rsession *con, struct kv *kv, u_int16_t labelid,
  * Returns 1 on match, 0 on no match or error.
  */
 static int
-_kv_match(const char *pattern, const char *str, int is_pattern, int fnflags)
+_kv_match(const char *pattern, const char *str, u_int16_t matching)
 {
 	struct str_find	 sm;
 	const char	*errstr = NULL;
 
 	if (pattern == NULL || str == NULL)
 		return (0);
-	if (!is_pattern)
-		return (fnmatch(pattern, str, fnflags) != FNM_NOMATCH);
 
-	if (str_find(str, pattern, &sm, 1, &errstr) > 0 && errstr == NULL)
-		return (1);
-	if (errstr != NULL)
-		log_warnx("%s: pattern \"%s\": %s", __func__, pattern, errstr);
-	return (0);
+	if (matching & (KV_FLAG_VAL_PATTERN | KV_FLAG_KEY_PATTERN)) {
+		if (str_find(str, pattern, &sm, 1, &errstr) > 0 &&
+		    errstr == NULL)
+			return (1);
+		if (errstr != NULL)
+			log_warnx("%s: pattern \"%s\": %s", __func__, pattern,
+			    errstr);
+		return (0);
+	}
+	if (matching &
+	    (KV_FLAG_VAL_GLOBBING_ICASE | KV_FLAG_KEY_GLOBBING_ICASE))
+		return (fnmatch(pattern, str, FNM_CASEFOLD) != FNM_NOMATCH);
+
+	if (matching &
+	    (KV_FLAG_VAL_GLOBBING | KV_FLAG_KEY_GLOBBING))
+		return (fnmatch(pattern, str, 0) != FNM_NOMATCH);
+
+	/* no matching keyword: literal, case-insensitive (historic behavior) */
+	return (strcasecmp(pattern, str) == 0);
 }
 
 int
-kv_match_key(const struct kv *kv, const char *str, int fnflags)
+kv_match_key(const struct kv *kv, const char *str)
 {
-	return (_kv_match(kv->kv_key, str, kv->kv_flags & KV_FLAG_KEY_PATTERN,
-	    fnflags));
+	u_int16_t	matching = kv->kv_flags & (KV_FLAG_KEY_PATTERN |
+	    KV_FLAG_KEY_GLOBBING | KV_FLAG_KEY_GLOBBING_ICASE);
+
+	return (_kv_match(kv->kv_key, str, matching));
 }
 
 int
-kv_match_val(const struct kv *kv, const char *str, int fnflags)
+kv_match_val(const struct kv *kv, const char *str)
+{
+	u_int16_t	matching = kv->kv_flags & (KV_FLAG_VAL_PATTERN |
+	    KV_FLAG_VAL_GLOBBING | KV_FLAG_VAL_GLOBBING_ICASE);
+
+	return (_kv_match(kv->kv_value, str, matching));
+}
+
+const char *
+kv_key_flag_str(u_int16_t flags)
 {
-	return (_kv_match(kv->kv_value, str, kv->kv_flags & KV_FLAG_VAL_PATTERN,
-	    fnflags));
+	if (flags & KV_FLAG_KEY_PATTERN)
+		return ("pattern");
+	if (flags & KV_FLAG_KEY_GLOBBING_ICASE)
+		return ("glob ignorecase");
+	if (flags & KV_FLAG_KEY_GLOBBING)
+		return ("glob");
+	return ("literal");
 }
 
 /*
@@ -894,13 +922,14 @@ kv_find(struct kvtree *keys, struct kv *kv)
 	  * If the key uses glob(7) or a patterns(7) expression, fall back
 	  * to a linear scan and match each entry.
 	  */
-	if (kv->kv_flags & (KV_FLAG_GLOBBING | KV_FLAG_KEY_PATTERN)) {
+	if (kv->kv_flags &
+	    (KV_FLAG_KEY_GLOBBING | KV_FLAG_KEY_GLOBBING_ICASE |
+	     KV_FLAG_KEY_PATTERN)) {
 		RB_FOREACH(match, kvtree, keys) {
-			if (kv_match_key(kv, match->kv_key, FNM_CASEFOLD)) {
+			if (kv_match_key(kv, match->kv_key)) {
 				log_debug("%s: %s \"%s\" matched key \"%s\"",
 				    __func__,
-				    (kv->kv_flags & KV_FLAG_KEY_PATTERN) ?
-				    "pattern" : "glob",
+				    kv_key_flag_str(kv->kv_flags),
 				    kv->kv_key, match->kv_key);
 				break;
 			}
@@ -1003,10 +1032,11 @@ rule_add(struct protocol *proto, struct relay_rule *rule, const char *rulefile)
 		    !(kv->kv_flags & KV_FLAG_VAL_PATTERN) &&
 		    strchr(kv->kv_value, '$') != NULL)
 			kv->kv_flags |= KV_FLAG_MACRO;
+		/* Fallback to literal if no metacharacters found. */
 		if (kv->kv_key != NULL &&
-		    !(kv->kv_flags & KV_FLAG_KEY_PATTERN) &&
-		    strpbrk(kv->kv_key, "*?[") != NULL)
-			kv->kv_flags |= KV_FLAG_GLOBBING;
+		    (kv->kv_flags & KV_FLAG_KEY_GLOBBING_ICASE) &&
+		    strpbrk(kv->kv_key, "*?[\\") == NULL)
+			kv->kv_flags &= ~KV_FLAG_KEY_GLOBBING_ICASE;
 	}
 
 	if (rulefile == NULL) {
diff --git a/relayd.conf.5 b/relayd.conf.5
index 07788f4..802999a 100644
--- a/relayd.conf.5
+++ b/relayd.conf.5
@@ -1323,8 +1323,8 @@ $ echo -n "example.com/path/?args" | sha1
 may be one of:
 .Bl -tag -width Ds
 .It Xo Ic cookie Ar option
-.Oo Oo Ic pattern Oc Ar key
-.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Oo Oo Ar matching Oc Ar key
+.Oo Ic value Oo Ar matching Oc Ar value Oc Oc
 .Xc
 Look up the entity as a value in the Cookie header.
 This type is only available with the direction
@@ -1337,9 +1337,19 @@ block request quick cookie "session" value pattern "^admin_"
 # Only accept two-letter language cookies
 pass  request cookie "lang" value pattern "^%a%a$"
 .Ed
+.Pp
+Without a
+.Ar matching
+keyword, the
+.Ar key
+is compared literally and case-insensitively, and the
+.Ar value
+is a case-insensitive
+.Xr glob 7
+pattern.
 .It Xo Ic header Ar option
-.Oo Oo Ic pattern Oc Ar key
-.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Oo Oo Ar matching Oc Ar key
+.Oo Ic value Oo Ar matching Oc Ar value Oc Oc
 .Xc
 Look up the entity in the application protocol headers, like HTTP
 headers in
@@ -1356,12 +1366,35 @@ block request quick header "User-Agent" \e
 pass  request header "Content-Type" \e
     value pattern "^application/json"
 
-# Strip any custom debug header (matches key by pattern)
-pass request header remove pattern "^X%-Debug%-"
+# Strip any custom debug header
+# (pattern is case-sensitive, use classes for RFC 9110)
+pass request header remove pattern "^[Xx]%-[Dd]ebug%-"
 .Ed
+.Pp
+Without a
+.Ar matching
+keyword or with
+.Ic glob ,
+the
+.Ar key
+is treated as a
+.Ic glob ignorecase
+pattern, so header names are matched case-insensitively (RFC 9110).
+.Ic pattern
+on a header
+.Ar key
+is case-sensitive.
+Use character classes such as
+.Dq [Xx]%-[Dd]ebug
+to retain RFC 9110 semantics.
+The
+.Ar value
+defaults to a case-sensitive
+.Xr glob 7
+pattern.
 .It Xo Ic path Ar option
-.Oo Oo Ic pattern Oc Ar key
-.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Oo Oo Ar matching Oc Ar key
+.Oo Ic value Oo Ar matching Oc Ar value Oc Oc
 .Xc
 Look up the entity as a value in the URL path when using the
 .Ic http
@@ -1387,6 +1420,18 @@ pass request path pattern "^/api/v%d+/"
 # Match image extensions
 pass request path pattern "%.jpe?g$"
 .Ed
+.Pp
+Without a
+.Ar matching
+keyword, the
+.Ar key
+is a case-sensitive
+.Xr glob 7
+pattern and the
+.Ar value
+is a case-insensitive
+.Xr glob 7
+pattern.
 .It Ic path  strip Ar number
 Strip
 .Ar number
@@ -1397,8 +1442,8 @@ protocol.
 This type is only available with the direction
 .Ic request .
 .It Xo Ic query Ar option
-.Oo Oo Ic pattern Oc Ar key
-.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Oo Oo Ar matching Oc Ar key
+.Oo Ic value Oo Ar matching Oc Ar value Oc Oc
 .Xc
 Look up the entity as a query variable in the URL when using the
 .Ic http
@@ -1416,9 +1461,19 @@ pass request query "id" value pattern "^%d+$"
 # Reject requests that carry a debug flag on any query key
 block request quick query pattern "^debug" value "*"
 .Ed
+.Pp
+Without a
+.Ar matching
+keyword, the
+.Ar key
+and
+.Ar value
+are case-sensitive
+.Xr glob 7
+patterns.
 .It Xo Ic url Ar option
-.Oo Oo Ic pattern Oc Oo Ic digest Oc Ar key
-.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Oo Oo Ar matching Oc Oo Ic digest Oc Ar key
+.Oo Ic value Oo Ar matching Oc Ar value Oc Oc
 .Xc
 Look up the entity as a URL suffix/prefix expression consisting of a
 canonicalized hostname without port or suffix and a path name or
@@ -1436,6 +1491,23 @@ block url pattern "^example%.com/index%.html$"
 pass request url pattern "^[%w%-]+%.example%.com/api/"
 .Ed
 .Pp
+Without a
+.Ar matching
+keyword, the
+.Ar key
+is compared literally and case-insensitively, and the
+.Ar value
+is a case-insensitive
+.Xr glob 7
+pattern.
+With the
+.Ic log
+option, the
+.Ar key
+instead defaults to a case-insensitive
+.Xr glob 7
+pattern for backward compatibility.
+.Pp
 .Xr relayd 8
 will match the full URL and different possible suffix/prefix
 combinations by stripping subdomains and path components (up to 5
@@ -1459,17 +1531,26 @@ example.com/1/2/3/
 .Ed
 .El
 .Pp
-The optional
-.Ic pattern
-keyword makes the following string a
-.Xr patterns 7
-expression instead of the default
-.Xr glob 7
-pattern.
+An optional
+.Ar matching
+keyword
+.Po
+.Ic pattern ,
+.Ic glob ,
+or
+.Ic glob ignorecase
+.Pc
+selects how the following string is interpreted.
+See
+.Sx Matching options
+below for details.
 It can appear before any matchable string, for example:
 .Bd -literal -offset indent
 pass request path pattern "^/api/v%d+/" \e
     value pattern "^id=%d+$"
+
+pass request header "User-Agent" \e
+    value glob ignorecase "*mozilla*"
 .Ed
 .Pp
 Note that macro expansion is disabled on a
@@ -1485,25 +1566,12 @@ Use a plain
 .Ar value
 if macro expansion is required.
 .Pp
-Matching with
-.Ic pattern
-is always case-sensitive.
-The default
+Use
+.Ic glob ignorecase
+to force case-insensitive
 .Xr glob 7
-matching is case-insensitive for the
-.Ic url
-lookup, for
-.Ic cookie
-names and values, and for
-.Ic header
-names.
-It is case-sensitive for
-.Ic header
-values, for the
-.Ic path
-name, and for
-.Ic query
-names and values, matching how the client sent them on the wire.
+matching on fields that default to case-sensitive.
+The per-type defaults are described with each lookup type above.
 When porting existing rules to
 .Ic pattern ,
 add character classes such as
@@ -1518,34 +1586,54 @@ is omitted, it defaults to
 .Sq *
 and matches any value using
 .Xr glob 7
-rules regardless of whether the key uses
-.Ic pattern .
+rules regardless of which
+.Ar matching
+keyword the key uses.
 .Pp
 For
 .Ic url ,
-.Ic pattern
-on the
+a
+.Ar matching
+keyword on the
 .Ar key
-is tested against every candidate form listed above; anchor patterns
-carefully.
+is tested against every candidate form listed above.
+Anchor
+.Xr patterns 7
+expressions carefully.
 When combined with
 .Ic digest ,
-the pattern applies to the digest string, not to the URL.
+the match applies to the digest string, not to the URL.
 .Pp
-.Ic pattern
-on the
+A
+.Ar matching
+keyword on the
 .Ar key
 is most useful with the
 .Ic remove
 and
 .Ic log
 options.
-It has no effect for
+It is rejected for
 .Ic append
-or
+and
 .Ic set ,
 which create a new entity from a literal name.
 .Pp
+A single rule may contain at most one clause of each lookup type.
+A second
+.Ic cookie ,
+.Ic header ,
+.Ic path ,
+.Ic query ,
+or
+.Ic url
+clause in the same rule is rejected.
+Use
+.Ic tag
+and
+.Ic tagged
+on separate rules to combine such conditions.
+.Pp
 .Bq Ar option
 may be one of:
 .Bl -tag -width Ds
@@ -1557,6 +1645,14 @@ to a protocol entity with the selected
 name.
 If it does not exist, it will be created with the new value.
 .Pp
+The
+.Ar key
+and
+.Ar value
+are written rather than matched, so no
+.Ar matching
+keyword may be given.
+.Pp
 The value string may contain predefined macros that will be expanded
 at runtime:
 .Pp
@@ -1613,7 +1709,41 @@ may contain predefined macros that will be expanded at runtime,
 as detailed for the
 .Ic append
 directive above.
+.Pp
+As with
+.Ic append ,
+no
+.Ar matching
+keyword may be given.
 .El
+.Ss Matching options
+An optional
+.Ar matching
+keyword before a
+.Ar key
+or
+.Ar value
+string selects how the following string is interpreted.
+It may be one of:
+.Bl -tag -width Ds
+.It Ic pattern
+Treat the following string as a
+.Xr patterns 7
+expression.
+Matching is always case-sensitive.
+.It Ic glob
+Treat the following string as a case-sensitive
+.Xr glob 7
+pattern.
+.It Ic glob Ic ignorecase
+Like
+.Ic glob ,
+but match case-insensitively.
+.El
+.Pp
+The behavior without a
+.Ar matching
+keyword depends on the lookup type and is described with each type above.
 .Ss Routers
 Routers represent routing table entries in the kernel forwarding
 database, see
diff --git a/relayd.h b/relayd.h
index 4e055c5..192bc76 100644
--- a/relayd.h
+++ b/relayd.h
@@ -323,11 +323,14 @@ enum digest_type {
 TAILQ_HEAD(kvlist, kv);
 RB_HEAD(kvtree, kv);
 
-#define KV_FLAG_MACRO		 0x01
-#define KV_FLAG_INVALID		 0x02
-#define KV_FLAG_GLOBBING	 0x04
-#define KV_FLAG_KEY_PATTERN	 0x08
-#define KV_FLAG_VAL_PATTERN	 0x10
+#define KV_FLAG_MACRO			0x01
+#define KV_FLAG_INVALID			0x02
+#define KV_FLAG_KEY_PATTERN		0x04
+#define KV_FLAG_KEY_GLOBBING		0x08
+#define KV_FLAG_KEY_GLOBBING_ICASE	0x10
+#define KV_FLAG_VAL_PATTERN		0x20
+#define KV_FLAG_VAL_GLOBBING		0x40
+#define KV_FLAG_VAL_GLOBBING_ICASE	0x80
 
 struct kv {
 	char			*kv_key;
@@ -337,7 +340,8 @@ struct kv {
 	enum key_option		 kv_option;
 	enum digest_type	 kv_digest;
 
-	u_int8_t		 kv_flags;
+	/* 0 = literal, otherwise KV_FLAG_* */
+	u_int16_t		 kv_flags;
 
 	struct kvlist		 kv_children;
 	struct kv		*kv_parent;
@@ -1357,8 +1361,9 @@ int			 kv_log(struct rsession *, struct kv *, u_int16_t,
 struct kv		*kv_find(struct kvtree *, struct kv *);
 struct kv		*kv_find_value(struct kvtree *, char *, const char *,
     const char *);
-int			 kv_match_key(const struct kv *, const char *, int);
-int			 kv_match_val(const struct kv *, const char *, int);
+int			 kv_match_key(const struct kv *, const char *);
+int			 kv_match_val(const struct kv *, const char *);
+const char		*kv_key_flag_str(u_int16_t);
 int			 kv_cmp(struct kv *, struct kv *);
 int			 rule_add(struct protocol *, struct relay_rule *,
     const char *);