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, with `sources` added as
39    /// [`with_sources`] adds them.
40    pub(crate) fn resolve(
41        self,
42        sources: &[(String, String)],
43    ) -> Result<Option<String>, crate::DeclarationErrorKind> {
44        let policy = match self {
45            Self::Default => default_csp(std::env::var("TOPCOAT_DEV_URL").ok().as_deref()),
46            Self::Custom(policy) => policy,
47            Self::Off => {
48                return match sources.first() {
49                    Some((directive, source)) => Err(refused_source(directive, source)),
50                    None => Ok(None),
51                };
52            }
53        };
54        with_sources(&policy, sources).map(Some)
55    }
56}
57
58/// `policy` with each `(directive, source)` of `sources` added: to the directive when the policy
59/// has it, else to a new fetch directive (`*-src`) that starts from `default-src`'s sources, so
60/// it allows what the policy allowed before. A directive that is not a lowercase name, a source
61/// that is empty or holds whitespace, `;` or `,`, and any other directive the policy leaves out,
62/// which no source would narrow to `source` alone, are declaration errors.
63pub(crate) fn with_sources(
64    policy: &str,
65    sources: &[(String, String)],
66) -> Result<String, crate::DeclarationErrorKind> {
67    if sources.is_empty() {
68        return Ok(policy.to_owned());
69    }
70    let mut directives: Vec<Vec<&str>> = policy
71        .split(';')
72        .map(|directive| directive.split_ascii_whitespace().collect::<Vec<_>>())
73        .filter(|tokens| !tokens.is_empty())
74        .collect();
75    let named = |directives: &[Vec<&str>], name: &str| {
76        directives
77            .iter()
78            .position(|tokens| tokens[0].eq_ignore_ascii_case(name))
79    };
80    for (directive, source) in sources {
81        let valid_name = !directive.is_empty()
82            && directive
83                .bytes()
84                .all(|byte| byte.is_ascii_lowercase() || byte == b'-');
85        let valid_source = !source.is_empty()
86            && !source
87                .chars()
88                .any(|c| c.is_whitespace() || c == ';' || c == ',');
89        if !valid_name || !valid_source {
90            return Err(refused_source(directive, source));
91        }
92        let index = match named(&directives, directive) {
93            Some(index) => index,
94            None if directive.ends_with("-src") => {
95                let Some(default) = named(&directives, "default-src") else {
96                    return Err(refused_source(directive, source));
97                };
98                let mut tokens = vec![directive.as_str()];
99                tokens.extend(
100                    directives[default][1..]
101                        .iter()
102                        .filter(|token| **token != "'none'"),
103                );
104                directives.push(tokens);
105                directives.len() - 1
106            }
107            None => return Err(refused_source(directive, source)),
108        };
109        let tokens = &mut directives[index];
110        tokens.retain(|token| *token != "'none'");
111        if !tokens[1..].contains(&source.as_str()) {
112            tokens.push(source);
113        }
114    }
115    Ok(directives
116        .iter()
117        .map(|tokens| tokens.join(" "))
118        .collect::<Vec<_>>()
119        .join("; "))
120}
121
122/// The declaration error for a source [`with_sources`] cannot add.
123fn refused_source(directive: &str, source: &str) -> crate::DeclarationErrorKind {
124    crate::DeclarationErrorKind::ContentSecurityPolicy(crate::PolicyFault::InvalidSource {
125        directive: directive.to_owned(),
126        source: source.to_owned(),
127    })
128}
129
130/// The default policy, with the `topcoat dev` server's origin `dev` allowed to load its live
131/// reload script and connect its socket.
132pub(crate) fn default_csp(dev: Option<&str>) -> String {
133    let Some(dev) = dev.and_then(origin) else {
134        return DEFAULT_CSP.to_owned();
135    };
136    // Chrome matches no `ws:` URL against an `http:` source, so the socket is named too.
137    let socket = dev.replacen("http", "ws", 1);
138    DEFAULT_CSP.replacen(
139        "'unsafe-eval';",
140        &format!("'unsafe-eval' {dev}; connect-src 'self' {dev} {socket};"),
141        1,
142    )
143}
144
145/// The `scheme://host[:port]` of `url`, else `None`.
146fn origin(url: &str) -> Option<&str> {
147    let (scheme, rest) = url.split_once("://")?;
148    if !matches!(scheme, "http" | "https") {
149        return None;
150    }
151    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
152    let host = &rest[..end];
153    let valid = !host.is_empty()
154        && host
155            .bytes()
156            .all(|byte| byte.is_ascii_alphanumeric() || b".-:[]".contains(&byte));
157    valid.then(|| &url[..scheme.len() + 3 + end])
158}
159
160/// The headers every response under a panel's prefix carries, unless its handler set them:
161///
162/// - the panel's `Content-Security-Policy`, when it has one;
163/// - `X-Content-Type-Options: nosniff`, so a plain-text error that echoes input never renders as
164///   HTML;
165/// - `Referrer-Policy: same-origin`, so a record's URL never leaves for another site;
166/// - `Cache-Control: no-store`, so a page a signed-out user saw stays out of the browser's cache
167///   and its back button;
168/// - `Strict-Transport-Security`, when the panel opts in.
169#[derive(Debug, Clone)]
170pub(crate) struct PanelHeaders {
171    prefix: String,
172    headers: Vec<(header::HeaderName, header::HeaderValue)>,
173}
174
175impl PanelHeaders {
176    /// The headers for the panel at `prefix`; a `csp` the header codec rejects is a declaration
177    /// error.
178    pub(crate) fn new(
179        prefix: impl Into<String>,
180        csp: Option<&str>,
181        hsts: Option<std::time::Duration>,
182    ) -> Result<Self, crate::DeclarationErrorKind> {
183        let mut headers = vec![
184            (
185                header::X_CONTENT_TYPE_OPTIONS,
186                header::HeaderValue::from_static("nosniff"),
187            ),
188            (
189                header::REFERRER_POLICY,
190                header::HeaderValue::from_static("same-origin"),
191            ),
192            (
193                header::CACHE_CONTROL,
194                header::HeaderValue::from_static("no-store"),
195            ),
196        ];
197        if let Some(policy) = csp {
198            let value = header::HeaderValue::from_str(policy).map_err(|_| {
199                crate::DeclarationErrorKind::ContentSecurityPolicy(
200                    crate::PolicyFault::InvalidHeader {
201                        policy: policy.to_owned(),
202                    },
203                )
204            })?;
205            headers.push((CSP, value));
206        }
207        if let Some(max_age) = hsts {
208            let value = header::HeaderValue::from_str(&format!("max-age={}", max_age.as_secs()))
209                .expect("a decimal number is a valid header value");
210            headers.push((header::STRICT_TRANSPORT_SECURITY, value));
211        }
212        Ok(Self {
213            prefix: prefix.into(),
214            headers,
215        })
216    }
217}
218
219impl Layer for PanelHeaders {
220    fn path(&self) -> Option<&Path> {
221        None
222    }
223
224    fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
225        Box::pin(async move {
226            if !under_prefix(&self.prefix, uri(cx).path()) {
227                return next.run(cx, body).await;
228            }
229            match next.run(cx, body).await {
230                Ok(mut response) => {
231                    insert_missing(&mut response, &self.headers);
232                    Ok(response)
233                }
234                Err(error) => {
235                    // The router builds the error response after the layers return.
236                    let queued = response_headers(cx);
237                    for (name, value) in &self.headers {
238                        queued.append(name.clone(), value.clone());
239                    }
240                    Err(error)
241                }
242            }
243        })
244    }
245}
246
247/// Adds each of `headers` that `response` does not carry yet.
248fn insert_missing(response: &mut Response, headers: &[(header::HeaderName, header::HeaderValue)]) {
249    let target = response.headers_mut();
250    for (name, value) in headers {
251        if !target.contains_key(name) {
252            target.insert(name.clone(), value.clone());
253        }
254    }
255}
256
257impl Panel {
258    /// The `Content-Security-Policy` a panel sends unless
259    /// [`content_security_policy`](Self::content_security_policy) replaces it:
260    ///
261    /// ```text
262    /// default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';
263    /// style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;
264    /// font-src 'self' data: https:; media-src 'self' data: blob: https:; object-src 'none';
265    /// base-uri 'self'; form-action 'self'; frame-ancestors 'self'
266    /// ```
267    ///
268    /// - Script files load from the panel's origin only, and `fetch` and sockets reach it alone.
269    ///   Inline scripts run, so the policy does not stop a script an XSS injects, nor its sending
270    ///   data away through an image or a navigation; it stops framing, `<base>` hijacking, forms
271    ///   posting off-site and plugins. Inline scripts run: Topcoat streams a page's live regions,
272    ///   such as a list's rows, with inline scripts that carry no nonce, so a policy without
273    ///   `'unsafe-inline'` leaves the list loading on a page load. `'unsafe-eval'` lets Topcoat's
274    ///   runtime compile its client expressions: without it the runtime throws at the page's first
275    ///   binding and does not start. The script that marks a page's panel is inline too.
276    /// - Images, fonts and media may come from any `https:` host, so an uploader or a font on a CDN
277    ///   keeps working.
278    /// - Plugins are off, `<base>` and form targets stay on the origin, and only the panel may
279    ///   frame itself.
280    ///
281    /// While `topcoat dev` runs the app, the policy also lets the dev server's origin load its
282    /// reload script and connect.
283    pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = DEFAULT_CSP;
284
285    /// Replaces the panel's `Content-Security-Policy`,
286    /// [`DEFAULT_CONTENT_SECURITY_POLICY`](Self::DEFAULT_CONTENT_SECURITY_POLICY) by default.
287    ///
288    /// Every response under the prefix carries the policy, and with it `X-Content-Type-Options:
289    /// nosniff`, `Referrer-Policy: same-origin` and `Cache-Control: no-store`, so a page a
290    /// signed-out user saw does not come back from the browser's cache. A header on the `Response`
291    /// a handler returns is kept; one it queues through Topcoat's `response_headers` is added
292    /// after the panel's, so both reach the browser, which enforces both policies.
293    ///
294    /// - The headers cover the prefix's 404 and 405 pages too; the app's own routes outside the
295    ///   prefix carry whatever the app sets.
296    /// - The router answers three errors before any layer runs, so they carry none of them: the 403
297    ///   for a cross-site request, the 400 for a malformed `x-topcoat-identity` header, and the 500
298    ///   for a panic.
299    ///
300    /// A policy with a line break refuses to mount.
301    ///
302    /// ```no_run
303    /// # use tablo_core::Panel;
304    /// Panel::new("admin").content_security_policy(Panel::DEFAULT_CONTENT_SECURITY_POLICY.replace(
305    ///     "frame-ancestors 'self'",
306    ///     "frame-ancestors 'self' https://intranet.example",
307    /// ));
308    /// ```
309    pub fn content_security_policy(mut self, policy: impl Into<String>) -> Self {
310        self.content_security_policy = Policy::Custom(policy.into());
311        self
312    }
313
314    /// Adds `source` to `directive` of the panel's policy, the default or the one
315    /// [`content_security_policy`](Self::content_security_policy) sets, keeping what `topcoat
316    /// dev` adds: a form that posts to a payment provider adds its origin to `form-action`.
317    ///
318    /// ```no_run
319    /// # use tablo_core::Panel;
320    /// Panel::new("app").content_security_policy_source("form-action", "https://checkout.stripe.com");
321    /// ```
322    ///
323    /// A fetch directive (`*-src`) the policy leaves out starts from `default-src`'s sources, so
324    /// it allows what it allowed before, plus `source`. Mounting refuses a directive that is not a
325    /// lowercase name, a source that is empty or holds whitespace, `;` or `,`, any other directive
326    /// the policy leaves out, and any source on a panel
327    /// [without a policy](Self::without_content_security_policy).
328    pub fn content_security_policy_source(
329        mut self,
330        directive: impl Into<String>,
331        source: impl Into<String>,
332    ) -> Self {
333        self.content_security_policy_sources
334            .push((directive.into(), source.into()));
335        self
336    }
337
338    /// Sends no `Content-Security-Policy`, for deployments whose proxy owns it. The panel's other
339    /// headers stay.
340    pub fn without_content_security_policy(mut self) -> Self {
341        self.content_security_policy = Policy::Off;
342        self
343    }
344
345    /// Sends `Strict-Transport-Security: max-age=<seconds>` on every response under the prefix,
346    /// so a browser that reached the panel over HTTPS never tries plain HTTP for the host again
347    /// until `max_age` passes. Off by default, since TLS usually ends at a proxy, which may own
348    /// the header; turn it on only for a host that serves HTTPS alone.
349    ///
350    /// ```no_run
351    /// # use tablo_core::Panel;
352    /// Panel::new("admin").strict_transport_security(std::time::Duration::from_secs(31_536_000));
353    /// ```
354    pub fn strict_transport_security(mut self, max_age: std::time::Duration) -> Self {
355        self.strict_transport_security = Some(max_age);
356        self
357    }
358}
359
360/// The policy every served file carries.
361const SERVED_FILE_CSP: &str = "default-src 'none'; img-src 'self'; media-src 'self'; \
362     style-src 'unsafe-inline'; sandbox; frame-ancestors 'self'";
363
364/// The content types a served file may render inline.
365const INLINE_TYPES: &[&str] = &[
366    "image/png",
367    "image/jpeg",
368    "image/gif",
369    "image/webp",
370    "image/avif",
371    "video/mp4",
372    "video/webm",
373    "audio/mpeg",
374    "audio/ogg",
375    "audio/wav",
376    "text/plain",
377];
378
379/// A directory a panel serves: at `path`, a route pattern ending in a catch-all, from `dir`.
380#[derive(Debug, Clone)]
381pub(crate) struct ServedDir {
382    pub(crate) path: String,
383    pub(crate) dir: std::path::PathBuf,
384    /// Whether a request needs a URL [`signed`](crate::upload::signed) by a page of the panel.
385    pub(crate) signed: bool,
386}
387
388/// Hardens every file response the directory route serves, and refuses a request to a signed
389/// directory without a valid signature.
390#[derive(Debug, Clone)]
391pub(crate) struct ServedFileHeaders {
392    path: PathBuf,
393    signed: bool,
394}
395
396impl ServedFileHeaders {
397    /// Wraps the directory route mounted at `pattern`.
398    pub(crate) fn new(pattern: &str, signed: bool) -> Self {
399        Self {
400            path: route_path(pattern),
401            signed,
402        }
403    }
404}
405
406impl Layer for ServedFileHeaders {
407    fn path(&self) -> Option<&Path> {
408        // Wraps exactly the served directory.
409        Some(&self.path)
410    }
411
412    fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
413        Box::pin(async move {
414            if self.signed {
415                let uri = uri(cx);
416                let now = crate::upload::signed::now();
417                if !crate::upload::signed::verify(cx, uri.path(), uri.query(), now) {
418                    return Err(topcoat::router::error::not_found().into());
419                }
420            }
421            let mut response = next.run(cx, body).await?;
422            harden_served_file(&mut response);
423            if self.signed {
424                // A shared cache keeps no copy a signature opened.
425                response.headers_mut().insert(
426                    header::CACHE_CONTROL,
427                    header::HeaderValue::from_static("private"),
428                );
429            }
430            Ok(response)
431        })
432    }
433}
434
435/// Makes one file response the directory route served inert.
436fn harden_served_file(response: &mut Response) {
437    let not_modified = response.status() == StatusCode::NOT_MODIFIED;
438    let headers = response.headers_mut();
439    headers.insert(
440        header::X_CONTENT_TYPE_OPTIONS,
441        header::HeaderValue::from_static("nosniff"),
442    );
443    headers.insert(CSP, header::HeaderValue::from_static(SERVED_FILE_CSP));
444    if not_modified || is_inline(headers.get(header::CONTENT_TYPE)) {
445        return;
446    }
447    let already_an_attachment = headers
448        .get(header::CONTENT_DISPOSITION)
449        .and_then(|value| value.to_str().ok())
450        .is_some_and(|value| value.starts_with("attachment"));
451    if !already_an_attachment {
452        headers.insert(
453            header::CONTENT_DISPOSITION,
454            header::HeaderValue::from_static("attachment"),
455        );
456    }
457}
458
459/// Reports whether `content_type` may render inline.
460fn is_inline(content_type: Option<&header::HeaderValue>) -> bool {
461    let Some(mime) = content_type.and_then(|value| value.to_str().ok()) else {
462        return false;
463    };
464    let mime = mime.split(';').next().unwrap_or_default().trim();
465    INLINE_TYPES.contains(&mime.to_ascii_lowercase().as_str())
466}
467
468#[cfg(test)]
469mod tests;