Skip to content

Commit f7cff1a

Browse files
committed
Reimplement DNS identifier validation.
The signature of ngx_http_validate_host has changed in NGINX 1.29.4, breaking the build of the module. Instead of continuing to abuse an internal API and wrapping it with version check, this commit reimplements the validation in a more suitable for the module way. Notable differences from the previous behavior: - Minimal supported NGINX version is now aligned to the ngx crate: 1.22 - The new logic mostly follows ngx_http_validate_host behavior as of NGINX 1.29.4, and may reject some previously accepted values. - Wildcard DNS identifiers are now permitted. Some ACME servers allow validating wildcard identifiers with challenges other than dns-01.
1 parent 7c3ceb9 commit f7cff1a

File tree

5 files changed

+179
-50
lines changed

5 files changed

+179
-50
lines changed

.github/workflows/ci.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,13 @@ jobs:
6161
build: aws-lc-static
6262

6363
include:
64+
# minimal supported nginx version
6465
- runner: ubuntu
6566
rust-version: stable
66-
nginx-ref: stable-1.26
67+
nginx-ref: stable-1.22
6768
build: debug
6869

70+
# minimal supported Rust version
6971
- runner: ubuntu
7072
rust-version: ${{ needs.rust-version.outputs.version }}
7173
nginx-ref: stable-1.28

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The module implements following specifications:
2828

2929
### Requirements
3030

31-
- NGINX sources, 1.25.0 or later.
31+
- NGINX sources, 1.22.0 or later.
3232
- Regular NGINX build dependencies: C compiler, make, PCRE2, Zlib
3333
- System-wide installation of OpenSSL 1.1.1 or later
3434
- Rust toolchain (1.81.0 or later)

src/acme/solvers/tls_alpn.rs

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use core::ffi::{c_int, c_uint, c_void, CStr};
2727
use core::net::{Ipv4Addr, Ipv6Addr};
2828
use core::ptr;
2929

30-
use nginx_sys::{ngx_conf_t, ngx_connection_t, ngx_http_validate_host, ngx_str_t, NGX_LOG_WARN};
30+
use nginx_sys::{ngx_conf_t, ngx_connection_t, ngx_str_t, NGX_LOG_WARN};
3131
use ngx::allocator::Allocator;
3232
use ngx::collections::RbTreeMap;
3333
use ngx::core::{NgxString, SlabPool, Status};
@@ -408,22 +408,16 @@ unsafe extern "C" fn acme_ssl_cert_cb(ssl: *mut SSL, arg: *mut c_void) -> c_int
408408
return 0;
409409
};
410410

