Skip to content

Commit 373cab9

Browse files
committed
Truncation 2.0
Details in huge comment in code
1 parent a7b4b1d commit 373cab9

3 files changed

Lines changed: 107 additions & 74 deletions

File tree

src/dns_truncate.c

Lines changed: 82 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <stdint.h>
44
#include <stdlib.h>
55
#include <string.h>
6+
#include <sys/types.h>
67

78
#include "dns_common.h"
89
#include "dns_truncate.h"
@@ -13,104 +14,127 @@
1314
// RFC1035 4.2.1 default of 512 if the request can't be parsed or the OPT
1415
// advertises a smaller size (RFC6891 4.3 mandates that values below 512
1516
// MUST be treated as 512).
17+
// Using c-ares' DNS parser for convenience and robustness, since the client's
18+
// request can not be trusted.
1619
static uint16_t get_edns_udp_size(const char *dns_req, const size_t dns_req_len) {
1720
ares_dns_record_t *dnsrec = NULL;
18-
ares_status_t parse_status = ares_dns_parse((const unsigned char *)dns_req, dns_req_len, 0, &dnsrec);
21+
ares_status_t parse_status = ares_dns_parse((const unsigned char *)dns_req, dns_req_len,
22+
ARES_DNS_PARSE_AN_BASE_RAW | ARES_DNS_PARSE_NS_BASE_RAW, // for faster parsing
23+
&dnsrec);
1924
if (parse_status != ARES_SUCCESS) {
20-
WLOG("Failed to parse DNS request: %s", ares_strerror((int)parse_status));
25+
const uint16_t req_id = ntohs(*((uint16_t*)dns_req));
26+
WLOG("%04hX: Failed to parse DNS request: %s", req_id, ares_strerror((int)parse_status));
2127
return DNS_SIZE_LIMIT;
2228
}
23-
const uint16_t tx_id = ares_dns_record_get_id(dnsrec);
29+
const uint16_t req_id = ares_dns_record_get_id(dnsrec);
2430
uint16_t udp_size = 0;
2531
const size_t record_count = ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_ADDITIONAL);
2632
for (size_t i = 0; i < record_count; ++i) {
2733
const ares_dns_rr_t *rr = ares_dns_record_rr_get(dnsrec, ARES_SECTION_ADDITIONAL, i);
2834
if (ares_dns_rr_get_type(rr) == ARES_REC_TYPE_OPT) {
2935
udp_size = ares_dns_rr_get_u16(rr, ARES_RR_OPT_UDP_SIZE);
3036
if (udp_size > 0) {
31-
DLOG("%04hX: Found EDNS0 UDP buffer size: %u", tx_id, udp_size);
37+
DLOG("%04hX: Found EDNS0 UDP buffer size: %u", req_id, udp_size);
3238
}
3339
break;
3440
}
3541
}
3642
ares_dns_record_destroy(dnsrec);
3743
if (udp_size < DNS_SIZE_LIMIT) {
38-
DLOG("%04hX: EDNS0 UDP buffer size %u overruled to %d", tx_id, udp_size, DNS_SIZE_LIMIT);
44+
DLOG("%04hX: EDNS0 UDP buffer size %u overruled to %d", req_id, udp_size, DNS_SIZE_LIMIT);
3945
return DNS_SIZE_LIMIT;
4046
}
4147
return udp_size;
4248
}
4349

