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;