use std::convert::Infallible;
use std::pin::Pin;
use std::task::{Context, Poll};
use http_body_util::{BodyExt, Empty};
use hyper::body::{Body, Bytes, Frame, Incoming};
use hyper::header::{HeaderMap, HeaderName, HeaderValue};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::{TcpListener, TcpStream};
use waf_core::{
Config, GrpcConfig, WafMode,
};
use waf_proxy::Proxy;
fn varint(mut v: u64, out: &mut Vec<u8>) {
loop {
let mut b = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
b |= 0x80;
}
out.push(b);
if v == 0 {
break;
}
}
}
fn len_field(field: u64, data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
varint((field << 3) | 2, &mut out);
varint(data.len() as u64, &mut out);
out.extend_from_slice(data);
out
}
fn frame(msg: &[u8]) -> Bytes {
let mut out = vec![0u8];
out.extend_from_slice(&(msg.len() as u32).to_be_bytes());
out.extend_from_slice(msg);
Bytes::from(out)
}
struct FramedBody {
data: Option<Bytes>,
trailers: Option<HeaderMap>,
}
impl Body for FramedBody {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
if let Some(d) = self.data.take() {
return Poll::Ready(Some(Ok(Frame::data(d))));
}
if let Some(t) = self.trailers.take() {
return Poll::Ready(Some(Ok(Frame::trailers(t))));
}
Poll::Ready(None)
}
}
fn hv(s: &str) -> HeaderValue {
HeaderValue::from_str(s).unwrap()
}
async fn collect(body: Incoming) -> (Bytes, Option<HeaderMap>) {
let c = body.collect().await.unwrap();
let t = c.trailers().cloned();
(c.to_bytes(), t)
}
async fn start_grpc_backend() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(async move {
let svc = service_fn(|req: Request<Incoming>| async move {
let (data, tr) = collect(req.into_body()).await;
let echoed = tr
.as_ref()
.and_then(|t| t.get("x-req-trailer"))
.and_then(|v| v.to_str().ok())
.unwrap_or("<none>")
.to_string();
let mut out_tr = HeaderMap::new();
out_tr.insert(HeaderName::from_static("grpc-status"), hv("0"));
out_tr.insert(HeaderName::from_static("x-echo-req-trailer"), hv(&echoed));
let body = FramedBody {
data: Some(Bytes::from(format!("ok:{}", data.len()))),
trailers: Some(out_tr),
};
let mut resp = Response::new(body);
resp.headers_mut().insert(HeaderName::from_static("content-type"), hv("application/grpc"));
Ok::<_, Infallible>(resp)
});
hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), svc)
.await
.ok();
});
}
});
addr
}
fn grpc_config(backend: std::net::SocketAddr) -> Config {
let mut c = Config::default();
c.proxy.listen = "127.0.0.1:0".parse().unwrap();
c.proxy.backend = format!("http://{backend}");
c.waf.mode = WafMode::Blocking;
c.modules.grpc = GrpcConfig { enabled: true, ..Default::default() };
c
}
async fn grpc_call(
proxy: std::net::SocketAddr,
body: Bytes,
req_trailer: Option<&str>,
) -> (u16, Bytes, Option<HeaderMap>) {
let tcp = TcpStream::connect(proxy).await.unwrap();
let (mut sender, conn) =
hyper::client::conn::http2::handshake(TokioExecutor::new(), TokioIo::new(tcp)).await.unwrap();
tokio::spawn(async move { let _ = conn.await; });
let trailers = req_trailer.map(|v| {
let mut t = HeaderMap::new();
t.insert(HeaderName::from_static("x-req-trailer"), hv(v));
t
});
let req = Request::builder()
.method("POST")
.uri("/grpc.Svc/Call")
.header("content-type", "application/grpc")
.header("te", "trailers")
.body(FramedBody { data: Some(body), trailers })
.unwrap();
let resp = sender.send_request(req).await.unwrap();
let status = resp.status().as_u16();
let (data, tr) = collect(resp.into_body()).await;
(status, data, tr)
}
async fn start_proxy(backend: std::net::SocketAddr) -> std::net::SocketAddr {
let proxy = Proxy::bind(&grpc_config(backend)).await.unwrap();
let addr = proxy.local_addr().unwrap();
tokio::spawn(proxy.run());
addr
}
#[tokio::test]
async fn benign_unary_forwarded_with_trailers_both_ways() {
let backend = start_grpc_backend().await;
let proxy = start_proxy(backend).await;
let body = frame(&len_field(1, b"hello gRPC"));
let (status, data, trailers) = grpc_call(proxy, body, Some("trailer-from-client")).await;
assert_eq!(status, 200);
assert!(data.starts_with(b"ok:"), "response body relayed: {data:?}");
let tr = trailers.expect("gRPC response must carry trailers");
assert_eq!(tr.get("grpc-status").unwrap(), "0");
assert_eq!(tr.get("x-echo-req-trailer").unwrap(), "trailer-from-client");
}
#[tokio::test]
async fn sqli_in_grpc_field_is_blocked() {
let backend = start_grpc_backend().await;
let proxy = start_proxy(backend).await;
let sqli = "1 UNION SELECT a,b FROM users--";
let body = frame(&len_field(1, sqli.as_bytes()));
let (status, _data, _tr) = grpc_call(proxy, body, None).await;
assert_eq!(status, 403, "SQLi inside a protobuf field must be blocked before forwarding");
}
#[allow(dead_code)]
fn _empty() -> Empty<Bytes> {
Empty::new()
}