44-
static void truncate_to_size_limit(char *buf, size_t *buflen, const uint16_t size_limit) {
50+
/*
51+
* @brief Truncates a DNS response in-place to a skeleton packet to force an immediate TCP fallback.
52+
*
53+
* @param buf Pointer to the raw DNS message buffer.
54+
* @param buflen The actual size of the data currently in the buffer.
55+
* Will be set to the new truncated size after processing.
56+
* @param size_limit The desired maximum size (e.g. 512).
57+
*
58+
* @section reasoning Architectural Reasoning & RFC Compliance:
59+
*
60+
* 1. TC Bit Enforcement (RFC 1035):
61+
* Sets the Truncation bit (buf[2] |= 0x02) unconditionally when payload data is cleared.
62+
* According to RFC 1035, the primary directive given to a resolver when it catches a packet
63+
* with TC = 1 is that it must discard the UDP response data and immediately retry the query
64+
* over a reliable transport (TCP). Because the client throws away the packet anyway, returning
65+
* an empty data section completely satisfies the protocol's intent.
66+
*
67+
* 2. Total Section Cleardown (Deterministic Atomicity & RFC 2181):
68+
* RFC 2181, Section 5.2, introduces the concept of RRSet Atomicity, stating that all records
69+
* belonging to the same name, class, and type must be treated as a single cohesive unit.
70+
* Instead of complex, error-prone progressive backtracking loops that risk partial RRSet exposure
71+
* (which can cause intermediary resolvers to incorrectly cache incomplete data), this engine
72+
* clears the ANCount, NSCount, and non-OPT ARCount fields to 0. Wiping all records
73+
* uniformly ensures zero data corruption risk, as a set of 0 records cannot violate atomicity.
74+
*
75+
* 3. EDNS0/OPT Preservation (RFC 6891):
76+
* The OPT pseudo-RR (Type 41) is critical for extended error tracking, cookies, and DNSSEC signaling.
77+
* RFC 6891 mandates that OPT records should be preserved in truncated messages if they were present
78+
* in the request. This function scans the Additional section, locates the OPT record, and uses
79+
* memmove() to safely relocate it to sit directly flush against the end of the Question section,
80+
* preserving it in the truncated response stream.
81+
*
82+
* 4. Trusted Data Assumption:
83+
* DoH resolver response is considered trusted input, so assuming that it complies with RFCs
84+
* and is well-formed.
85+
*
86+
*/
87+
static void truncate_to_size_limit(uint8_t *buf, size_t *buflen, size_t size_limit) {
4588
const size_t old_size = *buflen;
4689
buf[2] |= 0x02; // anyway: set truncation flag
4790

4891
ares_dns_record_t *dnsrec = NULL;
49-
ares_status_t status = ares_dns_parse((const unsigned char *)buf, *buflen, 0, &dnsrec);
92+
ares_status_t status = ares_dns_parse((const unsigned char *)buf, *buflen,
93+
ARES_DNS_PARSE_AN_BASE_RAW | ARES_DNS_PARSE_NS_BASE_RAW, // for faster parsing
94+
&dnsrec);
5095
if (status != ARES_SUCCESS) {
51-
WLOG("Failed to parse DNS response: %s", ares_strerror((int)status));
96+
const uint16_t resp_id = ntohs(*((uint16_t*)buf));
97+
WLOG("%04hX: Failed to parse DNS response: %s", resp_id, ares_strerror((int)status));
5298
return;
5399
}
54-
const uint16_t tx_id = ares_dns_record_get_id(dnsrec);
100+
const uint16_t resp_id = ares_dns_record_get_id(dnsrec);
55101

56-
// NOTE: according to current c-ares implementation, removing first or last elements are the fastest!
102+
// NOTE: according to current c-ares implementation, removing last element is the fastest!
57103

58-
// remove every additional and authority record
59-
while (ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_ADDITIONAL) > 0) {
60-
status = ares_dns_record_rr_del(dnsrec, ARES_SECTION_ADDITIONAL, 0);
104+
// Remove every answer and authority record
105+
for (size_t i = ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_ANSWER); i > 0; i--) {
106+
status = ares_dns_record_rr_del(dnsrec, ARES_SECTION_ANSWER, i - 1);
61107
if (status != ARES_SUCCESS) {
62-
WLOG("%04hX: Could not remove additional record: %s", tx_id, ares_strerror((int)status));
108+
WLOG("%04hX: Could not remove answer record: %s", resp_id, ares_strerror((int)status));
63109
}
64110
}
65-
while (ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_AUTHORITY) > 0) {
66-
status = ares_dns_record_rr_del(dnsrec, ARES_SECTION_AUTHORITY, 0);
111+
for (size_t i = ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_AUTHORITY); i > 0; i--) {
112+
status = ares_dns_record_rr_del(dnsrec, ARES_SECTION_AUTHORITY, i - 1);
67113
if (status != ARES_SUCCESS) {
68-
WLOG("%04hX: Could not remove authority record: %s", tx_id, ares_strerror((int)status));
114+
WLOG("%04hX: Could not remove authority record: %s", resp_id, ares_strerror((int)status));
69115
}
70116
}
71-
72-
// rough estimate to reach size limit
73-
size_t answers = ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_ANSWER);
74-
size_t answers_to_keep = ((size_limit - DNS_HEADER_LENGTH) * answers) / old_size;
75-
answers_to_keep = answers_to_keep > 0 ? answers_to_keep : 1; // try to keep 1 answer
76-
77-
// remove answer records until fit size limit or running out of answers
78-
unsigned char *new_resp = NULL;
79-
size_t new_resp_len = 0;
80-
for (uint8_t g = 0; g < UINT8_MAX; ++g) { // endless loop guard
81-
status = ares_dns_write(dnsrec, &new_resp, &new_resp_len);
82-
if (status != ARES_SUCCESS) {
83-
WLOG("%04hX: Failed to create truncated DNS response: %s", tx_id, ares_strerror((int)status));
84-
new_resp = NULL; // just to be sure
85-
break;
86-
}
87-
if (new_resp_len < size_limit || answers == 0) {
88-
break;
117+
// Remove every additional record except OPT
118+
for (size_t i = ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_ADDITIONAL); i > 0; i--) {
119+
const ares_dns_rr_t *rr = ares_dns_record_rr_get(dnsrec, ARES_SECTION_ADDITIONAL, i - 1);
120+
if (ares_dns_rr_get_type(rr) == ARES_REC_TYPE_OPT) {
121+
continue; // skip removing OPT, removing records before will be unoptimal
89122
}
90-
if (new_resp_len >= old_size) {
91-
WLOG("%04hX: Truncated DNS response size larger or equal to original: %u >= %u",
92-
tx_id, new_resp_len, old_size); // impossible?
123+
status = ares_dns_record_rr_del(dnsrec, ARES_SECTION_ADDITIONAL, i - 1);
124+
if (status != ARES_SUCCESS) {
125+
WLOG("%04hX: Could not remove additional record: %s", resp_id, ares_strerror((int)status));
93126
}
94-
ares_free_string(new_resp);
95-
new_resp = NULL;
127+
}
96128

97-
DLOG("%04hX: DNS response size truncated from %u to %u but to keep %u limit reducing answers from %u to %u",
98-
tx_id, old_size, new_resp_len, size_limit, answers, answers_to_keep);
129+
unsigned char *new_resp = NULL;
130+
size_t new_resp_len = 0;
131+
status = ares_dns_write(dnsrec, &new_resp, &new_resp_len);
99132

100-
while (answers > answers_to_keep) {
101-
status = ares_dns_record_rr_del(dnsrec, ARES_SECTION_ANSWER, answers - 1);
102-
if (status != ARES_SUCCESS) {
103-
WLOG("%04hX: Could not remove answer record: %s", tx_id, ares_strerror((int)status));
104-
break;
105-
}
106-
--answers;
107-
}
108-
answers = ares_dns_record_rr_cnt(dnsrec, ARES_SECTION_ANSWER); // update to be sure!
109-
answers_to_keep /= 2;
110-
}
111133
ares_dns_record_destroy(dnsrec);
112134

113-
if (new_resp == NULL) {
135+
if (status != ARES_SUCCESS || new_resp == NULL || new_resp_len == 0) {
136+
WLOG("%04hX: Failed to create truncated DNS response: %s (new_resp=%p, new_resp_len=%zu)",
137+
resp_id, ares_strerror((int)status), new_resp, new_resp_len);
114138
return;
115139
}
116140

@@ -119,7 +143,7 @@ static void truncate_to_size_limit(char *buf, size_t *buflen, const uint16_t siz
119143
*buflen = new_resp_len;
120144
buf[2] |= 0x02; // set truncation flag
121145
ILOG("%04hX: DNS response size truncated from %u to %u to keep %u limit",
122-
tx_id, old_size, new_resp_len, size_limit);
146+
resp_id, old_size, new_resp_len, size_limit);
123147
}
124148

125149
ares_free_string(new_resp);
@@ -132,10 +156,10 @@ void dns_truncate_for_udp(const char *dns_req, size_t dns_req_len,
132156
}
133157
const uint16_t udp_size = get_edns_udp_size(dns_req, dns_req_len);
134158
if (*resp_len <= udp_size) {
135-
uint16_t tx_id = ntohs(*((uint16_t*)dns_req));
159+
uint16_t req_id = ntohs(*((uint16_t*)dns_req));
136160
DLOG("%04hX: DNS response size %zu larger than %d but EDNS0 UDP buffer size %u allows it",
137-
tx_id, *resp_len, DNS_SIZE_LIMIT, udp_size);
161+
req_id, *resp_len, DNS_SIZE_LIMIT, udp_size);
138162
return;
139163
}
140-
truncate_to_size_limit(resp, resp_len, udp_size);
164+
truncate_to_size_limit((uint8_t*)resp, resp_len, udp_size);
141165
}

