Skip to main content

tablo_core/panel/
headers.rs

1//! Response hardening headers for panel responses and served files.
2
3use http::{StatusCode, header};
4use topcoat::{
5    context::Cx,
6    router::{
7        Body, Layer, LayerFuture, Next, Path, PathBuf,
8        request::uri,
9        response::{Response, response_headers},
10    },
11};
12
13use super::{Panel, state::under_prefix};
14use crate::naming::route_path;
15
16const CSP: header::HeaderName = header::CONTENT_SECURITY_POLICY;
17
18/// The panel's default `Content-Security-Policy`, which
19/// [`Panel::DEFAULT_CONTENT_SECURITY_POLICY`] documents.
20pub(crate) const DEFAULT_CSP: &str = "default-src 'self'; \
21     script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; \
22     img-src 'self' data: blob: https:; font-src 'self' data: https:; \
23     media-src 'self' data: blob: https:; object-src 'none'; base-uri 'self'; \
24     form-action 'self'; frame-ancestors 'self'";
25
26/// The `Content-Security-Policy` a panel sends.
27#[derive(Debug, Clone)]
28pub(crate) enum Policy {
29    /// [`DEFAULT_CSP`], with the `topcoat dev` server allowed while it runs the app.
30    Default,
31    /// The app's own policy.
32    Custom(String),
33    /// No policy: the deployment's proxy owns it.
34    Off,
35}
36
37impl Policy {
38    /// The header value the panel sends, if any.
39    pub(crate) fn resolve(self) -> Option<String> {
40        match self {
41            Self::Default => Some(default_csp(
42                std::env::var("TOPCOAT_DEV_URL").ok().as_deref(),
43            )),
44            Self::Custom(policy) => Some(policy),
45            Self::Off => None,
46        }
47    }
48}
49
50/// The default policy, with the `topcoat dev` server's origin `dev` allowed to load its live
51/// reload script and connect its socket.
52pub(crate) fn default_csp(dev: Option<&str>) -> String {
53    let Some(dev) = dev.and_then(origin) else {
54        return DEFAULT_CSP.to_owned();
55    };
56    // Chrome matches no `ws:` URL against an `http:` source, so the socket is named too.
57    let socket = dev.replacen("http", "ws", 1);
58    DEFAULT_CSP.replacen(
59        "'unsafe-eval';",
60        &format!("'unsafe-eval' {dev}; connect-src 'self' {dev} {socket};"),
61        1,
62    )
63}
64
65/// The `scheme://host[:port]` of `url`, else `None`.
66fn origin(url: &str) -> Option<&str> {
67    let (scheme, rest) = url.split_once("://")?;
68    if !matches!(scheme, "http" | "https") {
69        return None;
70    }
71    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
72    let host = &rest[..end];
73    let valid = !host.is_empty()
74        && host
75            .bytes()
76            .all(|byte| byte.is_ascii_alphanumeric() || b".-:[]".contains(&byte));
77    valid.then(|| &url[..scheme.len() + 3 + end])
78}
79
80/// The headers every response under a panel's prefix carries, unless its handler set them:
81///
82/// - the panel's `Content-Security-Policy`, when it has one;
83/// - `X-Content-Type-Options: nosniff`, so a plain-text error that echoes input never renders as
84///   HTML;
85/// - `Referrer-Policy: same-origin`, so a record's URL never leaves for another site;
86/// - `Cache-Control: no-store`, so a page a signed-out user saw stays out of the browser's cache
87///   and its back button;
88/// - `Strict-Transport-Security`, when the panel opts in.
89#[derive(Debug, Clone)]
90pub(crate) struct PanelHeaders {
91    prefix: String,
92    headers: Vec<(header::HeaderName, header::HeaderValue)>,
93}
94
95impl PanelHeaders {
96    /// The headers for the panel at `prefix`; a `csp` the header codec rejects is a declaration
97    /// error.
98    pub(crate) fn new(
99        prefix: impl Into<String>,
100        csp: Option<&str>,
101        hsts: Option<std::time::Duration>,
102    ) -> Result<Self, crate::DeclarationErrorKind> {
103        let mut headers = vec![
104            (
105                header::X_CONTENT_TYPE_OPTIONS,
106                header::HeaderValue::from_static("nosniff"),
107            ),
108            (
109                header::REFERRER_POLICY,
110                header::HeaderValue::from_static("same-origin"),
111            ),
112            (
113                header::CACHE_CONTROL,
114                header::HeaderValue::from_static("no-store"),
115            ),
116        ];
117        if let Some(policy) = csp {
118            let value = header::HeaderValue::from_str(policy).map_err(|_| {
119                crate::DeclarationErrorKind::InvalidContentSecurityPolicy {
120                    policy: policy.to_owned(),
121                }
122            })?;
123            headers.push((CSP, value));
124        }
125        if let Some(max_age) = hsts {
126            let value = header::HeaderValue::from_str(&format!("max-age={}", max_age.as_secs()))
127                .expect("a decimal number is a valid header value");
128            headers.push((header::STRICT_TRANSPORT_SECURITY, value));
129        }
130        Ok(Self {
131            prefix: prefix.into(),
132            headers,
133        })
134    }
135}
136
137impl Layer for PanelHeaders {
138    fn path(&self) -> Option<&Path> {
139        None
140    }
141
142    fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
143        Box::pin(async move {
144            if !under_prefix(&self.prefix, uri(cx).path()) {
145                return next.run(cx, body).await;
146            }
147            match next.run(cx, body).await {
148                Ok(mut response) => {
149                    insert_missing(&mut response, &self.headers);
150                    Ok(response)
151                }
152                Err(error) => {
153                    // The router builds the error response after the layers return.
154                    let queued = response_headers(cx);
155                    for (name, value) in &self.headers {
156                        queued.append(name.clone(), value.clone());
157                    }
158                    Err(error)
159                }
160            }
161        })
162    }
163}
164
165/// Adds each of `headers` that `response` does not carry yet.
166fn insert_missing(response: &mut Response, headers: &[(header::HeaderName, header::HeaderValue)]) {
167    let target = response.headers_mut();
168    for (name, value) in headers {
169        if !target.contains_key(name) {
170            target.insert(name.clone(), value.clone());
171        }
172    }
173}
174
175impl Panel {
176    /// The `Content-Security-Policy` a panel sends unless
177    /// [`content_security_policy`](Self::content_security_policy) replaces it:
178    ///
179    /// ```text
180    /// default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';
181    /// style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;
182    /// font-src 'self' data: https:; media-src 'self' data: blob: https:; object-src 'none';
183    /// base-uri 'self'; form-action 'self'; frame-ancestors 'self'
184    /// ```
185    ///
186    /// - Script files load from the panel's origin only, and `fetch` and sockets reach it alone.
187    ///   Inline scripts run, so the policy does not stop a script an XSS injects, nor its sending
188    ///   data away through an image or a navigation; it stops framing, `<base>` hijacking, forms
189    ///   posting off-site and plugins. Inline scripts run: Topcoat streams a page's live regions,
190    ///   such as a list's rows, with inline scripts that carry no nonce, so a policy without
191    ///   `'unsafe-inline'` leaves the list loading. `'unsafe-eval'` lets Topcoat's runtime compile
192    ///   its client expressions.
193    /// - Images, fonts and media may come from any `https:` host, so an uploader or a font on a CDN
194    ///   keeps working.
195    /// - Plugins are off, `<base>` and form targets stay on the origin, and only the panel may
196    ///   frame itself.
197    ///
198    /// While `topcoat dev` runs the app, the policy also lets the dev server's origin load its
199    /// reload script and connect.
200    pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = DEFAULT_CSP;
201
202    /// Replaces the panel's `Content-Security-Policy`,
203    /// [`DEFAULT_CONTENT_SECURITY_POLICY`](Self::DEFAULT_CONTENT_SECURITY_POLICY) by default.
204    ///
205    /// Every response under the prefix carries the policy, and with it `X-Content-Type-Options:
206    /// nosniff`, `Referrer-Policy: same-origin` and `Cache-Control: no-store`, so a page a
207    /// signed-out user saw does not come back from the browser's cache. A header on the `Response`
208    /// a handler returns is kept; one it queues through Topcoat's `response_headers` is added
209    /// after the panel's, so both reach the browser, which enforces both policies.
210    ///
211    /// - The headers cover the prefix's 404 and 405 pages too; the app's own routes outside the
212    ///   prefix carry whatever the app sets.
213    /// - The router answers three errors before any layer runs, so they carry none of them: the 403
214    ///   for a cross-site request, the 400 for a malformed `x-topcoat-identity` header, and the 500
215    ///   for a panic.
216    ///
217    /// A policy with a line break refuses to mount.
218    ///
219    /// ```no_run
220    /// # use tablo_core::Panel;
221    /// Panel::new("admin").content_security_policy(Panel::DEFAULT_CONTENT_SECURITY_POLICY.replace(
222    ///     "frame-ancestors 'self'",
223    ///     "frame-ancestors 'self' https://intranet.example",
224    /// ));
225    /// ```
226    pub fn content_security_policy(mut self, policy: impl Into<String>) -> Self {
227        self.content_security_policy = Policy::Custom(policy.into());
228        self
229    }
230
231    /// Sends no `Content-Security-Policy`, for deployments whose proxy owns it. The panel's other
232    /// headers stay.
233    pub fn without_content_security_policy(mut self) -> Self {
234        self.content_security_policy = Policy::Off;
235        self
236    }
237
238    /// Sends `Strict-Transport-Security: max-age=<seconds>` on every response under the prefix,
239    /// so a browser that reached the panel over HTTPS never tries plain HTTP for the host again
240    /// until `max_age` passes. Off by default, since TLS usually ends at a proxy, which may own
241    /// the header; turn it on only for a host that serves HTTPS alone.
242    ///
243    /// ```no_run
244    /// # use tablo_core::Panel;
245    /// Panel::new("admin").strict_transport_security(std::time::Duration::from_secs(31_536_000));
246    /// ```
247    pub fn strict_transport_security(mut self, max_age: std::time::Duration) -> Self {
248        self.strict_transport_security = Some(max_age);
249        self
250    }
251}
252
253/// The policy every served file carries.
254const SERVED_FILE_CSP: &str = "default-src 'none'; img-src 'self'; media-src 'self'; \
255     style-src 'unsafe-inline'; sandbox; frame-ancestors 'self'";
256
257/// The content types a served file may render inline.
258const INLINE_TYPES: &[&str] = &[
259    "image/png",
260    "image/jpeg",
261    "image/gif",
262    "image/webp",
263    "image/avif",
264    "video/mp4",
265    "video/webm",
266    "audio/mpeg",
267    "audio/ogg",
268    "audio/wav",
269    "text/plain",
270];
271
272/// A directory a panel serves: at `path`, a route pattern ending in a catch-all, from `dir`.
273#[derive(Debug, Clone)]
274pub(crate) struct ServedDir {
275    pub(crate) path: String,
276    pub(crate) dir: std::path::PathBuf,
277    /// Whether a request needs a URL [`signed`](crate::upload::signed) by a page of the panel.
278    pub(crate) signed: bool,
279}
280
281/// Hardens every file response the directory route serves, and refuses a request to a signed
282/// directory without a valid signature.
283#[derive(Debug, Clone)]
284pub(crate) struct ServedFileHeaders {
285    path: PathBuf,
286    signed: bool,
287}
288
289impl ServedFileHeaders {
290    /// Wraps the directory route mounted at `pattern`.
291    pub(crate) fn new(pattern: &str, signed: bool) -> Self {
292        Self {
293            path: route_path(pattern),
294            signed,
295        }
296    }
297}
298
299impl Layer for ServedFileHeaders {
300    fn path(&self) -> Option<&Path> {
301        // Wraps exactly the served directory.
302        Some(&self.path)
303    }
304
305    fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
306        Box::pin(async move {
307            if self.signed {
308                let uri = uri(cx);
309                let now = crate::upload::signed::now();
310                if !crate::upload::signed::verify(cx, uri.path(), uri.query(), now) {
311                    return Err(topcoat::router::error::not_found().into());
312                }
313            }
314            let mut response = next.run(cx, body).await?;
315            harden_served_file(&mut response);
316            if self.signed {
317                // A shared cache keeps no copy a signature opened.
318                response.headers_mut().insert(
319                    header::CACHE_CONTROL,
320                    header::HeaderValue::from_static("private"),
321                );
322            }
323            Ok(response)
324        })
325    }
326}
327
328/// Makes one file response the directory route served inert.
329fn harden_served_file(response: &mut Response) {
330    let not_modified = response.status() == StatusCode::NOT_MODIFIED;
331    let headers = response.headers_mut();
332    headers.insert(
333        header::X_CONTENT_TYPE_OPTIONS,
334        header::HeaderValue::from_static("nosniff"),
335    );
336    headers.insert(CSP, header::HeaderValue::from_static(SERVED_FILE_CSP));
337    if not_modified || is_inline(headers.get(header::CONTENT_TYPE)) {
338        return;
339    }
340    let already_an_attachment = headers
341        .get(header::CONTENT_DISPOSITION)
342        .and_then(|value| value.to_str().ok())
343        .is_some_and(|value| value.starts_with("attachment"));
344    if !already_an_attachment {
345        headers.insert(
346            header::CONTENT_DISPOSITION,
347            header::HeaderValue::from_static("attachment"),
348        );
349    }
350}
351
352/// Reports whether `content_type` may render inline.
353fn is_inline(content_type: Option<&header::HeaderValue>) -> bool {
354    let Some(mime) = content_type.and_then(|value| value.to_str().ok()) else {
355        return false;
356    };
357    let mime = mime.split(';').next().unwrap_or_default().trim();
358    INLINE_TYPES.contains(&mime.to_ascii_lowercase().as_str())
359}
360
361#[cfg(test)]
362mod tests;