411-
let name = unsafe { SSL_get_servername(ssl, openssl_sys::TLSEXT_NAMETYPE_host_name as _) };
412-
if name.is_null() {
411+
let Some(mut name) = (unsafe {
412+
SSL_get_servername(ssl, openssl_sys::TLSEXT_NAMETYPE_host_name as _)
413+
.as_ref()
414+
.map(|x| CStr::from_ptr(x).to_bytes())
415+
// Reallocate, because we will need to mutate the name in place.
416+
.and_then(|x| ngx_str_t::from_bytes(c.pool, x))
417+
}) else {
413418
return 0;
414-
}
415-
416-
let mut name = ngx_str_t {
417-
data: name.cast_mut().cast(),
418-
len: unsafe { CStr::from_ptr(name).count_bytes() },
419419
};
420420

421-
// Validate `name` and reallocate on the connection pool.
422-
if !Status(unsafe { ngx_http_validate_host(&mut name, c.pool, 1) }).is_ok() {
423-
ngx_log_error!(NGX_LOG_WARN, c.log, "acme/tls-alpn-01: invalid server name");
424-
return 0;
425-
}
426-
427421
let id = match acme_parse_ssl_server_name(&mut name) {
428422
Ok(x) => x,
429423
Err(err) => {
@@ -496,6 +490,8 @@ enum ParseIdentifierError {
496490
InvalidV4Ptr,
497491
#[error("invalid IPv6 reverse mapping")]
498492
InvalidV6Ptr,
493+
#[error("invalid name")]
494+
Name,
499495
#[error(transparent)]
500496
Utf8(#[from] core::str::Utf8Error),
501497
}
@@ -504,6 +500,8 @@ enum ParseIdentifierError {
504500
fn acme_parse_ssl_server_name(
505501
name: &mut ngx_str_t,
506502
) -> Result<Identifier<&str>, ParseIdentifierError> {
503+
name.as_bytes_mut().make_ascii_lowercase();
504+
507505
if let Some(v4) = name.strip_suffix(".in-addr.arpa") {
508506
// RFC1035 § 3.5 encoded IPv4 address.
509507

@@ -572,7 +570,12 @@ fn acme_parse_ssl_server_name(
572570

573571
Ok(Identifier::Ip(name.to_str()?))
574572
} else {
575-
Ok(Identifier::Dns(name.to_str()?))
573+
let name = name.to_str()?;
574+
if !name.is_ascii() {
575+
return Err(ParseIdentifierError::Name);
576+
}
577+
578+
Ok(Identifier::Dns(name))
576579
}
577580
}
578581

@@ -676,7 +679,9 @@ mod tests {
676679

677680
let pairs: &[(&str, Result<Identifier<&str>, _>)] = &[
678681
("example.com", Ok(Identifier::Dns("example.com"))),
682+
("EXAMPLE.COM", Ok(Identifier::Dns("example.com"))),
679683
("1.0.0.127.in-addr.arpa", Ok(Identifier::Ip("127.0.0.1"))),
684+
("1.0.0.127.IN-ADDR.ARPA", Ok(Identifier::Ip("127.0.0.1"))),
680685
("1.0.0.0.127.in-addr.arpa", Err(Error::InvalidV4Ptr)),
681686
("1.0..0.127.in-addr.arpa", Err(Error::InvalidV4Ptr)),
682687
("256.0.0.127.in-addr.arpa", Err(Error::InvalidV4Ptr)),
@@ -685,6 +690,10 @@ mod tests {
685690
"1.a.b.c.d.e.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.e.f.ip6.arpa",
686691
Ok(Identifier::Ip("fe80::fed:cba1")),
687692
),
693+
(
694+
"1.A.B.C.D.E.F.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.E.F.IP6.ARPA",
695+
Ok(Identifier::Ip("fe80::fed:cba1")),
696+
),
688697
(
689698
"1.a.b.c.d.e.f.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.e.f.ip6.arpa",
690699
Err(Error::InvalidV6Ptr),

src/conf/order.rs

Lines changed: 134 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ use core::fmt;
77
use core::hash::{self, Hash, Hasher};
88
use core::str::Utf8Error;
99

10-
use nginx_sys::{ngx_conf_t, ngx_http_server_name_t, ngx_str_t};
10+
use nginx_sys::{ngx_conf_t, ngx_http_server_name_t};
1111
use ngx::allocator::{AllocError, Allocator, TryCloneIn};
1212
use ngx::collections::Vec;
13-
use ngx::core::{NgxString, Pool, Status};
13+
use ngx::core::{NgxString, Pool};
1414
use ngx::ngx_log_error;
1515
use siphasher::sip::SipHasher;
1616
use thiserror::Error;
@@ -136,12 +136,10 @@ where
136136
pub enum IdentifierError {
137137
#[error("memory allocation failed")]
138138
Alloc(#[from] AllocError),
139-
#[error("invalid server name")]
140-
Invalid,
139+
#[error("invalid server name: {0}")]
140+
Invalid(#[from] NameError),
141141
#[error("invalid UTF-8 string")]
142142
Utf8(#[from] Utf8Error),
143-
#[error("unsupported wildcard server name")]
144-
Wildcard,
145143
}
146144

147145
impl CertificateOrder<&'static str, Pool> {
@@ -193,37 +191,39 @@ impl CertificateOrder<&'static str, Pool> {
193191
return self.push(Identifier::Ip(addr)).map_err(Into::into);
194192
}
195193

196-
if value.contains('*') {
197-
return Err(IdentifierError::Wildcard);
198-
}
199-
200-
let host = validate_host(cf, value).map_err(|st| {
201-
if st == Status::NGX_ERROR {
202-
IdentifierError::Alloc(AllocError)
203-
} else {
204-
IdentifierError::Invalid
205-
}
206-
})?;
194+
let realloc = validate_dns_identifier(value)?;
207195

208196
/*
209197
* The only special syntax we want to support is a leading dot, which matches the domain
210198
* with "www." and without it.
211199
* See <https://nginx.org/en/docs/http/server_names.html>
212200
*/
213201

214-
if let Some(host) = host.strip_prefix(".") {
202+
if let Some(host) = value.strip_prefix(".") {
215203
let mut www = Vec::new_in(self.identifiers.allocator().clone());
216204
www.try_reserve_exact(host.len() + 4)
217205
.map_err(|_| AllocError)?;
218206
www.extend_from_slice(b"www.");
219207
www.extend_from_slice(host.as_bytes());
208+
www.make_ascii_lowercase();
209+
// SAFETY: www is a pre-validated ASCII character slice.
220210
// The buffer is owned by ngx_pool_t and does not leak.
221-
let www = core::str::from_utf8(www.leak())?;
211+
let www = unsafe { core::str::from_utf8_unchecked(www.leak()) };
222212

223213
self.push(Identifier::Dns(www))?;
224-
self.push(Identifier::Dns(host))?;
214+
self.push(Identifier::Dns(&www[4..]))?;
215+
} else if realloc {
216+
let mut out = Vec::new_in(self.identifiers.allocator().clone());
217+
out.try_reserve_exact(value.len()).map_err(|_| AllocError)?;
218+
out.extend_from_slice(value.as_bytes());
219+
out.make_ascii_lowercase();
220+
// SAFETY: out is a pre-validated ASCII character slice.
221+
// The buffer is owned by ngx_pool_t and does not leak.
222+
let out = unsafe { core::str::from_utf8_unchecked(out.leak()) };
223+
224+
self.push(Identifier::Dns(out))?;
225225
} else {
226-
self.push(Identifier::Dns(host))?;
226+
self.push(Identifier::Dns(value))?;
227227
}
228228

229229
Ok(())
@@ -289,17 +289,119 @@ fn parse_ip_identifier(
289289
Ok(Some(out))
290290
}
291291

292-
/// Checks if the value is a valid domain name and returns a canonical (lowercase) form,
293-
/// reallocated on the configuration pool if necessary.
294-
fn validate_host(cf: &ngx_conf_t, host: &'static str) -> Result<&'static str, Status> {
295-
let mut host = ngx_str_t {
296-
data: host.as_ptr().cast_mut(),
297-
len: host.len(),
298-
};
299-
let rc = Status(unsafe { nginx_sys::ngx_http_validate_host(&mut host, cf.pool, 0) });
300-
if rc != Status::NGX_OK {
301-
return Err(rc);
292+
#[derive(Debug, Error, PartialEq)]
293+
pub enum NameError {
294+
#[error("invalid character {0:x} at position {1}")]
295+
Invalid(u8, usize),
296+
#[error("unexpected character '{0}' at position {1}")]
297+
Unexpected(char, usize),
298+
#[error("does not end with a label")]
299+
NotALabel,
300+
}
301+
302+
/// Validates that the `name` can be used as a DNS identifier.
303+
///
304+
/// RFC8555 § 7.1.4 states that the "dns" identifier is a fully qualified domain name,
305+
/// encoded according to the rules in RFC5280 § 7.
306+
/// In practice, existing ACME servers and clients are rather relaxed about this requirement,
307+
/// trusting the user to specify valid values and the underlying protocol implementations (DNS or
308+
/// HTTP) to reject invalid ones.
309+
///
310+
/// Given that, the validation logic is loosely based on the `ngx_http_validate_host` as of 1.29.4,
311+
/// asserting that the name
312+
///
313+
/// - is a printable ASCII string, as required both by ACME and by NGINX[^1]
314+
/// - is a valid Host value accepted by NGINX
315+
/// - can have a leading dot or a single leading wildcard label
316+
/// - ends with a non-empty label
317+
///
318+
/// Returns true if the name needs to be reallocated and converted to lower case.
319+
///
320+
/// [^1]: https://nginx.org/en/docs/http/server_names.html
321+
fn validate_dns_identifier(name: &str) -> Result<bool, NameError> {
322+
#[derive(PartialEq, Eq)]
323+
enum State {
324+
Start,
325+
Label,
326+
Dot,
327+
Wildcard,
328+
}
329+
330+
let mut alloc = false;
331+
let mut state = State::Start;
332+
333+
for (i, ch) in name.bytes().enumerate() {
334+
state = match ch {
335+
// non-printable - handle separately for better error formatting
336+
0x00..=0x20 | 0x7f.. => return Err(NameError::Invalid(ch, i)),
337+
338+
b'*' if state == State::Start => State::Wildcard,
339+
b'.' if state != State::Dot => State::Dot,
340+
// A wildcard domain name consists of a single asterisk character followed by a single
341+
// full stop character ("*.") followed by a domain name (RFC8555 § 7.1.3)
342+
_ if state == State::Wildcard => return Err(NameError::Unexpected(ch as _, i)),
343+
344+
// unreserved
345+
b'A'..=b'Z' => {
346+
alloc = true;
347+
State::Label
348+
}
349+
b'0'..=b'9' | b'a'..=b'z' | b'-' | b'_' | b'~' => State::Label,
350+
351+
// pct-encoded
352+
b'%' => State::Label,
353+
354+
// sub-delims
355+
b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'+' | b',' | b';' | b'=' => State::Label,
356+
357+
_ => return Err(NameError::Unexpected(ch as _, i)),
358+
};
302359
}
303360

304-
unsafe { super::conf_value_to_str(&host) }.map_err(|_| Status::NGX_ERROR)
361+
// We intentionally reject the trailing dot, as it should not appear in the TLS layer.
362+
363+
if state != State::Label {
364+
return Err(NameError::NotALabel);
365+
}
366+
367+
Ok(alloc)
368+
}
369+
370+
#[cfg(test)]
371+
mod tests {
372+
use super::*;
373+
374+
#[test]
375+
fn test_validate_dns_identifier() {
376+
for (name, expect) in [
377+
("example", Ok(false)),
378+
("_example", Ok(false)),
379+
("exam_ple", Ok(false)),
380+
("Example", Ok(true)),
381+
("Example.", Err(NameError::NotALabel)),
382+
("E\x10ample", Err(NameError::Invalid(0x10, 1))),
383+
("00.example.test", Ok(false)),
384+
("www1.example.Test", Ok(true)),
385+
("www.example..test", Err(NameError::Unexpected('.', 12))),
386+
("www.example.test.", Err(NameError::NotALabel)),
387+
("пример.испытание", Err(NameError::Invalid(0xd0, 0))),
388+
("xn--e1afmkfd.xn--80akhbyknj4f", Ok(false)),
389+
// wildcards
390+
("*", Err(NameError::NotALabel)),
391+
("*.example.test", Ok(false)),
392+
("*.xn--e1afmkfd.xn--80akhbyknj4f", Ok(false)),
393+
("*ww.example.test", Err(NameError::Unexpected('w', 1))),
394+
("ww*.example.test", Err(NameError::Unexpected('*', 2))),
395+
("www.example.*", Err(NameError::Unexpected('*', 12))),
396+
// incorrect syntax
397+
("", Err(NameError::NotALabel)),
398+
("*.", Err(NameError::NotALabel)),
399+
] {
400+
assert_eq!(
401+
super::validate_dns_identifier(name),
402+
expect,
403+
"incorrect validation result for \"{name}\"",
404+
);
405+
}
406+
}
305407
}

t/acme_conf_certificate.t

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use Test::Nginx;
2424
select STDERR; $| = 1;
2525
select STDOUT; $| = 1;
2626

27-
my $t = Test::Nginx->new()->has(qw/http http_ssl/)->plan(4);
27+
my $t = Test::Nginx->new()->has(qw/http http_ssl/)->plan(6);
2828

2929
use constant TEMPLATE_CONF => <<'EOF';
3030
@@ -80,6 +80,22 @@ acme_certificate example;
8080
EOF
8181

8282

83+
like(check($t, <<'EOF' ), qr/\[emerg].*invalid server name/, 'bad name - 1');
84+
85+
server_name www.example.*;
86+
acme_certificate example;
87+
88+
EOF
89+
90+
91+
like(check($t, <<'EOF' ), qr/\[emerg].*invalid server name/, 'bad name - 2');
92+
93+
server_name .example..test;
94+
acme_certificate example;
95+
96+
EOF
97+
98+
8399
like(check($t, <<'EOF' ), qr/\[emerg].*no identifiers/, 'no identifiers');
84100
85101
acme_certificate example;

0 commit comments

Comments
 (0)