tests/robot/functional_tests.robot

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
*** Settings ***
2+
23
Documentation Simple functional tests for https_dns_proxy
34
Library OperatingSystem
45
Library Process
@@ -7,15 +8,18 @@ Library DnsTcpClient.py
78

89

910
*** Variables ***
11+
1012
${BINARY_PATH} ${CURDIR}/../../https_dns_proxy
1113
${PORT} 55353
1214

1315

1416
*** Settings ***
17+
1518
Test Teardown Stop Proxy
1619

1720

1821
*** Keywords ***
22+
1923
Common Test Setup
2024
Set Test Variable &{expected_logs} loop destroyed=1 # last log line
2125
Set Test Variable @{error_logs} [F] # any fatal error
@@ -68,7 +72,6 @@ Stop Proxy
6872
END
6973
Should Be Equal As Integers ${result.rc} 0
7074

71-
7275
Start Dig
7376
[Arguments] ${domain}=google.com
7477
${handle} = Start Process dig +timeout\=${dig_timeout} +retry\=${dig_retry} @{dig_options} @127.0.0.1 -p ${PORT} ${domain}
@@ -100,7 +103,6 @@ Run Dig Parallel
100103
Stop Dig ${handle}
101104
END
102105

103-
104106
Large Response Test
105107
[Documentation] https://dnscheck.tools/#more
106108
# use large buffer not to fragment UDP response, and ask for TXT response
@@ -109,18 +111,17 @@ Large Response Test
109111
Should Match Regexp ${dig_output} MSG SIZE\\s+rcvd: 4\\d{3}$ # expecting more than 4k large response
110112

