1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
pub(crate) mod build_connector {
use crate::tls::TlsContext;
use client::connect::HttpConnector;
use hyper_util::client::legacy as client;
use s2n_tls::callbacks::VerifyHostNameCallback;
use s2n_tls::security::Policy;
use std::sync::LazyLock;
// Default S2N security policy which sets protocol versions and cipher suites
// See https://aws.github.io/s2n-tls/usage-guide/ch06-security-policies.html
const S2N_POLICY_VERSION: &str = "20230317";
fn base_config() -> s2n_tls::config::Builder {
let mut builder = s2n_tls::config::Config::builder();
let policy = Policy::from_version(S2N_POLICY_VERSION).unwrap();
builder
.set_security_policy(&policy)
.expect("valid s2n security policy");
// default is true
builder.with_system_certs(false).unwrap();
builder
}
static CACHED_CONFIG: LazyLock<s2n_tls::config::Config> = LazyLock::new(|| {
let mut config = base_config();
config.with_system_certs(true).unwrap();
// actually loads the system certs
config.build().expect("valid s2n config")
});
/// A host name verifier that extends standard verification with support for
/// additional server names.
///
/// By default, s2n-tls verifies the server's certificate by checking that at least
/// one Subject Alternative Name (SAN) matches the server name set on the connection
/// (typically extracted from the request URI). This verifier additionally accepts
/// SANs that match any of the configured `additional_server_names`.
///
/// This is useful when a server presents a certificate whose SANs do not include
/// the hostname used to connect, but do include an alternative name the client has
/// been configured to accept.
///
/// # How it works
///
/// When set as a `ConnectionInitializer`, this struct reads the primary server name
/// from the connection (which was already set by the connector from the request URI)
/// and installs a per-connection `VerifyHostNameCallback` that accepts hostnames
/// matching either the primary server name or any of the additional server names.
#[derive(Clone)]
struct AdditionalServerNamesInitializer {
additional_server_names: Vec<String>,
}
/// Per-connection callback that verifies hostnames from a certificate's SANs
/// against both the primary server name and any additional configured names.
struct AdditionalServerNamesVerifier {
/// The primary server name (from the connection/URI) plus all additional
/// server names that should be accepted.
accepted_names: Vec<String>,
}
/// Checks whether a presented hostname (from a certificate SAN) matches
/// an accepted reference name, using rules from RFC 6125 ยง6.4:
///
/// 1. Case-insensitive exact match
/// 2. Single-level wildcard: a presented name like `*.example.com` matches
/// a reference name like `foo.example.com` (but not `bar.foo.example.com`)
///
/// This mirrors s2n-tls's default `s2n_default_verify_host` behavior in
/// <https://github.com/aws/s2n-tls/blob/main/tls/s2n_connection.c>.
fn matches_host_name(accepted: &str, presented: &str) -> bool {
// Case-insensitive exact match
if accepted.eq_ignore_ascii_case(presented) {
return true;
}
// Wildcard match: presented = "*.example.com", accepted = "foo.example.com"
if let Some(wildcard_suffix) = presented.strip_prefix("*.") {
if let Some((_first_label, accepted_suffix)) = accepted.split_once('.') {
return accepted_suffix.eq_ignore_ascii_case(wildcard_suffix);
}
}
false
}
impl VerifyHostNameCallback for AdditionalServerNamesVerifier {
fn verify_host_name(&self, host_name: &str) -> bool {
self.accepted_names
.iter()
.any(|accepted| matches_host_name(accepted, host_name))
}
}
impl s2n_tls::config::ConnectionInitializer for AdditionalServerNamesInitializer {
fn initialize_connection(
&self,
connection: &mut s2n_tls::connection::Connection,
) -> Result<
Option<std::pin::Pin<Box<dyn s2n_tls::callbacks::ConnectionFuture>>>,
s2n_tls::error::Error,
> {
// Build the list of all accepted names: the primary server name (from the URI)
// plus all additional server names.
let mut accepted_names = Vec::with_capacity(self.additional_server_names.len() + 1);
// The primary server name was already set on the connection by s2n-tls-hyper/
// s2n-tls-tokio before the handshake begins. Read it so we can include it
// in the verifier's accepted names list.
if let Some(primary) = connection.server_name() {
accepted_names.push(primary.to_owned());
}
accepted_names.extend(self.additional_server_names.iter().cloned());
connection
.set_verify_host_callback(AdditionalServerNamesVerifier { accepted_names })
.expect("additional server names hostname verifier set on s2n connection");
Ok(None)
}
}
impl TlsContext {
fn s2n_config(&self) -> s2n_tls::config::Config {
// TODO(s2n-tls): s2n does not support turning a config back into a builder or a way to load a trust store and re-use it
// instead if we are only using the defaults then use a cached config, otherwise pay the cost to build a new one
if self.trust_store.enable_native_roots
&& self.trust_store.custom_certs.is_empty()
&& self.additional_server_names.is_empty()
{
CACHED_CONFIG.clone()
} else {
let mut config = base_config();
config
.with_system_certs(self.trust_store.enable_native_roots)
.unwrap();
for pem_cert in &self.trust_store.custom_certs {
config
.trust_pem(pem_cert.0.as_slice())
.expect("valid certificate");
}
if !self.additional_server_names.is_empty() {
let additional_server_names: Vec<String> = self
.additional_server_names
.iter()
.map(|name| name.0.to_str().into_owned())
.collect();
config
.set_connection_initializer(AdditionalServerNamesInitializer {
additional_server_names,
})
.expect("additional server names connection initializer set on s2n config");
}
config.build().expect("valid s2n config")
}
}
}
pub(crate) fn wrap_connector<R>(
mut http_connector: HttpConnector<R>,
tls_context: &TlsContext,
proxy_config: crate::client::proxy::ProxyConfig,
) -> super::connect::S2nTlsConnector<R> {
let config = tls_context.s2n_config();
http_connector.enforce_http(false);
let mut builder = s2n_tls_hyper::connector::HttpsConnector::builder_with_http(
http_connector,
config.clone(),
);
builder.with_plaintext_http(true);
let https_connector = builder.build();
super::connect::S2nTlsConnector::new(https_connector, config, proxy_config)
}
#[cfg(test)]
mod tests {
use super::matches_host_name;
/// Tests modeled after s2n-tls's `s2n_default_verify_host` behavior in
/// <https://github.com/aws/s2n-tls/blob/main/tls/s2n_connection.c>
#[test]
fn exact_match() {
assert!(matches_host_name("foo.example.com", "foo.example.com"));
// Case-insensitive
assert!(matches_host_name("FOO.Example.COM", "foo.example.com"));
}
#[test]
fn wildcard_matches_single_label() {
assert!(matches_host_name("foo.example.com", "*.example.com"));
assert!(matches_host_name("bar.example.com", "*.example.com"));
// Case-insensitive
assert!(matches_host_name("FOO.Example.COM", "*.example.com"));
}
#[test]
fn wildcard_does_not_match_deeper_or_bare() {
// Must not match multi-level subdomain
assert!(!matches_host_name("bar.foo.example.com", "*.example.com"));
// Must not match the domain itself
assert!(!matches_host_name("example.com", "*.example.com"));
}
#[test]
fn no_match() {
assert!(!matches_host_name("other.com", "example.com"));
assert!(!matches_host_name("foo.other.com", "*.example.com"));
}
#[test]
fn ip_address_exact_match() {
assert!(matches_host_name("127.0.0.1", "127.0.0.1"));
assert!(matches_host_name("::1", "::1"));
}
#[test]
fn partial_wildcard_not_supported() {
// RFC 6125 ยง6.4.3 rule 3 is a MAY โ intentionally unsupported,
// matching s2n-tls's default behavior.
assert!(!matches_host_name("baz1.example.com", "baz*.example.com"));
assert!(!matches_host_name(
"foo.bar.example.com",
"foo.*.example.com"
));
}
}
}
pub(crate) mod connect {
use crate::client::connect::{Conn, Connecting};
use crate::client::proxy::ProxyConfig;
use aws_smithy_runtime_api::box_error::BoxError;
use http_1x::uri::Scheme;
use http_1x::Uri;
use hyper_util::client::legacy::connect::{Connected, Connection, HttpConnector};
use hyper_util::client::proxy::matcher::Matcher;
use hyper_util::rt::TokioIo;
use std::error::Error;
use std::sync::Arc;
use std::{
io::IoSlice,
pin::Pin,
task::{Context, Poll},
};
use tower::Service;
#[derive(Clone)]
pub(crate) struct S2nTlsConnector<R> {
https: s2n_tls_hyper::connector::HttpsConnector<HttpConnector<R>>,
tls_config: s2n_tls::config::Config,
proxy_matcher: Option<Arc<Matcher>>, // Pre-computed for performance
}
impl<R> S2nTlsConnector<R> {
pub(super) fn new(
https: s2n_tls_hyper::connector::HttpsConnector<HttpConnector<R>>,
tls_config: s2n_tls::config::Config,
proxy_config: ProxyConfig,
) -> Self {
// Pre-compute the proxy matcher once during construction
let proxy_matcher = if proxy_config.is_disabled() {
None
} else {
Some(Arc::new(proxy_config.into_hyper_util_matcher()))
};
Self {
https,
tls_config,
proxy_matcher,
}
}
}
impl<R> Service<Uri> for S2nTlsConnector<R>
where
R: Clone + Send + Sync + 'static,
R: Service<hyper_util::client::legacy::connect::dns::Name>,
R::Response: Iterator<Item = std::net::SocketAddr>,
R::Future: Send,
R::Error: Into<Box<dyn Error + Send + Sync>>,
{
type Response = Conn;
type Error = BoxError;
type Future = Connecting;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.https.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, dst: Uri) -> Self::Future {
// Check if this request should be proxied using pre-computed matcher
let proxy_intercept = if let Some(ref matcher) = self.proxy_matcher {
matcher.intercept(&dst)
} else {
None
};
if let Some(intercept) = proxy_intercept {
if dst.scheme() == Some(&Scheme::HTTPS) {
// HTTPS through HTTP proxy: Use CONNECT tunneling + manual TLS
self.handle_https_through_proxy(dst, intercept)
} else {
// HTTP through proxy: Direct connection to proxy
self.handle_http_through_proxy(dst, intercept)
}
} else {
// Direct connection: Use the existing HTTPS connector
self.handle_direct_connection(dst)
}
}
}
impl<R> S2nTlsConnector<R>
where
R: Clone + Send + Sync + 'static,
R: Service<hyper_util::client::legacy::connect::dns::Name>,
R::Response: Iterator<Item = std::net::SocketAddr>,
R::Future: Send,
R::Error: Into<Box<dyn Error + Send + Sync>>,
{
fn handle_direct_connection(&mut self, dst: Uri) -> Connecting {
let fut = self.https.call(dst);
Box::pin(async move {
let conn = fut.await?;
Ok(Conn {
inner: Box::new(conn),
is_proxy: false,
})
})
}
fn handle_http_through_proxy(
&mut self,
_dst: Uri,
intercept: hyper_util::client::proxy::matcher::Intercept,
) -> Connecting {
// For HTTP through proxy, connect to the proxy and let it handle the request
let proxy_uri = intercept.uri().clone();
let fut = self.https.call(proxy_uri);
Box::pin(async move {
let conn = fut.await?;
Ok(Conn {
inner: Box::new(conn),
is_proxy: true,
})
})
}
fn handle_https_through_proxy(
&mut self,
dst: Uri,
intercept: hyper_util::client::proxy::matcher::Intercept,
) -> Connecting {
// For HTTPS through HTTP proxy, we need to:
// 1. Establish CONNECT tunnel using the HTTPS connector
// 2. Perform manual TLS handshake over the tunneled stream
let tunnel = hyper_util::client::legacy::connect::proxy::Tunnel::new(
intercept.uri().clone(),
self.https.clone(),
);
// Configure tunnel with authentication if present
let mut tunnel = if let Some(auth) = intercept.basic_auth() {
tunnel.with_auth(auth.clone())
} else {
tunnel
};
let tls_config = self.tls_config.clone();
let dst_clone = dst.clone();
Box::pin(async move {
// Stage 1: Establish CONNECT tunnel
tracing::trace!("tunneling HTTPS over proxy using s2n-tls");
let tunneled = tunnel
.call(dst_clone.clone())
.await
.map_err(|e| BoxError::from(format!("CONNECT tunnel failed: {e}")))?;
// Stage 2: Manual TLS handshake over tunneled stream
let host = dst_clone
.host()
.ok_or("missing host in URI for TLS handshake")?;
// s2n-tls uses string server names (simpler than rustls ServerName)
let tls_connector = s2n_tls_tokio::TlsConnector::new(tls_config);
let tls_stream = tls_connector
.connect(host, TokioIo::new(tunneled))
.await
.map_err(|e| BoxError::from(format!("s2n-tls handshake failed: {e}")))?;
Ok(Conn {
inner: Box::new(S2nTlsConn {
inner: TokioIo::new(tls_stream),
}),
is_proxy: true,
})
})
}
}
// Simple wrapper that implements Connection for s2n-tls streams
struct S2nTlsConn<T>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
inner: TokioIo<s2n_tls_tokio::TlsStream<T>>,
}
impl<T> Connection for S2nTlsConn<T>
where
T: Connection + tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
fn connected(&self) -> Connected {
// For tunneled connections, we can't easily access the underlying connection info
// from s2n-tls, so we'll return a basic Connected instance
Connected::new()
}
}
impl<T> hyper::rt::Read for S2nTlsConn<T>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: hyper::rt::ReadBufCursor<'_>,
) -> Poll<tokio::io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
}
}
impl<T> hyper::rt::Write for S2nTlsConn<T>
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, tokio::io::Error>> {
Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
}
fn poll_flush(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), tokio::io::Error>> {
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
}
fn poll_shutdown(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), tokio::io::Error>> {
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<Result<usize, tokio::io::Error>> {
Pin::new(&mut self.get_mut().inner).poll_write_vectored(cx, bufs)
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
}
}