Skip to main content

rws_gateway_api/gateway_api/
ext_auth.rs

1//! Listener filters (Gateway API v1.7, experimental, GEP-5224): a listener's
2//! `filters.requests` run in order on every HTTP request, before a route is
3//! picked. rws supports `ExternalAuth`:
4//!
5//! - **HTTP**: the request's method and path (after `http.path`, the
6//!   prefix) go to the authorization server with `Host`, `Authorization`,
7//!   `Content-Length` and the `allowedHeaders`, and the body up to
8//!   `forwardBody.maxSize`. `200` allows the request, which gets the
9//!   server's response headers (`allowedResponseHeaders`, else all but
10//!   `Host`/`:authority` and framing); any other status is the answer.
11//! - **GRPC**: Envoy's `ext_authz` (`envoy.service.auth.v3.Authorization/Check`)
12//!   over HTTP/2 — the request's attributes (method, path, host, scheme,
13//!   headers, body) in a `CheckRequest`; status `OK` allows it (with the
14//!   `ok_response`'s header changes), anything else answers with the
15//!   `denied_response` (`403` by default).
16//!
17//! A request an authorization server denies goes no further; changes to
18//! `Host`/`:authority` are ignored (the listener was already chosen). An
19//! authorization server that can't be reached denies with `403`: the
20//! filter fails closed.
21
22pub mod proto;
23
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::time::Duration;
26
27use crate::header::Header;
28use crate::request::Request;
29use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
30
31use super::backend_tls::BackendTls;
32use super::types::HttpExternalAuthFilter;
33
34/// How long an authorization request may take.
35const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
36
37/// One `ExternalAuth` filter, its server resolved.
38#[derive(Clone, Debug, PartialEq)]
39pub struct CompiledAuth {
40    pub filter: HttpExternalAuthFilter,
41    /// The server's ready `ip:port`s; `Err` when the reference doesn't resolve.
42    pub endpoints: Result<Vec<String>, String>,
43    /// TLS to the server, when a BackendTLSPolicy covers it.
44    pub tls: Option<Result<BackendTls, String>>,
45}
46
47/// What an authorization server decided.
48#[derive(Debug)]
49pub enum Decision {
50    /// Go on, with these headers set and these removed.
51    Allow { set: Vec<(String, String)>, remove: Vec<String> },
52    /// This is the answer.
53    Deny(Response),
54}
55
56/// Headers a filter never changes: the listener was chosen by them.
57fn protected(name: &str) -> bool {
58    matches!(name.to_ascii_lowercase().as_str(), "host" | ":authority")
59}
60
61/// Run `filters` in order on `request`: the request as they left it
62/// (`None`: unchanged), or the answer when one denies it.
63pub fn run(filters: &[CompiledAuth], request: &Request, client: crate::proxy::Peer<'_>, counter: &AtomicUsize) -> Result<Option<Request>, Response> {
64    if filters.is_empty() {
65        return Ok(None);
66    }
67    let mut current = request.clone();
68    for filter in filters {
69        match check(filter, &current, client, counter) {
70            Decision::Deny(response) => return Err(response),
71            Decision::Allow { set, remove } => {
72                for name in remove.iter().filter(|n| !protected(n)) {
73                    current.headers.retain(|h| !h.name.eq_ignore_ascii_case(name));
74                }
75                for (name, value) in set.into_iter().filter(|(n, _)| !protected(n)) {
76                    current.headers.retain(|h| !h.name.eq_ignore_ascii_case(&name));
77                    current.headers.push(Header::new(name, value));
78                }
79            }
80        }
81    }
82    Ok(Some(current))
83}
84
85fn forbidden(message: &str) -> Response {
86    Response::text(STATUS_CODE_REASON_PHRASE.n403_forbidden, message)
87}
88
89/// Ask one authorization server.
90pub fn check(auth: &CompiledAuth, request: &Request, client: crate::proxy::Peer<'_>, counter: &AtomicUsize) -> Decision {
91    let endpoint = match &auth.endpoints {
92        Ok(endpoints) if !endpoints.is_empty() => endpoints[counter.fetch_add(1, Ordering::Relaxed) % endpoints.len()].clone(),
93        _ => return Decision::Deny(forbidden("the authorization server isn't available")),
94    };
95    let Some((ip, port)) = endpoint.rsplit_once(':').and_then(|(ip, port)| Some((ip.trim_start_matches('[').trim_end_matches(']').to_string(), port.parse::<u16>().ok()?))) else {
96        return Decision::Deny(forbidden("the authorization server isn't available"));
97    };
98    let tls = match &auth.tls {
99        None => None,
100        Some(Ok(tls)) => Some(tls),
101        Some(Err(_)) => return Decision::Deny(forbidden("the authorization server's TLS isn't usable")),
102    };
103    if auth.filter.protocol.eq_ignore_ascii_case("GRPC") {
104        check_grpc(&auth.filter, request, client, &ip, port, tls)
105    } else {
106        check_http(&auth.filter, request, client, &ip, port, tls)
107    }
108}
109
110/// The body to forward: up to `forwardBody.maxSize` bytes.
111fn forwarded_body<'a>(filter: &HttpExternalAuthFilter, request: &'a Request) -> &'a [u8] {
112    let max = filter.forward_body.as_ref().map_or(0, |f| f.max_size.max(0) as usize);
113    &request.body[..request.body.len().min(max)]
114}
115
116/// The HTTP authorization request for `request`.
117pub fn http_request(filter: &HttpExternalAuthFilter, request: &Request) -> Request {
118    let config = filter.http.clone().unwrap_or_default();
119    let prefix = config.path.trim_end_matches('/');
120    let mut headers = Vec::new();
121    for h in &request.headers {
122        let always = matches!(h.name.to_ascii_lowercase().as_str(), "host" | "authorization");
123        if always || config.allowed_headers.iter().any(|a| a.eq_ignore_ascii_case(&h.name)) {
124            headers.push(h.clone());
125        }
126    }
127    let body = forwarded_body(filter, request).to_vec();
128    headers.retain(|h| !h.name.eq_ignore_ascii_case("content-length"));
129    headers.push(Header::new("Content-Length", body.len().to_string()));
130    Request { method: request.method.clone(), request_uri: format!("{prefix}{}", request.request_uri), http_version: "HTTP/1.1".to_string(), headers, body: body.into() }
131}
132
133/// Response headers never copied into the request.
134fn not_copied(name: &str) -> bool {
135    matches!(name.to_ascii_lowercase().as_str(), "host" | ":authority" | "content-length" | "transfer-encoding" | "connection" | "keep-alive" | "proxy-connection" | "te" | "upgrade" | "trailer")
136}
137
138/// An HTTP authorization server's answer as a decision.
139pub fn http_decision(filter: &HttpExternalAuthFilter, response: Response) -> Decision {
140    if response.status_code != 200 {
141        return Decision::Deny(response);
142    }
143    let allowed = filter.http.as_ref().map(|h| h.allowed_response_headers.clone()).unwrap_or_default();
144    let set = response
145        .headers
146        .iter()
147        .filter(|h| !not_copied(&h.name))
148        .filter(|h| allowed.is_empty() || allowed.iter().any(|a| a.eq_ignore_ascii_case(&h.name)))
149        .map(|h| (h.name.to_string(), h.value.to_string()))
150        .collect();
151    Decision::Allow { set, remove: Vec::new() }
152}
153
154fn check_http(filter: &HttpExternalAuthFilter, request: &Request, client: crate::proxy::Peer<'_>, ip: &str, port: u16, tls: Option<&BackendTls>) -> Decision {
155    let auth_request = http_request(filter, request);
156    let host = request.header("host").map(|h| h.value.to_string()).unwrap_or_else(|| format!("{ip}:{port}"));
157    let answered = match tls {
158        Some(tls) => crate::proxy::forward_tls_with_host(&tls.config, &tls.label, &auth_request, client, ip, port, &tls.server_name, &host, AUTH_TIMEOUT, AUTH_TIMEOUT),
159        None => crate::proxy::forward_pooled_with_host(crate::proxy::shared_pool(), &auth_request, client, ip, port, &host, AUTH_TIMEOUT, AUTH_TIMEOUT),
160    };
161    match answered {
162        Ok(response) => http_decision(filter, buffered(response)),
163        Err(_) => Decision::Deny(forbidden("the authorization server didn't answer")),
164    }
165}
166
167/// A response with a streamed body read into memory (an authorization
168/// answer is small).
169fn buffered(mut response: Response) -> Response {
170    if let Some(mut pipe) = response.stream_pipe.take() {
171        let mut body = Vec::new();
172        let _ = std::io::Read::read_to_end(&mut std::io::Read::take(&mut pipe, 1 << 20), &mut body);
173        response.content_range_list = vec![crate::range::Range::get_content_range(body, "application/octet-stream".to_string())];
174    }
175    response
176}
177
178fn body_of(response: &Response) -> Vec<u8> {
179    response.content_range_list.iter().flat_map(|c| c.body.iter().copied()).collect()
180}
181
182fn check_grpc(filter: &HttpExternalAuthFilter, request: &Request, client: crate::proxy::Peer<'_>, ip: &str, port: u16, tls: Option<&BackendTls>) -> Decision {
183    let allowed = filter.grpc.as_ref().map(|g| g.allowed_headers.clone()).unwrap_or_default();
184    let headers: Vec<(String, String)> = request
185        .headers
186        .iter()
187        .filter(|h| allowed.is_empty() || allowed.iter().any(|a| a.eq_ignore_ascii_case(&h.name)))
188        .map(|h| (h.name.to_ascii_lowercase(), h.value.to_string()))
189        .collect();
190    let host = request.header("host").map(|h| h.value.to_string()).unwrap_or_default();
191    let scheme = if client.tls { "https" } else { "http" };
192    let message = proto::check_request(&proto::HttpAttributes {
193        method: &request.method,
194        path: &request.request_uri,
195        host: &host,
196        scheme,
197        headers: &headers,
198        body: forwarded_body(filter, request),
199        source_ip: client.ip,
200    });
201    let mut frame = vec![0u8];
202    frame.extend_from_slice(&(message.len() as u32).to_be_bytes());
203    frame.extend_from_slice(&message);
204    let call = Request {
205        method: "POST".to_string(),
206        request_uri: "/envoy.service.auth.v3.Authorization/Check".to_string(),
207        http_version: "HTTP/2".to_string(),
208        headers: vec![Header::new("Content-Type", "application/grpc"), Header::new("TE", "trailers")],
209        body: frame.into(),
210    };
211    let authority = format!("{ip}:{port}");
212    let answered = match tls {
213        Some(tls) => crate::proxy::forward_h2_tls_with_host(&tls.h2_config, &call, client, ip, port, &tls.server_name, &authority, AUTH_TIMEOUT, AUTH_TIMEOUT),
214        None => crate::proxy::forward_h2c_with_host(&call, client, ip, port, &authority, AUTH_TIMEOUT, AUTH_TIMEOUT),
215    };
216    let Ok(response) = answered else { return Decision::Deny(forbidden("the authorization server didn't answer")) };
217    let response = buffered(response);
218    let grpc_status = response
219        .trailers
220        .as_ref()
221        .map(|t| t.get())
222        .into_iter()
223        .flatten()
224        .chain(response.headers.iter().cloned())
225        .find(|h| h.name.eq_ignore_ascii_case("grpc-status"))
226        .map(|h| h.value.to_string());
227    if grpc_status.as_deref() != Some("0") {
228        return Decision::Deny(forbidden("the authorization check failed"));
229    }
230    let body = body_of(&response);
231    let Some(message) = body.get(5..).filter(|_| body.first() == Some(&0)) else {
232        return Decision::Deny(forbidden("the authorization server's answer isn't a CheckResponse"));
233    };
234    match proto::check_response(message) {
235        Ok(verdict) => grpc_decision(verdict),
236        Err(_) => Decision::Deny(forbidden("the authorization server's answer isn't a CheckResponse")),
237    }
238}
239
240/// A `CheckResponse` as a decision.
241pub fn grpc_decision(verdict: proto::Verdict) -> Decision {
242    if verdict.code == 0 {
243        return Decision::Allow { set: verdict.headers, remove: verdict.headers_to_remove };
244    }
245    let status = u16::try_from(verdict.denied_status).ok().filter(|s| (400..600).contains(s)).unwrap_or(403);
246    let reason = Response::status_code_reason_phrase_list().into_iter().find(|p| *p.status_code == status as i16).unwrap_or(STATUS_CODE_REASON_PHRASE.n403_forbidden);
247    let mut response = Response::text(reason, &verdict.denied_body);
248    for (name, value) in verdict.headers.into_iter().filter(|(n, _)| !protected(n)) {
249        response.headers.retain(|h| !h.name.eq_ignore_ascii_case(&name));
250        response.headers.push(Header::new(name, value));
251    }
252    Decision::Deny(response)
253}
254
255#[cfg(test)]
256mod tests;