111113
Verify Truncation
112-
[Arguments] ${domain} ${udp_buffer_size} ${result_bytes_min} ${result_bytes_max} ${expect}=${None}
113-
# ask for TXT response
114-
Set Test Variable @{dig_options} +notcp +ignore +bufsize=${udp_buffer_size} -t txt
114+
[Arguments] ${domain} ${result_bytes_min} ${result_bytes_max} ${expect}=${None}
115115
${dig_output} = Run Dig ${domain} ${expect}
116116
Should Contain ${dig_output} flags: qr tc
117117
# expecting response to be ${result_bytes_min} byte (could be flaky)
118-
@{res} = Should Match Regexp ${dig_output} MSG SIZE\\s+rcvd: (\\d+)$ # expecting more than 4k large response
118+
@{res} = Should Match Regexp ${dig_output} MSG SIZE\\s+rcvd: (\\d+)$
119119
Should Be True ${res}[1] >= ${result_bytes_min}
120120
Should Be True ${res}[1] <= ${result_bytes_max}
121121

122122

123123
*** Test Cases ***
124+
124125
Handle Unbound Server Does Not Support HTTP/1.1
125126
Start Proxy -x -r https://doh.mullvad.net/dns-query # resolver uses Unbound
126127
Run Keyword And Expect Error 9 != 0 # timeout exit code
@@ -184,23 +185,32 @@ Send TCP Requests Fragmented
184185

185186
Close Tcp Client Connection
186187

187-
Truncate UDP Small
188+
No Truncate UDP Small
188189
Start Proxy
189-
Wait Until Keyword Succeeds 5x 200ms
190-
# too small buffer will be overridden to 512, so expecting more than 300 bytes
191-
... Verify Truncation microsoft.com 256 300 512
190+
# too small buffer will be overridden to 512, so no truncation
191+
Set Test Variable @{dig_options} @{dig_options} +ignore +bufsize=256 -t TXT +dnssec
192+
${dig_output} = Run Dig facebook.com
193+
Should Contain ${dig_output} flags: qr rd ra; # no tr flag!
194+
@{res} = Should Match Regexp ${dig_output} MSG SIZE\\s+rcvd: (\\d+)$
195+
Should Be True ${res}[1] >= 256
196+
Should Be True ${res}[1] <= 512
192197

193198
Truncate UDP Large
194199
Start Proxy
195-
Wait Until Keyword Succeeds 5x 200ms
196-
# expecting more than 1500 byte large response
197-
... Verify Truncation microsoft.com 2000 1500 2000
200+
# response would be ~4500 byte, has to be dropped because of RRSet Atomicity (RFC 2181, Sec 5.2)
201+
Set Test Variable @{dig_options} @{dig_options} +ignore +bufsize=4096 -t txt
202+
Verify Truncation microsoft.com 20 100 ANSWER: 0
198203

199204
Truncate UDP Impossible
200205
Start Proxy
201-
Wait Until Keyword Succeeds 5x 200ms
202206
# the only TXT answer record has to be dropped to met limit
203-
... Verify Truncation txtfill4096.test.dnscheck.tools 4096 12 100 ANSWER: 0
207+
Set Test Variable @{dig_options} @{dig_options} +ignore +bufsize=4096 -t txt
208+
Verify Truncation txtfill4096.test.dnscheck.tools 12 100 ANSWER: 0
209+
210+
Valgrind Resource Leak Check Truncation
211+
Start Proxy With Valgrind
212+
Set Test Variable @{dig_options} @{dig_options} +ignore +bufsize=4096 -t txt
213+
Verify Truncation txtfill4096.test.dnscheck.tools 12 100 ANSWER: 0
204214

205215
Source Address Binding
206216
[Documentation] Test -S flag binds both HTTPS and bootstrap DNS to source address

tests/robot/valgrind.supp

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
Memcheck:Leak
1111
match-leak-kinds: reachable
1212
...
13-
fun:ldap_int_sasl_init
1413
fun:ldap_int_initialize
1514
fun:ldap_get_option
1615
fun:curl_version

0 commit comments

Comments
 (0)