Index | Thread | Search

From:
Kirill A. Korinsky <kirill@korins.ky>
Subject:
Re: relayd 1/3: Add new matching syntax to control the matching rules
To:
Rafael Sadowski <rafael@sizeofvoid.org>
Cc:
tech@openbsd.org
Date:
Tue, 20 Oct 2026 23:06:55 +0200

Download raw body.

Thread
On Fri, 09 Oct 2026 14:18:30 +0200,
Rafael Sadowski <rafael@sizeofvoid.org> wrote:
> 
> 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.
>

I though about it and if I understand right how it works I see two behivour
changes:

1. block request method "HEAD" path log
   seems that before it mathces every path, and now it should stop

2. block request path file "/etc/blocked-paths"
   let assume that file of blocked paths has /private/*;
   I think that before it matches /private/a and now it stops

Next that I think may be a bug here. How this should work?

  http protocol "header-test" {
        pass request
        block request quick header pattern "^X" header "Host"
  }

  relay "header-test" {
        listen on 127.0.0.1 port 8080
        protocol "header-test"
        forward to 127.0.0.1 port 8000
  }

If I not mistaken before the patch both request with:

Host: example.com

and

host: example.com

leads to 403, after the patch lowercased is passed.

If I recall right HTTP states that header key is case insensetive.

But, again, I may miss something here.

> 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 *);
> 

-- 
wbr, Kirill