Skip to content

Commit 4d8abd3

Browse files
committed
Fix DNS response handling, relay caching, and server reliability
- Restore client transaction IDs and casing in stale and truncated replies - Fix classical encryption padding at 64-byte boundaries - Accept empty DNS responses during cache TTL processing - Validate relayed certificates using the received datagram length - Enforce relay size limits on cached replies and avoid caching truncated certificates - Handle bare filenames when saving state - Enforce the metrics connection timeout
1 parent e6d906c commit 4d8abd3

8 files changed

Lines changed: 474 additions & 42 deletions

File tree

‎src/anonymized_dns.rs‎

Lines changed: 154 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -105,40 +105,13 @@ pub async fn handle_anonymized_dns(
105105
if is_encrypted_response(&response, response_len) {
106106
break (response_len, false);
107107
}
108-
if is_certificate_response(&response, encrypted_packet) {
108+
if is_certificate_response(&response, response_len, encrypted_packet) {
109109
break (response_len, true);
110110
}
111111
};
112112
response.truncate(response_len);
113113
if is_certificate_response {
114-
let mut hasher = globals.hasher;
115-
hasher.write(&relayed_packet[..ANONYMIZED_DNSCRYPT_OVERHEAD]);
116-
hasher.write(&dns::qname(encrypted_packet)?);
117-
let packet_hash = hasher.finish128().as_u128();
118-
let cached_response = {
119-
match globals.cert_cache.lock().get(&packet_hash) {
120-
None => None,
121-
Some(response) if !(*response).has_expired() => {
122-
trace!("Relayed certificate cached");
123-
let mut cached_response = (*response).clone();
124-
cached_response.set_tid(dns::tid(encrypted_packet));
125-
Some(cached_response.into_response())
126-
}
127-
Some(_) => {
128-
trace!("Relayed certificate expired");
129-
None
130-
}
131-
}
132-
};
133-
match cached_response {
134-
None => {
135-
globals.cert_cache.lock().insert(
136-
packet_hash,
137-
CachedResponse::new(&globals.cert_cache, response.clone()),
138-
);
139-
}
140-
Some(cached_response) => response = cached_response,
141-
}
114+
response = cache_certificate_response(&globals, relayed_packet, response)?;
142115
}
143116

144117
#[cfg(feature = "metrics")]
@@ -147,13 +120,55 @@ pub async fn handle_anonymized_dns(
147120
respond_to_query(client_ctx, response).await
148121
}
149122

123+
fn cache_certificate_response(
124+
globals: &Globals,
125+
relayed_packet: &[u8],
126+
mut response: Vec<u8>,
127+
) -> Result<Vec<u8>, Error> {
128+
let encrypted_packet = &relayed_packet[ANONYMIZED_DNSCRYPT_OVERHEAD..];
129+
let mut hasher = globals.hasher;
130+
hasher.write(&relayed_packet[..ANONYMIZED_DNSCRYPT_OVERHEAD]);
131+
hasher.write(&dns::qname(encrypted_packet)?);
132+
let packet_hash = hasher.finish128().as_u128();
133+
let cached_response = {
134+
match globals.cert_cache.lock().get(&packet_hash) {
135+
None => None,
136+
Some(response) if !(*response).has_expired() => {
137+
trace!("Relayed certificate cached");
138+
let mut cached_response = (*response).clone();
139+
cached_response.set_tid(dns::tid(encrypted_packet));
140+
let cached_response = cached_response.into_response();
141+
// Cached certificates must obey the same amplification limit
142+
// as the response just received from the upstream.
143+
(cached_response.len() <= encrypted_packet.len()).then_some(cached_response)
144+
}
145+
Some(_) => {
146+
trace!("Relayed certificate expired");
147+
None
148+
}
149+
}
150+
};
151+
match cached_response {
152+
None if !dns::is_truncated(&response) => {
153+
globals.cert_cache.lock().insert(
154+
packet_hash,
155+
CachedResponse::new(&globals.cert_cache, response.clone()),
156+
);
157+
}
158+
None => {}
159+
Some(cached_response) => response = cached_response,
160+
}
161+
Ok(response)
162+
}
163+
150164
#[inline]
151165
fn is_encrypted_response(response: &[u8], response_len: usize) -> bool {
152166
(DNSCRYPT_UDP_RESPONSE_MIN_SIZE..=DNSCRYPT_UDP_RESPONSE_MAX_SIZE).contains(&response_len)
153167
&& response[..DNSCRYPT_RESPONSE_MAGIC_SIZE] == DNSCRYPT_RESPONSE_MAGIC
154168
}
155169

156-
fn is_certificate_response(response: &[u8], query: &[u8]) -> bool {
170+
fn is_certificate_response(response: &[u8], response_len: usize, query: &[u8]) -> bool {
171+
let response = &response[..response_len];
157172
let prefix = b"2.dnscrypt-cert.";
158173
if !((DNS_HEADER_SIZE + prefix.len() + 4..=DNS_MAX_PACKET_SIZE).contains(&query.len())
159174
&& (DNS_HEADER_SIZE + prefix.len() + 4..=DNS_MAX_PACKET_SIZE).contains(&response.len())
@@ -207,11 +222,117 @@ mod tests {
207222
fn only_txt_in_answers_are_certificate_responses() {
208223
let txt_query = cert_packet(dns::DNS_TYPE_TXT, false);
209224
let txt_response = cert_packet(dns::DNS_TYPE_TXT, true);
210-
assert!(is_certificate_response(&txt_response, &txt_query));
225+
assert!(is_certificate_response(
226+
&txt_response,
227+
txt_response.len(),
228+
&txt_query
229+
));
211230

212231
let a_query = cert_packet(dns::DNS_TYPE_A, false);
213232
let a_response = cert_packet(dns::DNS_TYPE_A, true);
214-
assert!(!is_certificate_response(&a_response, &a_query));
215-
assert!(!is_certificate_response(&a_response, &txt_query));
233+
assert!(!is_certificate_response(
234+
&a_response,
235+
a_response.len(),
236+
&a_query
237+
));
238+
assert!(!is_certificate_response(
239+
&a_response,
240+
a_response.len(),
241+
&txt_query
242+
));
243+
}
244+
245+
#[test]
246+
fn certificate_validation_ignores_bytes_beyond_the_received_datagram() {
247+
let query = cert_packet(dns::DNS_TYPE_TXT, false);
248+
let response = cert_packet(dns::DNS_TYPE_TXT, true);
249+
let mut buffer = vec![0; DNSCRYPT_UDP_RESPONSE_MAX_SIZE];
250+
buffer[..response.len()].copy_from_slice(&response);
251+
assert!(is_certificate_response(&buffer, response.len(), &query));
252+
// A subsequent datagram leaves the earlier question in the receive buffer.
253+
for len in [DNS_HEADER_SIZE, response.len() - 1] {
254+
assert!(!is_certificate_response(&buffer, len, &query));
255+
}
256+
}
257+
258+
fn relayed_cert_query(size: usize) -> Vec<u8> {
259+
let mut packet = vec![0; ANONYMIZED_DNSCRYPT_OVERHEAD];
260+
let mut query = cert_packet(dns::DNS_TYPE_TXT, false);
261+
query.resize(size, 0);
262+
packet.extend(query);
263+
packet
264+
}
265+
266+
fn cert_response(txt_size: usize) -> Vec<u8> {
267+
let mut response = cert_packet(dns::DNS_TYPE_TXT, true);
268+
response[7] = 1;
269+
response.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 2, 88]);
270+
response.extend_from_slice(&((txt_size + 1) as u16).to_be_bytes());
271+
response.push(txt_size as u8);
272+
response.resize(response.len() + txt_size, 0x42);
273+
response
274+
}
275+
276+
#[test]
277+
fn cached_certificate_respects_the_current_query_size() {
278+
let runtime = tokio::runtime::Builder::new_current_thread()
279+
.enable_all()
280+
.build()
281+
.unwrap();
282+
runtime.block_on(async {
283+
let globals = crate::tests::globals("127.0.0.1:9".parse().unwrap());
284+
let large = cert_response(255);
285+
cache_certificate_response(&globals, &relayed_cert_query(512), large).unwrap();
286+
let small = cert_response(124);
287+
let response =
288+
cache_certificate_response(&globals, &relayed_cert_query(256), small.clone())
289+
.unwrap();
290+
assert!(
291+
response.len() <= 256,
292+
"cache bypassed the relay amplification limit"
293+
);
294+
assert_eq!(response, small);
295+
});
296+
}
297+
298+
#[test]
299+
fn truncated_certificate_does_not_replace_a_complete_response() {
300+
let runtime = tokio::runtime::Builder::new_current_thread()
301+
.enable_all()
302+
.build()
303+
.unwrap();
304+
runtime.block_on(async {
305+
let globals = crate::tests::globals("127.0.0.1:9".parse().unwrap());
306+
let mut truncated = cert_response(124);
307+
dns::truncate(&mut truncated);
308+
cache_certificate_response(&globals, &relayed_cert_query(256), truncated).unwrap();
309+
let complete = cert_response(255);
310+
let response =
311+
cache_certificate_response(&globals, &relayed_cert_query(512), complete.clone())
312+
.unwrap();
313+
assert!(!dns::is_truncated(&response));
314+
assert_eq!(response, complete);
315+
});
316+
}
317+
318+
#[test]
319+
fn complete_cached_certificate_is_reused_when_it_fits() {
320+
let runtime = tokio::runtime::Builder::new_current_thread()
321+
.enable_all()
322+
.build()
323+
.unwrap();
324+
runtime.block_on(async {
325+
let globals = crate::tests::globals("127.0.0.1:9".parse().unwrap());
326+
let mut query = relayed_cert_query(512);
327+
let mut cached = cert_response(124);
328+
cache_certificate_response(&globals, &query, cached.clone()).unwrap();
329+
dns::set_tid(&mut query[ANONYMIZED_DNSCRYPT_OVERHEAD..], 0xabcd);
330+
dns::set_tid(&mut cached, 0xabcd);
331+
for mut fresh in [cert_response(255), cert_response(124)] {
332+
dns::set_tid(&mut fresh, 0xabcd);
333+
let response = cache_certificate_response(&globals, &query, fresh).unwrap();
334+
assert_eq!(response, cached);
335+
}
336+
});
216337
}
217338
}

‎src/config.rs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,10 @@ impl State {
205205
fp.sync_data().await?;
206206
mem::drop(fp);
207207
tokio::fs::rename(path_tmp, path).await?;
208-
let dir = tokio::fs::File::open(path.parent().unwrap_or_else(|| Path::new("."))).await?;
208+
let parent = path
209+
.parent()
210+
.filter(|parent| !parent.as_os_str().is_empty());
211+
let dir = tokio::fs::File::open(parent.unwrap_or_else(|| Path::new("."))).await?;
209212
dir.sync_all().await?;
210213
Ok(())
211214
}
@@ -219,3 +222,30 @@ impl State {
219222
Ok(state)
220223
}
221224
}
225+
226+
#[cfg(all(test, unix))]
227+
mod tests {
228+
use super::*;
229+
230+
#[test]
231+
fn save_state_to_a_bare_filename() {
232+
let runtime = tokio::runtime::Builder::new_current_thread()
233+
.enable_all()
234+
.build()
235+
.unwrap();
236+
let path = PathBuf::from(format!(".test-state-{:016x}.state", rand::random::<u64>()));
237+
let state = State {
238+
provider_kp: SignKeyPair::default(),
239+
dnscrypt_encryption_params_set: vec![],
240+
};
241+
let result = runtime.block_on(state.async_save(&path));
242+
let loaded = State::from_file(&path, 10);
243+
let _ = fs::remove_file(&path);
244+
let _ = fs::remove_file(path.with_extension("tmp"));
245+
result.unwrap();
246+
assert_eq!(
247+
loaded.unwrap().provider_kp.pk.as_bytes(),
248+
state.provider_kp.pk.as_bytes()
249+
);
250+
}
251+
}

‎src/crypto.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ impl SharedKey {
259259
hasher.write(client_nonce);
260260
let pad_size: usize = 1 + (hasher.finish() as usize & 0xff);
261261
let mut padded_plaintext_len = (plaintext_len + pad_size) & !63;
262-
if padded_plaintext_len < plaintext_len {
262+
if padded_plaintext_len <= plaintext_len {
263263
padded_plaintext_len += 256;
264264
}
265265
if padded_plaintext_len > max_padded_plaintext_len {

‎src/dns.rs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ pub fn min_ttl(packet: &[u8], min_ttl: u32, max_ttl: u32, failure_ttl: u32) -> R
361361
ensure!(qdcount(packet) == 1, "No question");
362362
let mut offset = skip_name(packet, DNS_OFFSET_QUESTION)?;
363363
assert!(offset > DNS_OFFSET_QUESTION);
364-
ensure!(packet_len - offset > 4, "Short packet");
364+
ensure!(packet_len - offset >= 4, "Short packet");
365365
offset += 4;
366366
let (ancount, nscount, arcount) = (ancount(packet), nscount(packet), arcount(packet));
367367
let rrcount = ancount as usize + nscount as usize + arcount as usize;
@@ -389,7 +389,7 @@ pub fn set_ttl(packet: &mut [u8], ttl: u32) -> Result<(), Error> {
389389
ensure!(qdcount(packet) == 1, "No question");
390390
let mut offset = skip_name(packet, DNS_OFFSET_QUESTION)?;
391391
assert!(offset > DNS_OFFSET_QUESTION);
392-
ensure!(packet_len - offset > 4, "Short packet");
392+
ensure!(packet_len - offset >= 4, "Short packet");
393393
offset += 4;
394394
let (ancount, nscount, arcount) = (ancount(packet), nscount(packet), arcount(packet));
395395
let rrcount = ancount as usize + nscount as usize + arcount as usize;
@@ -729,6 +729,26 @@ pub fn serve_ip_response(client_packet: Vec<u8>, ip: IpAddr, ttl: u32) -> Result
729729
mod tests {
730730
use super::*;
731731

732+
#[test]
733+
fn ttl_processing_accepts_responses_without_resource_records() {
734+
let query = vec![
735+
0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 7, b'e', b'x', b'a', b'm', b'p',
736+
b'l', b'e', 3, b'o', b'r', b'g', 0, 0, 1, 0, 1,
737+
];
738+
for rcode in [0, DNS_RCODE_NXDOMAIN] {
739+
let mut response = query.clone();
740+
authoritative_response(&mut response);
741+
set_rcode(&mut response, rcode);
742+
assert_eq!(min_ttl(&response, 0, 3600, 60).unwrap(), 60);
743+
let original = response.clone();
744+
set_ttl(&mut response, 30).unwrap();
745+
assert_eq!(response, original);
746+
response.pop();
747+
assert!(min_ttl(&response, 0, 3600, 60).is_err());
748+
assert!(set_ttl(&mut response, 30).is_err());
749+
}
750+
}
751+
732752
fn appended_len(cert_bin: &[u8]) -> usize {
733753
let mut packet = vec![0u8; DNS_HEADER_SIZE];
734754
append_txt_cert(&mut packet, cert_bin).unwrap();

‎src/dnscrypt.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,36 @@ mod tests {
328328
use super::*;
329329
use crate::pq;
330330

331+
#[test]
332+
fn classical_responses_fit_and_round_trip_at_padding_boundaries() {
333+
crate::crypto::init().unwrap();
334+
let shared_key = SharedKey::from_bytes([0x42; 32]);
335+
for nonce_byte in 0..=255 {
336+
let nonce = [nonce_byte; DNSCRYPT_FULL_NONCE_SIZE];
337+
let params = EncryptionParams::Classical {
338+
shared_key: shared_key.clone(),
339+
nonce,
340+
};
341+
for len in [63, 64, 65, 127, 128, 129] {
342+
let response = vec![0xda; len];
343+
for budget in [len + DNSCRYPT_RESPONSE_MIN_OVERHEAD, 512] {
344+
let encrypted = encrypt(response.clone(), &params, budget)
345+
.expect("response within budget failed to encrypt");
346+
assert!(encrypted.len() <= budget);
347+
assert_eq!(
348+
shared_key
349+
.decrypt(&nonce, &encrypted[DNSCRYPT_RESPONSE_HEADER_SIZE..])
350+
.unwrap(),
351+
response
352+
);
353+
}
354+
assert!(
355+
encrypt(response, &params, len + DNSCRYPT_RESPONSE_MIN_OVERHEAD - 1).is_err()
356+
);
357+
}
358+
}
359+
}
360+
331361
// Any response the `maybe_truncate_response` gate lets through must
332362
// encrypt into the query-size budget, shrinking the padding and
333363
// withholding the ticket as needed; anything larger must fail, keeping

0 commit comments

Comments
 (0)