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;