moonfire-nvr/server/src/web/websocket.rs
Scott Lamb 6a5b751bd6 log error messages in web paths
HTTP requests were only returning the error message to the caller, not
logging locally. In most cases the problem could be understood
client-side, but there are some exceptions. E.g. if Moonfire returns
a 403 on WebSocket update, even in the Chrome debug tools's network
tab the HTTP response body seems to be unavailable. And in general,
it's nice to have more context server-side.

Logging a `response::Body` isn't practical (it could be a stream), so
convert all the web stuff to use `base::Error` err returns.

Convert the `METHOD_NOT_ALLOWED` paths to return `Ok` for now. This is a
bit lame but punts on having some way of plumbing an explicit/overridden
status code in `base::Error`, as no gRPC error kind cleanly maps to
that.

Also convert `db::auth`, rather than making up an error kind in the web
layer.

This is also a small step toward getting rid of `failure::Error`.
2023-07-09 10:15:56 -07:00

148 lines
5.4 KiB
Rust

// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
//! Common code for WebSockets, including the live view WebSocket and a future
//! WebSocket for watching database changes.
use std::pin::Pin;
use crate::body::Body;
use base::{bail_t, format_err_t};
use futures::{Future, SinkExt};
use http::{header, Request, Response};
use tokio_tungstenite::{tungstenite, WebSocketStream};
use tracing::Instrument;
/// Upgrades to WebSocket and runs the supplied stream handler in a separate tokio task.
///
/// Fails on `Origin` mismatch with an HTTP-level error. If the handler returns
/// an error, tries to send it to the client before dropping the stream.
pub(super) fn upgrade<H>(
req: Request<::hyper::Body>,
handler: H,
) -> Result<Response<Body>, base::Error>
where
for<'a> H: FnOnce(
&'a mut WebSocketStream<hyper::upgrade::Upgraded>,
) -> Pin<Box<dyn Future<Output = Result<(), base::Error>> + Send + 'a>>
+ Send
+ 'static,
{
// An `Origin` mismatch should be a HTTP-level error; this is likely a cross-site attack,
// and using HTTP-level errors avoids giving any information to the Javascript running in
// the browser.
check_origin(req.headers())?;
// Otherwise, upgrade and handle the rest in a separate task.
let response =
tungstenite::handshake::server::create_response_with_body(&req, hyper::Body::empty)
.map_err(|e| format_err_t!(InvalidArgument, "{}", e.to_string()))?;
let (parts, _) = response.into_parts();
let span = tracing::info_span!("websocket");
tokio::spawn(
async move {
let upgraded = match hyper::upgrade::on(req).await {
Ok(u) => u,
Err(err) => {
tracing::error!(%err, "upgrade failed");
return;
}
};
let mut ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
upgraded,
tungstenite::protocol::Role::Server,
None,
)
.await;
if let Err(err) = handler(&mut ws).await {
// TODO: use a nice JSON message format for errors.
tracing::error!(%err, "closing with error");
let _ = ws.send(tungstenite::Message::Text(err.to_string())).await;
} else {
tracing::info!("closing");
};
let _ = ws.close(None).await;
}
.instrument(span),
);
Ok(Response::from_parts(parts, Body::from("")))
}
/// Checks the `Host` and `Origin` headers match, if the latter is supplied.
///
/// Web browsers must supply origin, according to [RFC 6455 section
/// 4.1](https://datatracker.ietf.org/doc/html/rfc6455#section-4.1).
/// It's not required for non-browser HTTP clients.
///
/// If present, verify it. Chrome doesn't honor the `s=` cookie's
/// `SameSite=Lax` setting for WebSocket requests, so this is the sole
/// protection against [CSWSH](https://christian-schneider.net/CrossSiteWebSocketHijacking.html).
fn check_origin(headers: &header::HeaderMap) -> Result<(), base::Error> {
let origin_hdr = match headers.get(http::header::ORIGIN) {
None => return Ok(()),
Some(o) => o,
};
let Some(host_hdr) = headers.get(header::HOST) else {
bail_t!(InvalidArgument, "missing Host header");
};
let host_str = host_hdr
.to_str()
.map_err(|_| format_err_t!(InvalidArgument, "bad Host header"))?;
// Currently this ignores the port number. This is easiest and I think matches the browser's
// rules for when it sends a cookie, so it probably doesn't cause great security problems.
let host = match host_str.split_once(':') {
Some((host, _port)) => host,
None => host_str,
};
let origin_url = origin_hdr
.to_str()
.ok()
.and_then(|o| url::Url::parse(o).ok())
.ok_or_else(|| format_err_t!(InvalidArgument, "bad Origin header"))?;
let origin_host = origin_url
.host_str()
.ok_or_else(|| format_err_t!(InvalidArgument, "bad Origin header"))?;
if host != origin_host {
bail_t!(
PermissionDenied,
"cross-origin request forbidden (request host {:?}, origin {:?})",
host_hdr,
origin_hdr
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::convert::TryInto;
use super::*;
#[test]
fn origin_port_8080_okay() {
// By default, Moonfire binds to port 8080. Make sure that specifying a port number works.
let mut hdrs = header::HeaderMap::new();
hdrs.insert(header::HOST, "nvr:8080".try_into().unwrap());
hdrs.insert(header::ORIGIN, "http://nvr:8080/".try_into().unwrap());
assert!(check_origin(&hdrs).is_ok());
}
#[test]
fn origin_missing_okay() {
let mut hdrs = header::HeaderMap::new();
hdrs.insert(header::HOST, "nvr".try_into().unwrap());
assert!(check_origin(&hdrs).is_ok());
}
#[test]
fn origin_mismatch_fails() {
let mut hdrs = header::HeaderMap::new();
hdrs.insert(header::HOST, "nvr".try_into().unwrap());
hdrs.insert(header::ORIGIN, "http://evil/".try_into().unwrap());
assert!(check_origin(&hdrs).is_err());
}
}