Skip to main content

mz_environmentd/http/
console.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apach
9
10//! HTTP endpoints for the web console.
11
12use std::collections::BTreeMap;
13use std::sync::{Arc, LazyLock};
14
15use axum::Extension;
16use axum::Json;
17use axum::body::Body;
18use axum::http::{Request, StatusCode};
19use axum::response::{IntoResponse, Response};
20use http::header::{CONTENT_TYPE, COOKIE, HOST, LOCATION, ORIGIN, SET_COOKIE};
21use http::{HeaderMap, HeaderValue, Method};
22use hyper::Uri;
23use hyper_tls::HttpsConnector;
24use hyper_util::client::legacy::Client;
25use hyper_util::client::legacy::connect::HttpConnector;
26use hyper_util::rt::TokioExecutor;
27use mz_adapter_types::dyncfgs::{CONSOLE_OIDC_CLIENT_ID, CONSOLE_OIDC_SCOPES, OIDC_ISSUER};
28
29use crate::http::Delayed;
30
31/// Query parameter that selects (or, with an empty value, clears) the console
32/// preview build this proxy serves to the requesting browser.
33const PREVIEW_BUILD_PARAM: &str = "preview_build";
34
35/// Cookie storing the selected preview build label.
36const PREVIEW_BUILD_COOKIE: &str = "mz_console_preview_build";
37
38/// Preview selections expire after a day so stale cookies drift back to the
39/// default build.
40const PREVIEW_BUILD_COOKIE_MAX_AGE_SECS: u64 = 60 * 60 * 24;
41
42pub(crate) struct ConsoleProxyConfig {
43    /// Hyper http client, supports https.
44    client: Client<HttpsConnector<HttpConnector>, Body>,
45
46    /// URL of upstream console to proxy to (e.g. <https://console.materialize.com>).
47    url: String,
48
49    /// Route this is being served from (e.g. /internal-console).
50    route_prefix: String,
51
52    /// Host of `url`, under which preview builds are served as subdomains
53    /// (e.g. `<label>.internal.console.materialize.com`).
54    preview_host_suffix: Option<String>,
55}
56
57impl ConsoleProxyConfig {
58    pub(crate) fn new(proxy_url: Option<String>, route_prefix: String) -> Self {
59        let mut url = proxy_url.unwrap_or_else(|| "https://console.materialize.com".to_string());
60        if let Some(new) = url.strip_suffix('/') {
61            url = new.to_string();
62        }
63        let preview_host_suffix = Uri::try_from(url.as_str())
64            .ok()
65            .and_then(|uri| uri.host().map(|host| host.to_string()));
66        Self {
67            client: Client::builder(TokioExecutor::new()).build(HttpsConnector::new()),
68            url,
69            route_prefix,
70            preview_host_suffix,
71        }
72    }
73
74    /// Returns the upstream URL serving the given preview build, or `None` if
75    /// the label is invalid or no preview host suffix could be derived.
76    ///
77    /// NOTE: This proxy runs inside the environment's network, so it must not
78    /// be usable for SSRF. Preview builds are only ever fetched over https
79    /// from a validated subdomain of the configured upstream host, never from
80    /// a caller-provided URL.
81    fn preview_url(&self, label: &str) -> Option<String> {
82        let suffix = self.preview_host_suffix.as_deref()?;
83        if !is_valid_preview_build_label(label) {
84            return None;
85        }
86        Some(format!("https://{label}.{suffix}"))
87    }
88}
89
90/// Prefix required of preview build labels. CI only creates preview aliases
91/// under this prefix, so requiring it keeps the reachable hosts to builds of
92/// console pull requests.
93const PREVIEW_BUILD_LABEL_PREFIX: &str = "console-git-";
94
95/// A valid preview build label is a DNS label (1-63 characters of lowercase
96/// ASCII alphanumerics and hyphens, not ending with a hyphen) starting with
97/// [`PREVIEW_BUILD_LABEL_PREFIX`].
98fn is_valid_preview_build_label(label: &str) -> bool {
99    label.len() <= 63
100        && label.starts_with(PREVIEW_BUILD_LABEL_PREFIX)
101        && label
102            .bytes()
103            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
104        && !label.ends_with('-')
105}
106
107/// OIDC configuration values needed by the Console to initiate OIDC login.
108static CONSOLE_CONFIG_VAR_NAMES: LazyLock<[&'static str; 3]> = LazyLock::new(|| {
109    [
110        OIDC_ISSUER.name(),
111        CONSOLE_OIDC_CLIENT_ID.name(),
112        CONSOLE_OIDC_SCOPES.name(),
113    ]
114});
115
116/// Returns system variable values the web console needs from
117/// environmentd. This endpoint requires no authentication.
118pub async fn handle_console_config(
119    Extension(adapter_client_rx): Extension<Delayed<mz_adapter::Client>>,
120) -> Result<Response, (StatusCode, String)> {
121    let adapter_client = adapter_client_rx.await.map_err(|_| {
122        (
123            StatusCode::INTERNAL_SERVER_ERROR,
124            "Adapter client unavailable".to_string(),
125        )
126    })?;
127
128    let system_vars = adapter_client.get_system_vars().await;
129    let mut config: BTreeMap<&str, String> = BTreeMap::new();
130    for var_name in CONSOLE_CONFIG_VAR_NAMES.iter() {
131        let value = system_vars.get(var_name).map(|v| v.value()).map_err(|_| {
132            (
133                StatusCode::INTERNAL_SERVER_ERROR,
134                format!("failed to retrieve system variable {var_name}"),
135            )
136        })?;
137        config.insert(var_name, value);
138    }
139
140    Ok((StatusCode::OK, Json(config)).into_response())
141}
142
143/// The User Impersonation feature uses a Teleport proxy in front of the
144/// Internal HTTP Server, however Teleport has issues with CORS that prevent
145/// making requests to that Teleport-proxied app from our production console URLs.
146/// To avoid CORS and serve the Console from the same host as the Teleport app,
147/// this route proxies the upstream Console to handle requests for
148/// HTML, JS, and CSS static files.
149///
150/// `?preview_build=<label>` selects a per-browser preview build served from a
151/// subdomain of the upstream host; an empty value returns to the default. A
152/// GET only renders a confirmation page. The selection itself requires a POST,
153/// so that a cross-site link cannot change which build a browser is served.
154pub(crate) async fn handle_internal_console(
155    console_config: Extension<Arc<ConsoleProxyConfig>>,
156    mut req: Request<Body>,
157) -> Result<Response, StatusCode> {
158    if let Some(response) = preview_build_selection_response(&console_config, &req)? {
159        return Ok(response);
160    }
161
162    let preview_build = preview_build_from_cookie(req.headers())
163        .and_then(|label| console_config.preview_url(&label).map(|url| (label, url)));
164    let upstream_url = preview_build
165        .as_ref()
166        .map_or_else(|| console_config.url.clone(), |(_, url)| url.clone());
167
168    let path = req.uri().path();
169    let mut path_query = req
170        .uri()
171        .path_and_query()
172        .map(|v| v.as_str())
173        .unwrap_or(path);
174    if let Some(stripped_path_query) = path_query.strip_prefix(&console_config.route_prefix) {
175        path_query = stripped_path_query;
176    }
177
178    let uri = Uri::try_from(format!("{}{}", upstream_url, path_query)).unwrap();
179    let host = uri.host().unwrap().to_string();
180    // Preserve the request, but update the URI to point upstream.
181    *req.uri_mut() = uri;
182
183    // If vercel sees the request being served from a different host it tries to redirect to it's own.
184    req.headers_mut()
185        .insert(HOST, HeaderValue::from_str(&host).unwrap());
186
187    // Call this request against the upstream, return response directly.
188    match console_config.client.request(req).await {
189        Ok(response) => Ok(response.into_response()),
190        Err(err) => {
191            tracing::warn!("Error retrieving console url: {}", err);
192            // A broken preview selection would otherwise present as an opaque
193            // error until the cookie expires, so offer the way back.
194            match preview_build {
195                Some((label, _)) => Ok(preview_build_unavailable_response(&label)),
196                None => Err(StatusCode::BAD_REQUEST),
197            }
198        }
199    }
200}
201
202/// Handles the `?preview_build=<label>` selection parameter. A GET with a
203/// label renders a confirmation page whose form POSTs the selection back; the
204/// POST stores it in a cookie and redirects to the same path without the
205/// parameter. The POST must additionally be same-origin (see
206/// [`is_same_origin`]), so a cross-site navigation cannot change the served
207/// build even if the fronting proxy's session cookie policy were to allow
208/// cross-site POSTs. Clearing (an empty label) is allowed on GET: it only
209/// ever restores the default build and is the recovery path for a broken
210/// selection. Returns `None` when the parameter is absent.
211fn preview_build_selection_response(
212    console_config: &ConsoleProxyConfig,
213    req: &Request<Body>,
214) -> Result<Option<Response>, StatusCode> {
215    let query = req.uri().query().unwrap_or("");
216    let mut selection = None;
217    let mut remaining_query = url::form_urlencoded::Serializer::new(String::new());
218    let mut any_remaining = false;
219    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
220        if key == PREVIEW_BUILD_PARAM {
221            selection = Some(value.into_owned());
222        } else {
223            remaining_query.append_pair(&key, &value);
224            any_remaining = true;
225        }
226    }
227    let is_post = *req.method() == Method::POST;
228    if is_post && !is_same_origin(req) {
229        return Err(StatusCode::FORBIDDEN);
230    }
231    let Some(label) = selection else {
232        // The proxied upstream serves only static assets, so only selection
233        // POSTs are accepted.
234        if is_post {
235            return Err(StatusCode::METHOD_NOT_ALLOWED);
236        }
237        return Ok(None);
238    };
239
240    let cookie_attributes = format!(
241        "Path={}; Secure; HttpOnly; SameSite=Lax",
242        console_config.route_prefix
243    );
244    let cookie = if label.is_empty() {
245        format!("{PREVIEW_BUILD_COOKIE}=; Max-Age=0; {cookie_attributes}")
246    } else {
247        let Some(preview_url) = console_config.preview_url(&label) else {
248            return Err(StatusCode::BAD_REQUEST);
249        };
250        if !is_post {
251            return Ok(Some(preview_build_confirmation_response(
252                console_config,
253                &label,
254                &preview_url,
255            )));
256        }
257        format!(
258            "{PREVIEW_BUILD_COOKIE}={label}; \
259             Max-Age={PREVIEW_BUILD_COOKIE_MAX_AGE_SECS}; {cookie_attributes}"
260        )
261    };
262
263    let mut location = req.uri().path().to_string();
264    if any_remaining {
265        location.push('?');
266        location.push_str(&remaining_query.finish());
267    }
268    let response = Response::builder()
269        .status(StatusCode::SEE_OTHER)
270        .header(LOCATION, location)
271        .header(SET_COOKIE, cookie)
272        .body(Body::empty())
273        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
274    Ok(Some(response))
275}
276
277/// True unless the request was initiated by another site. Browser-set
278/// `Sec-Fetch-Site` cannot be forged by page scripts; `Origin` is the
279/// fallback for browsers predating it. The fronting proxy's `SameSite=Lax`
280/// session cookie also keeps a cross-site POST unauthenticated today, but
281/// that is its configuration, not this code's, so the check here is the
282/// layer this proxy owns.
283fn is_same_origin(req: &Request<Body>) -> bool {
284    if let Some(site) = req
285        .headers()
286        .get("sec-fetch-site")
287        .and_then(|v| v.to_str().ok())
288    {
289        return site == "same-origin" || site == "none";
290    }
291    let (Some(origin), Some(host)) = (req.headers().get(ORIGIN), req.headers().get(HOST)) else {
292        // No `Origin` on a POST means it was not a cross-site form submission.
293        return true;
294    };
295    let (Ok(origin), Ok(host)) = (origin.to_str(), host.to_str()) else {
296        return false;
297    };
298    origin
299        .split_once("://")
300        .is_some_and(|(_, authority)| authority == host)
301}
302
303/// Confirmation page for a preview build selection. All interpolated values
304/// are validated or config-controlled, never raw request input.
305fn preview_build_confirmation_response(
306    console_config: &ConsoleProxyConfig,
307    label: &str,
308    preview_url: &str,
309) -> Response {
310    let route_prefix = &console_config.route_prefix;
311    let body = format!(
312        "<!DOCTYPE html>\n\
313         <html><head><title>Console preview build</title></head><body>\n\
314         <p>Serve console assets in this browser from <code>{preview_url}</code> \
315         for the next 24 hours?</p>\n\
316         <form method=\"post\"><button type=\"submit\">Use preview build {label}</button></form>\n\
317         <p><a href=\"{route_prefix}/\">Cancel</a></p>\n\
318         </body></html>\n"
319    );
320    (
321        StatusCode::OK,
322        [(CONTENT_TYPE, "text/html; charset=utf-8")],
323        body,
324    )
325        .into_response()
326}
327
328/// Error page served when the selected preview build cannot be fetched,
329/// linking back to the default build.
330fn preview_build_unavailable_response(label: &str) -> Response {
331    let body = format!(
332        "<!DOCTYPE html>\n\
333         <html><head><title>Console preview build unavailable</title></head><body>\n\
334         <p>Failed to load console preview build <code>{label}</code>.</p>\n\
335         <p><a href=\"?{PREVIEW_BUILD_PARAM}=\">Return to the default console build</a></p>\n\
336         </body></html>\n"
337    );
338    (
339        StatusCode::BAD_GATEWAY,
340        [(CONTENT_TYPE, "text/html; charset=utf-8")],
341        body,
342    )
343        .into_response()
344}
345
346/// Returns the preview build label from the request's cookies, if one is set
347/// and valid. Invalid values are ignored rather than rejected so a stale
348/// cookie can never break the default console.
349fn preview_build_from_cookie(headers: &HeaderMap) -> Option<String> {
350    for header in headers.get_all(COOKIE) {
351        let Ok(header) = header.to_str() else {
352            continue;
353        };
354        for pair in header.split(';') {
355            let mut parts = pair.trim().splitn(2, '=');
356            if parts.next() == Some(PREVIEW_BUILD_COOKIE) {
357                let label = parts.next().unwrap_or("");
358                if is_valid_preview_build_label(label) {
359                    return Some(label.to_string());
360                }
361            }
362        }
363    }
364    None
365}