use std::net::SocketAddr;
use async_trait::async_trait;
use bytes::BytesMut;
use eyre::{Result, eyre};
use nago_wss::conn::{Connection, Error as ConnError, Role};
use nago_wss::proto::handshake::{
DEFAULT_MAX_HEAD, Method, Parsed, Request, UpgradeError, build_rejection, build_response,
head_end, parse_request,
};
use nago_wss::proto::message::Limits;
use nago_wss::stream::StreamExt;
use tracing::*;
use super::super::{
WsMessage as Message, WsServerConfig,
traits::{BoxedStream, MessageStream, StreamError, UpgradeEvent, WsUpgrader},
};
const ALLOWED_METHODS: &str = "GET, HEAD, OPTIONS";
const DEFAULT_ALLOW_HEADERS: &str = "Content-Type, Authorization, Sec-WebSocket-Key, \
Sec-WebSocket-Version, Sec-WebSocket-Extensions, Sec-WebSocket-Protocol";
impl StreamExt for BoxedStream {}
pub struct NagoWssUpgrader;
#[deprecated(note = "renamed to NagoWssUpgrader; hyper and tungstenite are gone from this path")]
pub type HyperTungsteniteUpgrader = NagoWssUpgrader;
#[async_trait(?Send)]
impl WsUpgrader for NagoWssUpgrader {
async fn upgrade_stream(
&self,
stream: BoxedStream,
addr: SocketAddr,
config: &WsServerConfig,
) -> Result<Option<UpgradeEvent>> {
let upgrade = self.upgrade_inner(stream, addr, config);
match config.handshake_timeout {
Some(timeout) => nagoya::timeout(timeout, upgrade)
.await
.map_err(|_| eyre!("HTTP upgrade deadline exceeded"))?,
None => upgrade.await,
}
}
}
impl NagoWssUpgrader {
async fn upgrade_inner(
&self,
mut stream: BoxedStream,
addr: SocketAddr,
config: &WsServerConfig,
) -> Result<Option<UpgradeEvent>> {
let (head, rest) = match read_head(&mut stream).await? {
HeadOutcome::Head(head, rest) => (head, rest),
HeadOutcome::Eof => {
debug!(
ws_server = true,
?addr,
"peer closed before finishing its request head"
);
return Ok(None);
}
HeadOutcome::TooLarge => {
debug!(ws_server = true, ?addr, "request head over the size limit");
let extra = Headers::new(config, None, None).error_extra();
let rejection = build_rejection(UpgradeError::HeadTooLarge, &borrow(&extra));
write_all(&mut stream, &rejection).await?;
return Ok(None);
}
};
let parsed = match parse_request(&head, DEFAULT_MAX_HEAD) {
Ok(parsed) => parsed,
Err(error) => {
debug!(ws_server = true, ?addr, ?error, "unparseable request head");
let extra = Headers::new(config, None, None).error_extra();
let rejection = build_rejection(error, &borrow(&extra));
let _ = write_all(&mut stream, &rejection).await;
return Ok(None);
}
};
let request = match parsed {
Parsed::Plain(request) => {
let headers = Headers::from_request(config, &request);
let response = match &request.method {
Method::Options | Method::Get | Method::Head => {
plain_response("200 OK", &headers.ok_extra())
}
Method::Other(method) => {
debug!(
ws_server = true,
?addr,
%method,
"method not allowed on the WebSocket endpoint"
);
let mut extra = headers.error_extra();
extra.push(("Allow".to_string(), ALLOWED_METHODS.to_string()));
plain_response("405 Method Not Allowed", &extra)
}
};
write_all(&mut stream, &response).await?;
return Ok(None);
}
Parsed::Upgrade(request) => request,
};
let selected = request.protocols.first().cloned();
let headers = Headers::from_request(config, &request);
let extra = headers.ok_extra();
let borrowed = borrow(&extra);
write_all(
&mut stream,
&build_response(&request.key, selected.as_deref(), &borrowed),
)
.await?;
debug!(
ws_server = true,
?addr,
path = %request.path,
"upgrade accepted, 101 sent"
);
let limits = config
.max_message_bytes
.map_or_else(Limits::default, |max| Limits {
max_frame: max as u64,
max_message: max,
});
let connection = Connection::with_buffered(stream, Role::Server, limits, rest);
Ok(Some(UpgradeEvent {
stream: Box::new(NagoWsStream { inner: connection }),
protocol: request.protocols.join(", "),
}))
}
}
enum HeadOutcome {
Head(Vec<u8>, BytesMut),
Eof,
TooLarge,
}
async fn read_head(stream: &mut BoxedStream) -> Result<HeadOutcome> {
let mut buffer = BytesMut::with_capacity(2 * 1024);
let mut chunk = [0u8; 2 * 1024];
loop {
if let Some(end) = head_end(&buffer) {
let rest = buffer.split_off(end);
return Ok(HeadOutcome::Head(buffer.to_vec(), rest));
}
if buffer.len() > DEFAULT_MAX_HEAD {
return Ok(HeadOutcome::TooLarge);
}
let read = nagoya::io::Stream::read(stream, &mut chunk)
.await
.map_err(|err| eyre!("reading the request head: {err}"))?;
if read == 0 {
return Ok(HeadOutcome::Eof);
}
buffer.extend_from_slice(&chunk[..read]);
}
}
async fn write_all(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> {
nagoya::io::Stream::write_all(stream, bytes)
.await
.map_err(|err| eyre!("writing the handshake response: {err}"))
}
fn plain_response(status: &str, extra: &[(String, String)]) -> Vec<u8> {
let mut out = String::with_capacity(160);
out.push_str("HTTP/1.1 ");
out.push_str(status);
out.push_str("\r\nConnection: close\r\nContent-Length: 0\r\n");
for (name, value) in extra {
out.push_str(name);
out.push_str(": ");
out.push_str(value);
out.push_str("\r\n");
}
out.push_str("\r\n");
out.into_bytes()
}
fn borrow(extra: &[(String, String)]) -> Vec<(&str, &str)> {
extra
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect()
}
struct Headers {
server: String,
cors: Vec<(String, String)>,
}
impl Headers {
fn from_request(config: &WsServerConfig, request: &Request) -> Self {
Self::new(
config,
header(request, "origin"),
header(request, "access-control-request-headers"),
)
}
fn new(config: &WsServerConfig, origin: Option<&str>, requested_headers: Option<&str>) -> Self {
let app = option_env!("WS_SERVER_NAME").unwrap_or(&config.server_name);
let server = format!("{} endpointlibs/{}", app, env!("CARGO_PKG_VERSION"));
let allow_headers = requested_headers
.filter(|value| is_safe_header_value(value))
.unwrap_or(DEFAULT_ALLOW_HEADERS)
.to_string();
let mut cors = Vec::new();
match config.allow_cors_urls.as_ref() {
None => {
cors.push(("Access-Control-Allow-Origin".into(), "*".into()));
cors.push(("Timing-Allow-Origin".into(), "*".into()));
cors.push((
"Access-Control-Allow-Methods".into(),
ALLOWED_METHODS.into(),
));
cors.push(("Access-Control-Allow-Headers".into(), allow_headers));
cors.push(("Access-Control-Max-Age".into(), "86400".into()));
}
Some(domains) => {
let Some(origin) = origin else {
return Self { server, cors };
};
if !domains.iter().any(|domain| domain == origin) {
return Self { server, cors };
}
cors.push(("Access-Control-Allow-Origin".into(), origin.to_string()));
cors.push(("Timing-Allow-Origin".into(), origin.to_string()));
cors.push(("Vary".into(), "Origin".into()));
cors.push(("Access-Control-Allow-Credentials".into(), "true".into()));
cors.push((
"Access-Control-Allow-Methods".into(),
ALLOWED_METHODS.into(),
));
cors.push(("Access-Control-Allow-Headers".into(), allow_headers));
cors.push(("Access-Control-Max-Age".into(), "86400".into()));
}
}
Self { server, cors }
}
fn ok_extra(&self) -> Vec<(String, String)> {
let mut extra = Vec::with_capacity(self.cors.len() + 2);
extra.push(("Server".to_string(), self.server.clone()));
extra.push(("X-Content-Type-Options".to_string(), "nosniff".to_string()));
extra.extend(self.cors.iter().cloned());
extra
}
fn error_extra(&self) -> Vec<(String, String)> {
let mut extra = self.ok_extra();
extra.push(("Cache-Control".to_string(), "no-store".to_string()));
extra
}
}
fn header<'a>(request: &'a Request, name: &str) -> Option<&'a str> {
request
.headers
.iter()
.find(|(had, _)| had.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
fn is_safe_header_value(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte == b'\t' || (0x20..0x7F).contains(&byte))
}
struct NagoWsStream {
inner: Connection<BoxedStream>,
}
#[async_trait(?Send)]
impl MessageStream for NagoWsStream {
async fn send(&mut self, msg: Message) -> Result<(), StreamError> {
self.inner.write(msg.into()).await.map_err(map_err)
}
async fn recv(&mut self) -> Option<Result<Message, StreamError>> {
match self.inner.read().await {
Ok(Some(message)) => Some(Ok(message.into())),
Ok(None) => None,
Err(error) => Some(Err(map_err(error))),
}
}
}
fn map_err(error: ConnError) -> StreamError {
match error {
ConnError::UnexpectedEof => StreamError::Closed,
ConnError::Frame(_) | ConnError::Protocol(_) | ConnError::MaskingViolation => {
StreamError::Protocol(error.to_string())
}
other => StreamError::Other(eyre!(other.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn request(headers: &[(&str, &str)]) -> Request {
Request {
method: Method::Get,
path: "/".to_string(),
key: Vec::new(),
protocols: Vec::new(),
headers: headers
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect(),
}
}
fn config(allow: Option<Vec<String>>) -> WsServerConfig {
WsServerConfig {
allow_cors_urls: Arc::new(allow),
..Default::default()
}
}
fn value<'a>(extra: &'a [(String, String)], name: &str) -> Option<&'a str> {
extra
.iter()
.find(|(had, _)| had.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
#[test]
fn no_allowlist_means_wildcard_and_no_credentials() {
let extra = Headers::from_request(&config(None), &request(&[])).ok_extra();
assert_eq!(value(&extra, "access-control-allow-origin"), Some("*"));
assert_eq!(value(&extra, "timing-allow-origin"), Some("*"));
assert_eq!(value(&extra, "access-control-allow-credentials"), None);
assert_eq!(
value(&extra, "access-control-allow-methods"),
Some(ALLOWED_METHODS)
);
}
#[test]
fn an_allowlisted_origin_is_echoed_and_varied_on() {
let config = config(Some(vec!["https://app.example.com".to_string()]));
let extra =
Headers::from_request(&config, &request(&[("Origin", "https://app.example.com")]))
.ok_extra();
assert_eq!(
value(&extra, "access-control-allow-origin"),
Some("https://app.example.com")
);
assert_eq!(value(&extra, "vary"), Some("Origin"));
assert_eq!(
value(&extra, "access-control-allow-credentials"),
Some("true")
);
}
#[test]
fn an_origin_off_the_allowlist_gets_no_cors_headers() {
let config = config(Some(vec!["https://app.example.com".to_string()]));
for origin in [None, Some("https://evil.example.com")] {
let headers: Vec<(&str, &str)> = origin
.map(|value| vec![("Origin", value)])
.unwrap_or_default();
let extra = Headers::from_request(&config, &request(&headers)).ok_extra();
assert_eq!(
value(&extra, "access-control-allow-origin"),
None,
"a refused origin was echoed: {origin:?}"
);
assert!(value(&extra, "server").is_some());
}
}
#[test]
fn a_preflight_gets_the_headers_it_asked_for() {
let asked = "authorization, x-trace";
let extra = Headers::from_request(
&config(None),
&request(&[("Access-Control-Request-Headers", asked)]),
)
.ok_extra();
assert_eq!(value(&extra, "access-control-allow-headers"), Some(asked));
let extra = Headers::from_request(&config(None), &request(&[])).ok_extra();
assert_eq!(
value(&extra, "access-control-allow-headers"),
Some(DEFAULT_ALLOW_HEADERS)
);
}
#[test]
fn a_header_value_with_control_bytes_is_not_echoed() {
let extra = Headers::from_request(
&config(None),
&request(&[("Access-Control-Request-Headers", "authorization\u{7f}")]),
)
.ok_extra();
assert_eq!(
value(&extra, "access-control-allow-headers"),
Some(DEFAULT_ALLOW_HEADERS)
);
}
#[test]
fn only_a_failure_is_marked_uncacheable() {
let headers = Headers::from_request(&config(None), &request(&[]));
assert_eq!(value(&headers.ok_extra(), "cache-control"), None);
assert_eq!(
value(&headers.error_extra(), "cache-control"),
Some("no-store")
);
}
#[test]
fn the_first_of_two_origins_decides() {
let config = config(Some(vec!["https://app.example.com".to_string()]));
let extra = Headers::from_request(
&config,
&request(&[
("Origin", "https://evil.example.com"),
("Origin", "https://app.example.com"),
]),
)
.ok_extra();
assert_eq!(value(&extra, "access-control-allow-origin"), None);
}
#[test]
fn a_plain_response_ends_the_head_exactly_once() {
let response = plain_response("200 OK", &[("Server".into(), "test".into())]);
let text = String::from_utf8(response).expect("ASCII response");
assert!(text.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(text.contains("\r\nServer: test\r\n"));
assert!(text.ends_with("\r\n\r\n"));
assert_eq!(text.matches("\r\n\r\n").count(), 1);
assert!(text.contains("\r\nContent-Length: 0\r\n"));
}
}