- Fix #132: Add harden-cname-follow: yes config option, that

can be used to disable cname scrubbing. That can be useful
  for when the traffic is on localhost only.
This commit is contained in:
W.C.A. Wijngaards
2026-08-24 16:06:17 +02:00
parent 7fb79b0677
commit 2ec2afd6b0
15 changed files with 297 additions and 8 deletions
+1
View File
@@ -6373,6 +6373,7 @@ fr_atomic_copy_cfg(struct config_file* oldcfg, struct config_file* cfg,
COPY_VAR_int(harden_referral_path);
COPY_VAR_int(harden_algo_downgrade);
COPY_VAR_int(harden_unknown_additional);
COPY_VAR_int(harden_cname_follow);
COPY_VAR_int(use_caps_bits_for_id);
COPY_VAR_ptr(caps_whitelist);
COPY_VAR_ptr(private_address);
+5
View File
@@ -1,3 +1,8 @@
24 August 2026: Wouter
- Fix #132: Add `harden-cname-follow: yes` config option, that
can be used to disable cname scrubbing. That can be useful
for when the traffic is on localhost only.
21 August 2026: Wouter
- Fix to defend against double event deletion, that could cause
event corruption and use after free, for comm_timer_disable.
+3
View File
@@ -582,6 +582,9 @@ server:
# additional section.
# harden-unknown-additional: no
# Harden CNAME redirections by following them.
# harden-cname-follow: yes
# Sent minimum amount of information to upstream servers to enhance
# privacy. Only sent minimum required labels of the QNAME and set QTYPE
# to A when possible.
+11
View File
@@ -1952,6 +1952,17 @@ These options are part of the ``server:`` section.
Default: no
@@UAHL@unbound.conf@harden-cname-follow@@: *<yes or no>*
Harden CNAME redirections by following them.
If no, then upstream CNAME and DNAME redirections are allowed in a
response without checking with further messages if those are valid.
It can only really be disabled safely on localhost net or encrypted
connectivity.
Default is on to protect the cache integrity.
Default: yes
@@UAHL@unbound.conf@use-caps-for-id@@: *<yes or no>*
Use 0x20-encoded random bits in the query to foil spoof attempts.
This perturbs the lowercase and uppercase of query names sent to authority
+19 -1
View File
@@ -46,6 +46,7 @@
#include "services/cache/dns.h"
#include "util/net_help.h"
#include "util/data/dname.h"
#include "util/config_file.h"
#include "sldns/rrdef.h"
#include "sldns/pkthdr.h"
@@ -107,7 +108,8 @@ response_type_from_cache(struct dns_msg* msg,
enum response_type
response_type_from_server(int rdset,
struct dns_msg* msg, struct query_info* request, struct delegpt* dp,
int* empty_nodata_found, int msg_lame_empty, int msg_lame_referral)
int* empty_nodata_found, int msg_lame_empty, int msg_lame_referral,
struct config_file* cfg)
{
uint8_t* origzone = (uint8_t*)"\000"; /* the default */
struct ub_packed_rrset_key* s;
@@ -130,6 +132,16 @@ response_type_from_server(int rdset,
if( (msg->rep->flags&BIT_RA) &&
!(msg->rep->flags&BIT_AA) && !rdset)
return RESPONSE_TYPE_REC_LAME;
if(!cfg->harden_cname_follow /* follow CNAME chain */) {
/* If the CNAME chain is allowed, see if there is a
* SOA record, if so, the chain is complete to the
* end of it, otherwise it could be a partial chain.*/
for(i=msg->rep->an_numrrsets; i<msg->rep->an_numrrsets+msg->rep->ns_numrrsets; i++) {
s = msg->rep->rrsets[i];
if(ntohs(s->rk.type) == LDNS_RR_TYPE_SOA)
return RESPONSE_TYPE_ANSWER;
}
}
/* it could be a CNAME with NXDOMAIN rcode */
for(i=0; i<msg->rep->an_numrrsets; i++) {
s = msg->rep->rrsets[i];
@@ -162,6 +174,7 @@ response_type_from_server(int rdset,
if(msg->rep->an_numrrsets > 0) {
uint8_t* mname = request->qname;
size_t mname_len = request->qname_len;
int sawanswer = 0;
/* Now look at the answer section first. 3 states: our
* answer is there directly, our answer is there after
@@ -197,6 +210,7 @@ response_type_from_server(int rdset,
* the answer, we only provisionally say
* 'ANSWER' -- it very well could be a
* REFERRAL. */
sawanswer = 1;
break;
}
@@ -214,6 +228,10 @@ response_type_from_server(int rdset,
* still got to here, then it is a CNAME response.
* (This is regardless of the AA bit at this point) */
if(mname != request->qname) {
if(!cfg->harden_cname_follow /* allow CNAMEs */ &&
sawanswer /* The last was not CNAME, and
there is an answer RRset. */ )
return RESPONSE_TYPE_ANSWER;
return RESPONSE_TYPE_CNAME;
}
}
+5 -2
View File
@@ -45,6 +45,7 @@
struct dns_msg;
struct query_info;
struct delegpt;
struct config_file;
/**
* The response type is used to interpret the response.
@@ -115,7 +116,7 @@ enum response_type response_type_from_cache(struct dns_msg* msg,
* detection, mostly).
*
* @param rdset: if RD bit was sent in query sent by unbound.
* @param msg: the message from the cache.
* @param msg: the message.
* @param request: the request that generated the response.
* @param dp: The delegation point that was being queried
* when the response was returned.
@@ -124,10 +125,12 @@ enum response_type response_type_from_cache(struct dns_msg* msg,
* is lame, before it became empty.
* @param msg_lame_referral: returned true if the reply has a referral before
* scrub.
* @param cfg: config with options.
* @return the response type (CNAME or ANSWER).
*/
enum response_type response_type_from_server(int rdset,
struct dns_msg* msg, struct query_info* request, struct delegpt* dp,
int* empty_nodata_found, int msg_lame_empty, int msg_lame_referral);
int* empty_nodata_found, int msg_lame_empty, int msg_lame_referral,
struct config_file* cfg);
#endif /* ITERATOR_ITER_RESPTYPE_H */
+26 -2
View File
@@ -517,7 +517,7 @@ scrub_normalize(sldns_buffer* pkt, struct msg_parse* msg,
* server, scrub down the length to something
* shorter. This deletes everything after the limit
* is reached. The iterator is going to look up
* the content one by one anyway. */
* the content one by one, if harden-cname-follow . */
remove_rrset("normalize: removing because too many cnames:",
pkt, msg, prev, &rrset);
continue;
@@ -1021,6 +1021,8 @@ scrub_sanitize(sldns_buffer* pkt, struct msg_parse* msg,
uint8_t* ns_rrset_dname = NULL;
int added_rrlen_ede = 0;
struct rrset_parse* rrset, *prev;
uint8_t* sname = qinfo->qname;
size_t snamelen = qinfo->qname_len;
prev = NULL;
rrset = msg->rrset_first;
@@ -1028,6 +1030,7 @@ scrub_sanitize(sldns_buffer* pkt, struct msg_parse* msg,
* it can be used from the cache. After normalization, an initial
* DNAME will have a correctly synthesized CNAME after it. */
if(rrset && rrset->type == LDNS_RR_TYPE_DNAME &&
env->cfg->harden_cname_follow /* CNAME chain is cut off, one DNAME is allowed here. */ &&
rrset->section == LDNS_SECTION_ANSWER &&
pkt_strict_sub(pkt, qinfo->qname, rrset->dname) &&
pkt_sub(pkt, rrset->dname, zonename)) {
@@ -1043,12 +1046,33 @@ scrub_sanitize(sldns_buffer* pkt, struct msg_parse* msg,
* ANY queries get query name in answer section.
* Remainders of CNAME chains are cut off and resolved by iterator. */
while(rrset && rrset->section == LDNS_SECTION_ANSWER) {
if(dname_pkt_compare(pkt, qinfo->qname, rrset->dname) != 0) {
if(!env->cfg->harden_cname_follow /* CNAME chain is allowed to stay */ &&
rrset->type == LDNS_RR_TYPE_DNAME &&
pkt_strict_sub(pkt, sname, rrset->dname) &&
pkt_sub(pkt, rrset->dname, zonename)) {
/* This DNAME is allowed to stay, the synthesized
* CNAME follows next. */
prev = rrset;
rrset = rrset->rrset_all_next;
continue;
}
if(dname_pkt_compare(pkt, sname, rrset->dname) != 0) {
if(has_additional(rrset->type)) del_addi = 1;
remove_rrset("sanitize: removing extraneous answer "
"RRset:", pkt, msg, prev, &rrset);
continue;
}
if(!env->cfg->harden_cname_follow /* CNAME chain is allowed to stay */ &&
qinfo->qtype != LDNS_RR_TYPE_ANY &&
rrset->type == LDNS_RR_TYPE_CNAME &&
dname_pkt_compare(pkt, sname, rrset->dname) == 0) {
/* Follow the CNAME chain, and allow the elements
* that match the CNAME chain. Also allow a DNAME
* in front. */
if(!parse_get_cname_target(rrset, &sname, &snamelen,
pkt))
return 0;
}
prev = rrset;
rrset = rrset->rrset_all_next;
}
+25
View File
@@ -1712,3 +1712,28 @@ deleg_port_number(struct module_env* env)
return env->cfg->ssl_port;
return -1;
}
void
shorten_answer_cname(struct reply_info* rep, uint8_t* cutoff)
{
size_t i, found = 0, removenum;
uint8_t* sname = NULL;
size_t snamelen = 0;
for(i=0; i<rep->an_numrrsets; i++) {
if(query_dname_compare(rep->rrsets[i]->rk.dname, cutoff) == 0) {
found = 1;
removenum = i;
break;
}
if(ntohs(rep->rrsets[i]->rk.type) == LDNS_RR_TYPE_CNAME) {
get_cname_target(rep->rrsets[i], &sname, &snamelen);
if(query_dname_compare(sname, cutoff) == 0) {
found = 1;
removenum = i+1;
break;
}
}
}
if(!found) return; /* not found */
val_reply_remove_answers(rep, removenum, rep->an_numrrsets-removenum);
}
+3
View File
@@ -486,4 +486,7 @@ void iter_make_minimal(struct reply_info* rep);
/** See if we need a different port number */
int deleg_port_number(struct module_env* env);
/** Shorten reply CNAME chain to cutoff, cutoff is excluded. */
void shorten_answer_cname(struct reply_info* rep, uint8_t* cutoff);
#endif /* ITERATOR_ITER_UTILS_H */
+43 -2
View File
@@ -3189,7 +3189,7 @@ processQueryResponse(struct module_qstate* qstate, struct iter_qstate* iq,
type = response_type_from_server(
(int)((iq->chase_flags&BIT_RD) || iq->chase_to_rd),
iq->response, &iq->qinfo_out, iq->dp, &iq->empty_nodata_found,
iq->msg_lame_empty, iq->msg_lame_referral);
iq->msg_lame_empty, iq->msg_lame_referral, qstate->env->cfg);
iq->chase_to_rd = 0;
/* remove TC flag, if this is erroneously set by TCP upstream */
iq->response->rep->flags &= ~BIT_TC;
@@ -3302,6 +3302,47 @@ processQueryResponse(struct module_qstate* qstate, struct iter_qstate* iq,
* to send another query with a new qtype. */
type = RESPONSE_TYPE_ANSWER;
}
if(type == RESPONSE_TYPE_ANSWER &&
!qstate->env->cfg->harden_cname_follow /* cname chain from upstream is allowed */ &&
qstate->env->auth_zones &&
/* Check for CNAMEs in answer and RPZ after the CNAME. */
reply_find_rrset_section_an(
iq->response->rep, iq->qchase.qname,
iq->qchase.qname_len, LDNS_RR_TYPE_CNAME,
iq->qchase.qclass) != NULL) {
/* If this is an answer with CNAMEs in front, and
* RPZ wants to modify after CNAME(s), cut off, the
* remainder, and treat as the CNAME response */
size_t i;
uint8_t* origname = iq->qchase.qname;
size_t orignamelen = iq->qchase.qname_len;
for(i=0; i<iq->response->rep->an_numrrsets; i++) {
struct dns_msg* forged_response;
if(ntohs(iq->response->rep->rrsets[i]->rk.type) ==
LDNS_RR_TYPE_DNAME) {
continue;
}
if(ntohs(iq->response->rep->rrsets[i]->rk.type) !=
LDNS_RR_TYPE_CNAME) {
break;
}
get_cname_target(iq->response->rep->rrsets[i],
&iq->qchase.qname, &iq->qchase.qname_len);
forged_response = rpz_callback_from_iterator_cname(qstate, iq);
if(forged_response) {
/* Cut off the answer section at this point.
* RPZ is going to make an answer, in the
* processInit after the CNAME(s) in front
* are handled. */
shorten_answer_cname(iq->response->rep,
iq->qchase.qname);
type = RESPONSE_TYPE_CNAME;
break;
}
}
iq->qchase.qname = origname;
iq->qchase.qname_len = orignamelen;
}
/* handle each of the type cases */
if(type == RESPONSE_TYPE_ANSWER) {
@@ -3804,7 +3845,7 @@ processPrimeResponse(struct module_qstate* qstate, int id)
type = response_type_from_server(
(int)((iq->chase_flags&BIT_RD) || iq->chase_to_rd),
iq->response, &iq->qchase, iq->dp, NULL, iq->msg_lame_empty,
iq->msg_lame_referral);
iq->msg_lame_referral, qstate->env->cfg);
if(type == RESPONSE_TYPE_ANSWER) {
qstate->return_rcode = LDNS_RCODE_NOERROR;
qstate->return_msg = iq->response;
+137
View File
@@ -0,0 +1,137 @@
; This is a comment.
; config options go here.
server:
# allow cname chains from the upstream.
harden-cname-follow: no
forward-zone: name: "." forward-addr: 216.0.0.1
CONFIG_END
SCENARIO_BEGIN Forward and follow CNAME chain.
RANGE_BEGIN 0 100
ENTRY_BEGIN
MATCH opcode qtype qname
ADJUST copy_id
REPLY QR RD RA NOERROR
SECTION QUESTION
www.example.com. IN A
SECTION ANSWER
www.example.com. IN A 10.20.30.40
SECTION AUTHORITY
www.example.com. IN NS ns.example.com.
www.example.com. IN NS ns.example.com.
www.example.com. IN NS ns.example.com.
SECTION ADDITIONAL
ns.example.com. IN A 10.20.30.50
ENTRY_END
ENTRY_BEGIN
MATCH opcode qtype qname
ADJUST copy_id
REPLY QR RD RA NOERROR
SECTION QUESTION
www2.example.com. IN A
SECTION ANSWER
www2.example.com. IN CNAME b.ex2.com.
b.ex2.com. IN A 10.20.30.42
ENTRY_END
ENTRY_BEGIN
MATCH opcode qtype qname
ADJUST copy_id
REPLY QR RD RA NOERROR
SECTION QUESTION
www3.example.com. IN A
SECTION ANSWER
www3.example.com. IN CNAME b.ex2.com.
b.ex2.com. IN CNAME b.ex3.com.
b.ex3.com. IN A 10.20.30.43
ENTRY_END
ENTRY_BEGIN
MATCH opcode qtype qname
ADJUST copy_id
REPLY QR RD RA NOERROR
SECTION QUESTION
www4.example.com. IN A
SECTION ANSWER
www4.example.com. IN CNAME b4.ex2.com.
b4.ex2.com. IN CNAME b4.ex3.com.
b4.ex3.com. IN CNAME b4.ex4.com.
b4.ex4.com. IN A 10.20.30.44
ENTRY_END
RANGE_END
STEP 1 QUERY
ENTRY_BEGIN
REPLY RD
SECTION QUESTION
www.example.com. IN A
ENTRY_END
STEP 4 CHECK_ANSWER
ENTRY_BEGIN
MATCH opcode qname qtype
SECTION QUESTION
www.example.com. IN A
SECTION ANSWER
www.example.com. IN A 10.20.30.40
ENTRY_END
STEP 11 QUERY
ENTRY_BEGIN
REPLY RD
SECTION QUESTION
www2.example.com. IN A
ENTRY_END
STEP 14 CHECK_ANSWER
ENTRY_BEGIN
MATCH opcode qname qtype
SECTION QUESTION
www2.example.com. IN A
SECTION ANSWER
www2.example.com. IN CNAME b.ex2.com.
b.ex2.com. IN A 10.20.30.42
ENTRY_END
STEP 21 QUERY
ENTRY_BEGIN
REPLY RD
SECTION QUESTION
www3.example.com. IN A
ENTRY_END
STEP 24 CHECK_ANSWER
ENTRY_BEGIN
MATCH opcode qname qtype
SECTION QUESTION
www3.example.com. IN A
SECTION ANSWER
www3.example.com. IN CNAME b.ex2.com.
b.ex2.com. IN CNAME b.ex3.com.
b.ex3.com. IN A 10.20.30.43
ENTRY_END
STEP 31 QUERY
ENTRY_BEGIN
REPLY RD
SECTION QUESTION
www4.example.com. IN A
ENTRY_END
STEP 34 CHECK_ANSWER
ENTRY_BEGIN
MATCH opcode qname qtype
SECTION QUESTION
www4.example.com. IN A
SECTION ANSWER
www4.example.com. IN CNAME b4.ex2.com.
b4.ex2.com. IN CNAME b4.ex3.com.
b4.ex3.com. IN CNAME b4.ex4.com.
b4.ex4.com. IN A 10.20.30.44
ENTRY_END
SCENARIO_END
+3
View File
@@ -252,6 +252,7 @@ config_create(void)
cfg->harden_referral_path = 0;
cfg->harden_algo_downgrade = 0;
cfg->harden_unknown_additional = 0;
cfg->harden_cname_follow = 1;
cfg->use_caps_bits_for_id = 0;
cfg->caps_whitelist = NULL;
cfg->private_address = NULL;
@@ -737,6 +738,7 @@ int config_set_option(struct config_file* cfg, const char* opt,
else S_YNO("harden-referral-path:", harden_referral_path)
else S_YNO("harden-algo-downgrade:", harden_algo_downgrade)
else S_YNO("harden-unknown-additional:", harden_unknown_additional)
else S_YNO("harden-cname-follow:", harden_cname_follow)
else S_YNO("use-caps-for-id:", use_caps_bits_for_id)
else S_STRLIST("caps-whitelist:", caps_whitelist)
else S_SIZET_OR_ZERO("unwanted-reply-threshold:", unwanted_threshold)
@@ -1240,6 +1242,7 @@ config_get_option(struct config_file* cfg, const char* opt,
else O_YNO(opt, "harden-referral-path", harden_referral_path)
else O_YNO(opt, "harden-algo-downgrade", harden_algo_downgrade)
else O_YNO(opt, "harden-unknown-additional", harden_unknown_additional)
else O_YNO(opt, "harden-cname-follow", harden_cname_follow)
else O_YNO(opt, "use-caps-for-id", use_caps_bits_for_id)
else O_LST(opt, "caps-whitelist", caps_whitelist)
else O_DEC(opt, "unwanted-reply-threshold", unwanted_threshold)
+2
View File
@@ -311,6 +311,8 @@ struct config_file {
/** harden against unknown records in the authority section and in
* the additional section */
int harden_unknown_additional;
/** harden to follow cname redirections */
int harden_cname_follow;
/** use 0x20 bits in query as random ID bits */
int use_caps_bits_for_id;
/** 0x20 whitelist, domains that do not use capsforid */
+1
View File
@@ -323,6 +323,7 @@ harden-below-nxdomain{COLON} { YDVAR(1, VAR_HARDEN_BELOW_NXDOMAIN) }
harden-referral-path{COLON} { YDVAR(1, VAR_HARDEN_REFERRAL_PATH) }
harden-algo-downgrade{COLON} { YDVAR(1, VAR_HARDEN_ALGO_DOWNGRADE) }
harden-unknown-additional{COLON} { YDVAR(1, VAR_HARDEN_UNKNOWN_ADDITIONAL) }
harden-cname-follow{COLON} { YDVAR(1, VAR_HARDEN_CNAME_FOLLOW) }
use-caps-for-id{COLON} { YDVAR(1, VAR_USE_CAPS_FOR_ID) }
caps-whitelist{COLON} { YDVAR(1, VAR_CAPS_WHITELIST) }
caps-exempt{COLON} { YDVAR(1, VAR_CAPS_WHITELIST) }
+13 -1
View File
@@ -219,6 +219,7 @@ extern struct config_parser_state* cfg_parser;
%token VAR_MAX_TRANSFER_SIZE VAR_MAX_TRANSFER_TIME
%token VAR_MAX_GLOBAL_QUOTA VAR_HARDEN_UNVERIFIED_GLUE VAR_LOG_TIME_ISO
%token VAR_ITER_SCRUB_PROMISCUOUS VAR_LOG_THREAD_ID
%token VAR_HARDEN_CNAME_FOLLOW
%%
toplevelvars: /* empty */ | toplevelvars toplevelvar ;
@@ -361,7 +362,8 @@ content_server: server_num_threads | server_verbosity | server_port |
server_log_destaddr | server_cookie_secret_file |
server_iter_scrub_ns | server_iter_scrub_cname | server_max_global_quota |
server_iter_scrub_rrsig |
server_harden_unverified_glue | server_log_time_iso | server_iter_scrub_promiscuous
server_harden_unverified_glue | server_log_time_iso | server_iter_scrub_promiscuous |
server_harden_cname_follow
;
stub_clause: stubstart contents_stub
{
@@ -1947,6 +1949,16 @@ server_harden_unknown_additional: VAR_HARDEN_UNKNOWN_ADDITIONAL STRING_ARG
free($2);
}
;
server_harden_cname_follow: VAR_HARDEN_CNAME_FOLLOW STRING_ARG
{
OUTYY(("P(server_harden_cname_follow:%s)\n", $2));
if(strcmp($2, "yes") != 0 && strcmp($2, "no") != 0)
yyerror("expected yes or no.");
else cfg_parser->cfg->harden_cname_follow =
(strcmp($2, "yes")==0);
free($2);
}
;
server_use_caps_for_id: VAR_USE_CAPS_FOR_ID STRING_ARG
{
OUTYY(("P(server_use_caps_for_id:%s)\n", $2));