From 7e9397879639e752f2577581c176d665e179ed9a Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Fri, 26 Nov 2021 23:44:38 +1100 Subject: [PATCH 01/17] Replace `PreEscaped` and `Markup` with `Html` type Closes #270 --- maud/benches/complicated_maud.rs | 10 +- maud/src/lib.rs | 277 +++++++++++++++++++++---------- maud/tests/basic_syntax.rs | 6 +- maud/tests/misc.rs | 14 +- maud/tests/splices.rs | 5 +- maud_macros/src/generate.rs | 4 +- maud_macros/src/lib.rs | 5 +- 7 files changed, 211 insertions(+), 110 deletions(-) diff --git a/maud/benches/complicated_maud.rs b/maud/benches/complicated_maud.rs index d0cf0958..1732bdf9 100644 --- a/maud/benches/complicated_maud.rs +++ b/maud/benches/complicated_maud.rs @@ -2,7 +2,7 @@ extern crate test; -use maud::{html, Markup}; +use maud::{html, Html}; #[derive(Debug)] struct Entry { @@ -11,7 +11,7 @@ struct Entry { } mod btn { - use maud::{html, Markup, Render}; + use maud::{html, Html, ToHtml}; #[derive(Copy, Clone)] pub enum RequestMethod { @@ -41,8 +41,8 @@ mod btn { } } - impl<'a> Render for Button<'a> { - fn render(&self) -> Markup { + impl<'a> ToHtml for Button<'a> { + fn to_html(&self) -> Html { match self.req_meth { RequestMethod::Get => { html! { a.btn href=(self.path) { (self.label) } } @@ -59,7 +59,7 @@ mod btn { } } -fn layout>(title: S, inner: Markup) -> Markup { +fn layout>(title: S, inner: Html) -> Html { html! { html { head { diff --git a/maud/src/lib.rs b/maud/src/lib.rs index c2c0beaa..9511c060 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -59,103 +59,98 @@ impl<'a> fmt::Write for Escaper<'a> { /// Represents a type that can be rendered as HTML. /// -/// To implement this for your own type, override either the `.render()` -/// or `.render_to()` methods; since each is defined in terms of the +/// To implement this for your own type, override either the `.html()` +/// or `.to_html()` methods; since each is defined in terms of the /// other, you only need to implement one of them. See the example below. /// /// # Minimal implementation /// /// An implementation of this trait must override at least one of -/// `.render()` or `.render_to()`. Since the default definitions of -/// these methods call each other, not doing this will result in -/// infinite recursion. +/// `.html()` or `.to_html()`. Since the default definitions of these +/// methods call each other, not doing this will result in infinite +/// recursion. /// /// # Example /// /// ```rust -/// use maud::{html, Markup, Render}; +/// use maud::{html, Html, ToHtml}; /// /// /// Provides a shorthand for linking to a CSS stylesheet. /// pub struct Stylesheet(&'static str); /// -/// impl Render for Stylesheet { -/// fn render(&self) -> Markup { +/// impl ToHtml for Stylesheet { +/// fn to_html(&self) -> Html { /// html! { /// link rel="stylesheet" type="text/css" href=(self.0); /// } /// } /// } /// ``` -pub trait Render { - /// Renders `self` as a block of `Markup`. - fn render(&self) -> Markup { - let mut buffer = String::new(); - self.render_to(&mut buffer); - PreEscaped(buffer) +pub trait ToHtml { + /// Creates an HTML representation of `self`. + fn to_html(&self) -> Html { + let mut buffer = Html::default(); + self.html(&mut buffer); + buffer } - /// Appends a representation of `self` to the given buffer. + /// Appends an HTML representation of `self` to the given buffer. /// - /// Its default implementation just calls `.render()`, but you may + /// Its default implementation just calls `.to_html()`, but you may /// override it with something more efficient. - /// - /// Note that no further escaping is performed on data written to - /// the buffer. If you override this method, you must make sure that - /// any data written is properly escaped, whether by hand or using - /// the [`Escaper`](struct.Escaper.html) wrapper struct. - fn render_to(&self, buffer: &mut String) { - buffer.push_str(&self.render().into_string()); + fn html(&self, buffer: &mut Html) { + self.to_html().html(buffer) } } -impl Render for str { - fn render_to(&self, w: &mut String) { - escape::escape_to_string(self, w); +impl ToHtml for str { + fn html(&self, buffer: &mut Html) { + buffer.push_text(self); } } -impl Render for String { - fn render_to(&self, w: &mut String) { - str::render_to(self, w); +impl ToHtml for String { + fn html(&self, buffer: &mut Html) { + buffer.push_text(self); } } -impl<'a> Render for Cow<'a, str> { - fn render_to(&self, w: &mut String) { - str::render_to(self, w); +impl<'a> ToHtml for Cow<'a, str> { + fn html(&self, buffer: &mut Html) { + buffer.push_text(self); } } -impl<'a> Render for Arguments<'a> { - fn render_to(&self, w: &mut String) { - let _ = Escaper::new(w).write_fmt(*self); +impl<'a> ToHtml for Arguments<'a> { + fn html(&self, buffer: &mut Html) { + buffer.push_fmt(*self); } } -impl<'a, T: Render + ?Sized> Render for &'a T { - fn render_to(&self, w: &mut String) { - T::render_to(self, w); +impl<'a, T: ToHtml + ?Sized> ToHtml for &'a T { + fn html(&self, buffer: &mut Html) { + T::html(self, buffer); } } -impl<'a, T: Render + ?Sized> Render for &'a mut T { - fn render_to(&self, w: &mut String) { - T::render_to(self, w); +impl<'a, T: ToHtml + ?Sized> ToHtml for &'a mut T { + fn html(&self, buffer: &mut Html) { + T::html(self, buffer); } } -impl Render for Box { - fn render_to(&self, w: &mut String) { - T::render_to(self, w); +impl ToHtml for Box { + fn html(&self, buffer: &mut Html) { + T::html(self, buffer); } } macro_rules! impl_render_with_display { ($($ty:ty)*) => { $( - impl Render for $ty { - fn render_to(&self, w: &mut String) { - format_args!("{self}").render_to(w); + impl ToHtml for $ty { + fn html(&self, buffer: &mut Html) { + buffer.push_fmt(format_args!("{self}")); } } )* @@ -169,9 +164,10 @@ impl_render_with_display! { macro_rules! impl_render_with_itoa { ($($ty:ty)*) => { $( - impl Render for $ty { - fn render_to(&self, w: &mut String) { - let _ = itoa::fmt(w, *self); + impl ToHtml for $ty { + fn html(&self, buffer: &mut Html) { + // XSS-Safety: The characters '0' through '9', and '-', are HTML safe. + let _ = itoa::fmt(buffer.as_mut_string_unchecked(), *self); } } )* @@ -183,31 +179,144 @@ impl_render_with_itoa! { u8 u16 u32 u64 u128 usize } -/// A wrapper that renders the inner value without escaping. -#[derive(Debug, Clone, Copy)] -pub struct PreEscaped>(pub T); +/// A fragment of HTML. +/// +/// This is the type that's returned by the [`html!`] macro. +#[derive(Clone, Debug, Default)] +pub struct Html { + inner: Cow<'static, str>, +} + +impl Html { + /// Creates an HTML fragment from a constant string. + /// + /// # Example + /// + /// ```rust + /// use maud::Html; + /// + /// let analytics_script = Html::from_const(""); + /// ``` + /// + /// # Security + /// + /// The given string must be a *compile-time constant*: either a + /// literal, or a reference to a `const` value. This ensures that + /// the string is as trustworthy as the code itself. + /// + /// If the string is not a compile-time constant, use + /// [`Html::from_unchecked`] instead, and document why the call is + /// safe. + /// + /// In the future, when [`const` string parameters] are available on + /// Rust stable, this rule will be enforced by the API. + /// + /// [`const` string parameters]: https://blog.rust-lang.org/inside-rust/2021/09/06/Splitting-const-generics.html#featureadt_const_params + pub const fn from_const(html_string: &'static str) -> Self { + Html { + inner: Cow::Borrowed(html_string), + } + } -impl> Render for PreEscaped { - fn render_to(&self, w: &mut String) { - w.push_str(self.0.as_ref()); + /// Takes an untrusted HTML fragment and makes it safe. + pub fn sanitize(_value: &str) -> Self { + todo!() } -} -/// A block of markup is a string that does not need to be escaped. -/// -/// The `html!` macro expands to an expression of this type. -pub type Markup = PreEscaped; + /// Creates an HTML fragment from a string, without escaping it. + /// + /// # Example + /// + /// ```rust + /// # fn load_header_from_config() -> String { String::new() } + /// use maud::Html; + /// + /// // XSS-Safety: The config can only be edited by an admin. + /// let header = Html::from_unchecked(load_header_from_config()); + /// ``` + /// + /// # Security + /// + /// It is your responsibility to ensure that the string comes from a + /// trusted source. Misuse of this function can lead to [cross-site + /// scripting attacks (XSS)][xss]. + /// + /// It is strongly recommended to include a `// XSS-Safety:` comment + /// that explains why this call is safe. + /// + /// If your organization has a security team, consider asking them + /// for review. + /// + /// [xss]: https://www.cloudflare.com/en-au/learning/security/threats/cross-site-scripting/ + pub fn from_unchecked(html_string: impl Into>) -> Self { + Self { + inner: html_string.into(), + } + } + + /// For internal use only. + #[doc(hidden)] + pub fn with_capacity(capacity: usize) -> Self { + Self { + inner: Cow::Owned(String::with_capacity(capacity)), + } + } + + /// Appends another HTML fragment to this one. + pub fn push(&mut self, html: &Html) { + self.inner.to_mut().push_str(&html.inner); + } + + /// Appends a string, escaping if necessary. + pub fn push_text(&mut self, text: &str) { + escape::escape_to_string(text, self.inner.to_mut()); + } + + /// Appends a format string, escaping if necessary. + pub fn push_fmt(&mut self, args: Arguments<'_>) { + struct Escaper<'a>(&'a mut Html); + + impl<'a> Write for Escaper<'a> { + fn write_str(&mut self, text: &str) -> fmt::Result { + self.0.push_text(text); + Ok(()) + } + } + + let _ = Escaper(self).write_fmt(args); + } + + /// Exposes the underlying buffer as a `&mut String`. + /// + /// # Security + /// + /// As with [`Html::from_unchecked`], it is your responsibility to + /// ensure that any additions are properly escaped. + /// + /// It is strongly recommended to include a `// XSS-Safety:` comment + /// that explains why this call is safe. + /// + /// If your organization has a security team, consider asking them + /// for review. + pub fn as_mut_string_unchecked(&mut self) -> &mut String { + self.inner.to_mut() + } -impl + Into> PreEscaped { - /// Converts the inner value to a string. + /// Converts the inner value to a `String`. pub fn into_string(self) -> String { - self.0.into() + self.inner.into_owned() + } +} + +impl ToHtml for Html { + fn html(&self, buffer: &mut Html) { + buffer.push(self); } } -impl + Into> From> for String { - fn from(value: PreEscaped) -> String { - value.into_string() +impl From for String { + fn from(html: Html) -> String { + html.into_string() } } @@ -220,7 +329,7 @@ impl + Into> From> for String { /// ```rust /// use maud::{DOCTYPE, html}; /// -/// let markup = html! { +/// let page = html! { /// (DOCTYPE) /// html { /// head { @@ -233,14 +342,13 @@ impl + Into> From> for String { /// } /// }; /// ``` -pub const DOCTYPE: PreEscaped<&'static str> = PreEscaped(""); +pub const DOCTYPE: Html = Html::from_const(""); #[cfg(feature = "rocket")] mod rocket_support { extern crate std; - use crate::PreEscaped; - use alloc::string::String; + use crate::Html; use rocket::{ http::{ContentType, Status}, request::Request, @@ -248,11 +356,11 @@ mod rocket_support { }; use std::io::Cursor; - impl Responder<'static> for PreEscaped { + impl Responder<'static> for Html { fn respond_to(self, _: &Request) -> Result, Status> { Response::build() .header(ContentType::HTML) - .sized_body(Cursor::new(self.0)) + .sized_body(Cursor::new(self.into_string())) .ok() } } @@ -260,32 +368,30 @@ mod rocket_support { #[cfg(feature = "actix-web")] mod actix_support { - use crate::PreEscaped; + use crate::Html; use actix_web_dep::{Error, HttpRequest, HttpResponse, Responder}; - use alloc::string::String; use futures_util::future::{ok, Ready}; - impl Responder for PreEscaped { + impl Responder for Html { type Error = Error; type Future = Ready>; fn respond_to(self, _req: &HttpRequest) -> Self::Future { ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") - .body(self.0)) + .body(self.into_string())) } } } #[cfg(feature = "tide")] mod tide_support { - use crate::PreEscaped; - use alloc::string::String; + use crate::Html; use tide::{http::mime, Response, StatusCode}; - impl From> for Response { - fn from(markup: PreEscaped) -> Response { + impl From for Response { + fn from(html: Html) -> Response { Response::builder(StatusCode::Ok) - .body(markup.into_string()) + .body(html.into_string()) .content_type(mime::HTML) .build() } @@ -294,19 +400,18 @@ mod tide_support { #[cfg(feature = "axum")] mod axum_support { - use crate::PreEscaped; - use alloc::string::String; + use crate::Html; use axum_core::{body::BoxBody, response::IntoResponse}; use http::{header, HeaderMap, HeaderValue, Response}; - impl IntoResponse for PreEscaped { + impl IntoResponse for Html { fn into_response(self) -> Response { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ); - (headers, self.0).into_response() + (headers, self.inner).into_response() } } } diff --git a/maud/tests/basic_syntax.rs b/maud/tests/basic_syntax.rs index c406938e..f963f7fe 100644 --- a/maud/tests/basic_syntax.rs +++ b/maud/tests/basic_syntax.rs @@ -1,4 +1,4 @@ -use maud::{html, Markup}; +use maud::{html, Html}; #[test] fn literals() { @@ -228,7 +228,7 @@ fn class_string() { #[test] fn toggle_classes() { - fn test(is_cupcake: bool, is_muffin: bool) -> Markup { + fn test(is_cupcake: bool, is_muffin: bool) -> Html { html!(p.cupcake[is_cupcake].muffin[is_muffin] { "Testing!" }) } assert_eq!( @@ -268,7 +268,7 @@ fn toggle_classes_string() { #[test] fn mixed_classes() { - fn test(is_muffin: bool) -> Markup { + fn test(is_muffin: bool) -> Html { html!(p.cupcake.muffin[is_muffin].lamington { "Testing!" }) } assert_eq!( diff --git a/maud/tests/misc.rs b/maud/tests/misc.rs index 741af42d..f6edf698 100644 --- a/maud/tests/misc.rs +++ b/maud/tests/misc.rs @@ -1,4 +1,4 @@ -use maud::{self, html}; +use maud::{self, html, Html, ToHtml}; #[test] fn issue_13() { @@ -54,9 +54,9 @@ fn issue_23() { #[test] fn render_impl() { struct R(&'static str); - impl maud::Render for R { - fn render_to(&self, w: &mut String) { - w.push_str(self.0); + impl ToHtml for R { + fn html(&self, buffer: &mut Html) { + buffer.push_text(self.0); } } @@ -71,11 +71,9 @@ fn render_impl() { #[test] fn issue_97() { - use maud::Render; - struct Pinkie; - impl Render for Pinkie { - fn render(&self) -> maud::Markup { + impl ToHtml for Pinkie { + fn to_html(&self) -> Html { let x = 42; html! { (x) } } diff --git a/maud/tests/splices.rs b/maud/tests/splices.rs index eb568547..5f3abc5f 100644 --- a/maud/tests/splices.rs +++ b/maud/tests/splices.rs @@ -1,4 +1,4 @@ -use maud::html; +use maud::{html, Html}; #[test] fn literals() { @@ -8,8 +8,7 @@ fn literals() { #[test] fn raw_literals() { - use maud::PreEscaped; - let result = html! { (PreEscaped("")) }; + let result = html! { (Html::from_const("")) }; assert_eq!(result.into_string(), ""); } diff --git a/maud_macros/src/generate.rs b/maud_macros/src/generate.rs index ad8a0889..fbd57a0e 100644 --- a/maud_macros/src/generate.rs +++ b/maud_macros/src/generate.rs @@ -103,7 +103,7 @@ impl Generator { fn splice(&self, expr: TokenStream, build: &mut Builder) { let output_ident = self.output_ident.clone(); - build.push_tokens(quote!(maud::Render::render_to(&#expr, &mut #output_ident);)); + build.push_tokens(quote!(maud::ToHtml::html(&#expr, &mut #output_ident);)); } fn element(&self, name: TokenStream, attrs: Vec, body: ElementBody, build: &mut Builder) { @@ -284,7 +284,7 @@ impl Builder { let push_str_expr = { let output_ident = self.output_ident.clone(); let string = TokenTree::Literal(Literal::string(&self.tail)); - quote!(#output_ident.push_str(#string);) + quote!(#output_ident.as_mut_string_unchecked().push_str(#string);) }; self.tail.clear(); self.tokens.extend(push_str_expr); diff --git a/maud_macros/src/lib.rs b/maud_macros/src/lib.rs index 7323459c..9c5311e5 100644 --- a/maud_macros/src/lib.rs +++ b/maud_macros/src/lib.rs @@ -36,10 +36,9 @@ fn expand(input: TokenStream) -> TokenStream { let markups = parse::parse(input); let stmts = generate::generate(markups, output_ident.clone()); quote!({ - extern crate alloc; extern crate maud; - let mut #output_ident = alloc::string::String::with_capacity(#size_hint); + let mut #output_ident = maud::Html::with_capacity(#size_hint); #stmts - maud::PreEscaped(#output_ident) + #output_ident }) } From 4a6cee317828d9ae2f5e0393e4dd7c525401e64c Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Fri, 26 Nov 2021 23:59:11 +1100 Subject: [PATCH 02/17] Add ammonia --- maud/Cargo.toml | 4 ++-- maud/src/lib.rs | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/maud/Cargo.toml b/maud/Cargo.toml index 45c9ef94..94cedb49 100644 --- a/maud/Cargo.toml +++ b/maud/Cargo.toml @@ -14,9 +14,8 @@ edition = "2021" [features] default = [] axum = ["axum-core", "http"] - -# Web framework integrations actix-web = ["actix-web-dep", "futures-util"] +sanitize = ["ammonia"] [dependencies] maud_macros = { version = "0.23.0", path = "../maud_macros" } @@ -27,6 +26,7 @@ actix-web-dep = { package = "actix-web", version = ">= 2, < 4", optional = true, tide = { version = "0.16.0", optional = true, default-features = false } axum-core = { version = "0.1", optional = true } http = { version = "0.2", optional = true } +ammonia = { version = "3.1.2", optional = true } [dev-dependencies] trybuild = { version = "1.0.33", features = ["diff"] } diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 9511c060..81534018 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -218,9 +218,23 @@ impl Html { } } + #[cfg(feature = "sanitize")] /// Takes an untrusted HTML fragment and makes it safe. - pub fn sanitize(_value: &str) -> Self { - todo!() + /// + /// # Example + /// + /// ```rust + /// use maud::Html; + /// + /// let untrusted_html = "

"; + /// + /// let clean_html = Html::sanitize(untrusted_html); + /// + /// assert_eq!(clean_html.into_string(), "

"); + /// ``` + pub fn sanitize(untrusted_html_string: &str) -> Self { + // XSS-Safety: Ammonia sanitizes the input. + Self::from_unchecked(ammonia::clean(untrusted_html_string)) } /// Creates an HTML fragment from a string, without escaping it. From d3c03fa52abe143a1f8742d1a6301a09320a3041 Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Sat, 27 Nov 2021 00:22:04 +1100 Subject: [PATCH 03/17] Remove `Escaper` --- maud/src/lib.rs | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 81534018..878bb03b 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -18,45 +18,6 @@ pub use maud_macros::{html, html_debug}; mod escape; -/// An adapter that escapes HTML special characters. -/// -/// The following characters are escaped: -/// -/// * `&` is escaped as `&` -/// * `<` is escaped as `<` -/// * `>` is escaped as `>` -/// * `"` is escaped as `"` -/// -/// All other characters are passed through unchanged. -/// -/// **Note:** In versions prior to 0.13, the single quote (`'`) was -/// escaped as well. -/// -/// # Example -/// -/// ```rust -/// use maud::Escaper; -/// use std::fmt::Write; -/// let mut s = String::new(); -/// write!(Escaper::new(&mut s), "").unwrap(); -/// assert_eq!(s, "<script>launchMissiles()</script>"); -/// ``` -pub struct Escaper<'a>(&'a mut String); - -impl<'a> Escaper<'a> { - /// Creates an `Escaper` from a `String`. - pub fn new(buffer: &'a mut String) -> Escaper<'a> { - Escaper(buffer) - } -} - -impl<'a> fmt::Write for Escaper<'a> { - fn write_str(&mut self, s: &str) -> fmt::Result { - escape::escape_to_string(s, self.0); - Ok(()) - } -} - /// Represents a type that can be rendered as HTML. /// /// To implement this for your own type, override either the `.html()` From f79ff71a31dde6a0c9769b9c3d4022f578ef06bd Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Sun, 28 Nov 2021 23:13:18 +1100 Subject: [PATCH 04/17] Add `Write` impl for `Html` --- maud/src/lib.rs | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 878bb03b..61a025e6 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -84,7 +84,7 @@ impl<'a> ToHtml for Cow<'a, str> { impl<'a> ToHtml for Arguments<'a> { fn html(&self, buffer: &mut Html) { - buffer.push_fmt(*self); + let _ = buffer.write_fmt(*self); } } @@ -111,7 +111,7 @@ macro_rules! impl_render_with_display { $( impl ToHtml for $ty { fn html(&self, buffer: &mut Html) { - buffer.push_fmt(format_args!("{self}")); + let _ = write!(buffer, "{self}"); } } )* @@ -247,20 +247,6 @@ impl Html { escape::escape_to_string(text, self.inner.to_mut()); } - /// Appends a format string, escaping if necessary. - pub fn push_fmt(&mut self, args: Arguments<'_>) { - struct Escaper<'a>(&'a mut Html); - - impl<'a> Write for Escaper<'a> { - fn write_str(&mut self, text: &str) -> fmt::Result { - self.0.push_text(text); - Ok(()) - } - } - - let _ = Escaper(self).write_fmt(args); - } - /// Exposes the underlying buffer as a `&mut String`. /// /// # Security @@ -283,6 +269,13 @@ impl Html { } } +impl Write for Html { + fn write_str(&mut self, text: &str) -> fmt::Result { + self.push_text(text); + Ok(()) + } +} + impl ToHtml for Html { fn html(&self, buffer: &mut Html) { buffer.push(self); From 3e08d5a5e0f4c70e8c12de463ad8457279052c3f Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Sun, 28 Nov 2021 23:27:55 +1100 Subject: [PATCH 05/17] Fix docs --- docs/src/bin/build_nav.rs | 4 ++-- docs/src/lib.rs | 2 +- docs/src/string_writer.rs | 18 ------------------ docs/src/text_writer.rs | 18 ++++++++++++++++++ docs/src/views.rs | 33 ++++++++++++++++++++------------- 5 files changed, 41 insertions(+), 34 deletions(-) delete mode 100644 docs/src/string_writer.rs create mode 100644 docs/src/text_writer.rs diff --git a/docs/src/bin/build_nav.rs b/docs/src/bin/build_nav.rs index 13a93d5f..9bd11fd3 100644 --- a/docs/src/bin/build_nav.rs +++ b/docs/src/bin/build_nav.rs @@ -1,7 +1,7 @@ use comrak::{self, nodes::AstNode, Arena}; use docs::{ page::{Page, COMRAK_OPTIONS}, - string_writer::StringWriter, + text_writer::TextWriter, }; use std::{env, error::Error, fs, io, path::Path, str}; @@ -51,7 +51,7 @@ fn load_page_title<'a>( let page = Page::load(arena, path)?; let title = page.title.map(|title| { let mut buffer = String::new(); - comrak::format_commonmark(title, &COMRAK_OPTIONS, &mut StringWriter(&mut buffer)).unwrap(); + comrak::format_commonmark(title, &COMRAK_OPTIONS, &mut TextWriter(&mut buffer)).unwrap(); buffer }); Ok(title) diff --git a/docs/src/lib.rs b/docs/src/lib.rs index ac61d5b3..02155c06 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -1,5 +1,5 @@ #![feature(once_cell)] pub mod page; -pub mod string_writer; +pub mod text_writer; pub mod views; diff --git a/docs/src/string_writer.rs b/docs/src/string_writer.rs deleted file mode 100644 index ea7291ab..00000000 --- a/docs/src/string_writer.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::{io, str}; - -pub struct StringWriter<'a>(pub &'a mut String); - -impl<'a> io::Write for StringWriter<'a> { - fn write(&mut self, buf: &[u8]) -> io::Result { - str::from_utf8(buf) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) - .map(|s| { - self.0.push_str(s); - buf.len() - }) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} diff --git a/docs/src/text_writer.rs b/docs/src/text_writer.rs new file mode 100644 index 00000000..8edeb62c --- /dev/null +++ b/docs/src/text_writer.rs @@ -0,0 +1,18 @@ +use std::{fmt, io, str}; + +pub struct TextWriter(pub T); + +impl io::Write for TextWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let s = + str::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + self.0 + .write_str(s) + .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/docs/src/views.rs b/docs/src/views.rs index 47bdc59a..f5ac0d51 100644 --- a/docs/src/views.rs +++ b/docs/src/views.rs @@ -1,17 +1,23 @@ use comrak::nodes::AstNode; -use maud::{html, Markup, PreEscaped, Render, DOCTYPE}; +use maud::{html, Html, ToHtml, DOCTYPE}; use std::str; use crate::{ page::{Page, COMRAK_OPTIONS}, - string_writer::StringWriter, + text_writer::TextWriter, }; struct Comrak<'a>(&'a AstNode<'a>); -impl<'a> Render for Comrak<'a> { - fn render_to(&self, buffer: &mut String) { - comrak::format_html(self.0, &COMRAK_OPTIONS, &mut StringWriter(buffer)).unwrap(); +impl<'a> ToHtml for Comrak<'a> { + fn html(&self, buffer: &mut Html) { + // XSS-Safety: The input Markdown comes from docs, which are trusted. + comrak::format_html( + self.0, + &COMRAK_OPTIONS, + &mut TextWriter(buffer.as_mut_string_unchecked()), + ) + .unwrap(); } } @@ -19,12 +25,13 @@ impl<'a> Render for Comrak<'a> { /// general but not suitable for links in the navigation bar. struct ComrakRemovePTags<'a>(&'a AstNode<'a>); -impl<'a> Render for ComrakRemovePTags<'a> { - fn render(&self) -> Markup { +impl<'a> ToHtml for ComrakRemovePTags<'a> { + fn to_html(&self) -> Html { let mut buffer = String::new(); - comrak::format_html(self.0, &COMRAK_OPTIONS, &mut StringWriter(&mut buffer)).unwrap(); + comrak::format_html(self.0, &COMRAK_OPTIONS, &mut TextWriter(&mut buffer)).unwrap(); assert!(buffer.starts_with("

") && buffer.ends_with("

\n")); - PreEscaped( + // XSS-Safety: The input Markdown comes from docs, which are trusted. + Html::from_unchecked( buffer .trim_start_matches("

") .trim_end_matches("

\n") @@ -35,9 +42,9 @@ impl<'a> Render for ComrakRemovePTags<'a> { struct ComrakText<'a>(&'a AstNode<'a>); -impl<'a> Render for ComrakText<'a> { - fn render_to(&self, buffer: &mut String) { - comrak::format_commonmark(self.0, &COMRAK_OPTIONS, &mut StringWriter(buffer)).unwrap(); +impl<'a> ToHtml for ComrakText<'a> { + fn html(&self, buffer: &mut Html) { + comrak::format_commonmark(self.0, &COMRAK_OPTIONS, &mut TextWriter(buffer)).unwrap(); } } @@ -47,7 +54,7 @@ pub fn main<'a>( nav: &[(&str, &'a AstNode<'a>)], version: &str, hash: &str, -) -> Markup { +) -> Html { html! { (DOCTYPE) meta charset="utf-8"; From a4d1a2e9fe67abc8f6e7340a8b83f6c308ef05ae Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Fri, 3 Dec 2021 17:45:27 +1100 Subject: [PATCH 06/17] Make `Html::push` generic --- maud/src/lib.rs | 24 +++++++++++++----------- maud/tests/misc.rs | 2 +- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 61a025e6..95fb0235 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -66,19 +66,20 @@ pub trait ToHtml { impl ToHtml for str { fn html(&self, buffer: &mut Html) { - buffer.push_text(self); + // XSS-Safety: Special characters will be escaped by `escape_to_string`. + escape::escape_to_string(self, buffer.as_mut_string_unchecked()); } } impl ToHtml for String { fn html(&self, buffer: &mut Html) { - buffer.push_text(self); + str::html(self, buffer); } } impl<'a> ToHtml for Cow<'a, str> { fn html(&self, buffer: &mut Html) { - buffer.push_text(self); + str::html(self, buffer); } } @@ -237,14 +238,14 @@ impl Html { } } - /// Appends another HTML fragment to this one. - pub fn push(&mut self, html: &Html) { - self.inner.to_mut().push_str(&html.inner); + /// Appends the HTML representation of the given value to `self`. + pub fn push(&mut self, value: &(impl ToHtml + ?Sized)) { + value.html(self); } - /// Appends a string, escaping if necessary. - pub fn push_text(&mut self, text: &str) { - escape::escape_to_string(text, self.inner.to_mut()); + /// Exposes the underlying buffer as a `&str`. + pub fn as_str(&self) -> &str { + &self.inner } /// Exposes the underlying buffer as a `&mut String`. @@ -271,14 +272,15 @@ impl Html { impl Write for Html { fn write_str(&mut self, text: &str) -> fmt::Result { - self.push_text(text); + self.push(text); Ok(()) } } impl ToHtml for Html { fn html(&self, buffer: &mut Html) { - buffer.push(self); + // XSS-Safety: `self` is already guaranteed to be trusted HTML. + buffer.as_mut_string_unchecked().push_str(self.as_str()); } } diff --git a/maud/tests/misc.rs b/maud/tests/misc.rs index f6edf698..42237698 100644 --- a/maud/tests/misc.rs +++ b/maud/tests/misc.rs @@ -56,7 +56,7 @@ fn render_impl() { struct R(&'static str); impl ToHtml for R { fn html(&self, buffer: &mut Html) { - buffer.push_text(self.0); + buffer.push(self.0); } } From 7ff4f02c5bd9f342bbb97a868bac76f09d90e755 Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Sat, 4 Dec 2021 19:41:44 +1100 Subject: [PATCH 07/17] Rename `.html()` to `.push_html_to()` --- maud/src/lib.rs | 46 +++++++++++++++++-------------------- maud/tests/misc.rs | 2 +- maud_macros/src/generate.rs | 2 +- 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 95fb0235..51528cad 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -20,15 +20,11 @@ mod escape; /// Represents a type that can be rendered as HTML. /// -/// To implement this for your own type, override either the `.html()` -/// or `.to_html()` methods; since each is defined in terms of the -/// other, you only need to implement one of them. See the example below. -/// /// # Minimal implementation /// /// An implementation of this trait must override at least one of -/// `.html()` or `.to_html()`. Since the default definitions of these -/// methods call each other, not doing this will result in infinite +/// `.to_html()` or `.push_html_to()`. Since the default definitions of +/// these methods call each other, not doing this will result in infinite /// recursion. /// /// # Example @@ -51,7 +47,7 @@ pub trait ToHtml { /// Creates an HTML representation of `self`. fn to_html(&self) -> Html { let mut buffer = Html::default(); - self.html(&mut buffer); + self.push_html_to(&mut buffer); buffer } @@ -59,51 +55,51 @@ pub trait ToHtml { /// /// Its default implementation just calls `.to_html()`, but you may /// override it with something more efficient. - fn html(&self, buffer: &mut Html) { - self.to_html().html(buffer) + fn push_html_to(&self, buffer: &mut Html) { + self.to_html().push_html_to(buffer) } } impl ToHtml for str { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { // XSS-Safety: Special characters will be escaped by `escape_to_string`. escape::escape_to_string(self, buffer.as_mut_string_unchecked()); } } impl ToHtml for String { - fn html(&self, buffer: &mut Html) { - str::html(self, buffer); + fn push_html_to(&self, buffer: &mut Html) { + str::push_html_to(self, buffer); } } impl<'a> ToHtml for Cow<'a, str> { - fn html(&self, buffer: &mut Html) { - str::html(self, buffer); + fn push_html_to(&self, buffer: &mut Html) { + str::push_html_to(self, buffer); } } impl<'a> ToHtml for Arguments<'a> { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { let _ = buffer.write_fmt(*self); } } impl<'a, T: ToHtml + ?Sized> ToHtml for &'a T { - fn html(&self, buffer: &mut Html) { - T::html(self, buffer); + fn push_html_to(&self, buffer: &mut Html) { + T::push_html_to(self, buffer); } } impl<'a, T: ToHtml + ?Sized> ToHtml for &'a mut T { - fn html(&self, buffer: &mut Html) { - T::html(self, buffer); + fn push_html_to(&self, buffer: &mut Html) { + T::push_html_to(self, buffer); } } impl ToHtml for Box { - fn html(&self, buffer: &mut Html) { - T::html(self, buffer); + fn push_html_to(&self, buffer: &mut Html) { + T::push_html_to(self, buffer); } } @@ -111,7 +107,7 @@ macro_rules! impl_render_with_display { ($($ty:ty)*) => { $( impl ToHtml for $ty { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { let _ = write!(buffer, "{self}"); } } @@ -127,7 +123,7 @@ macro_rules! impl_render_with_itoa { ($($ty:ty)*) => { $( impl ToHtml for $ty { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { // XSS-Safety: The characters '0' through '9', and '-', are HTML safe. let _ = itoa::fmt(buffer.as_mut_string_unchecked(), *self); } @@ -240,7 +236,7 @@ impl Html { /// Appends the HTML representation of the given value to `self`. pub fn push(&mut self, value: &(impl ToHtml + ?Sized)) { - value.html(self); + value.push_html_to(self); } /// Exposes the underlying buffer as a `&str`. @@ -278,7 +274,7 @@ impl Write for Html { } impl ToHtml for Html { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { // XSS-Safety: `self` is already guaranteed to be trusted HTML. buffer.as_mut_string_unchecked().push_str(self.as_str()); } diff --git a/maud/tests/misc.rs b/maud/tests/misc.rs index 42237698..c6fdb19c 100644 --- a/maud/tests/misc.rs +++ b/maud/tests/misc.rs @@ -55,7 +55,7 @@ fn issue_23() { fn render_impl() { struct R(&'static str); impl ToHtml for R { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { buffer.push(self.0); } } diff --git a/maud_macros/src/generate.rs b/maud_macros/src/generate.rs index fbd57a0e..a4e1e6e8 100644 --- a/maud_macros/src/generate.rs +++ b/maud_macros/src/generate.rs @@ -103,7 +103,7 @@ impl Generator { fn splice(&self, expr: TokenStream, build: &mut Builder) { let output_ident = self.output_ident.clone(); - build.push_tokens(quote!(maud::ToHtml::html(&#expr, &mut #output_ident);)); + build.push_tokens(quote!(maud::ToHtml::push_html_to(&#expr, &mut #output_ident);)); } fn element(&self, name: TokenStream, attrs: Vec, body: ElementBody, build: &mut Builder) { From e02f4033c16d587f72d0070c0962ad32698cd3e4 Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Sat, 4 Dec 2021 20:09:24 +1100 Subject: [PATCH 08/17] Whoopsies --- docs/src/views.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/views.rs b/docs/src/views.rs index f5ac0d51..9267bc47 100644 --- a/docs/src/views.rs +++ b/docs/src/views.rs @@ -10,7 +10,7 @@ use crate::{ struct Comrak<'a>(&'a AstNode<'a>); impl<'a> ToHtml for Comrak<'a> { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { // XSS-Safety: The input Markdown comes from docs, which are trusted. comrak::format_html( self.0, @@ -43,7 +43,7 @@ impl<'a> ToHtml for ComrakRemovePTags<'a> { struct ComrakText<'a>(&'a AstNode<'a>); impl<'a> ToHtml for ComrakText<'a> { - fn html(&self, buffer: &mut Html) { + fn push_html_to(&self, buffer: &mut Html) { comrak::format_commonmark(self.0, &COMRAK_OPTIONS, &mut TextWriter(buffer)).unwrap(); } } From 7f4a802a64e74bc2fa1ea583cc1752932b06301f Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Wed, 22 Dec 2021 13:46:06 +1100 Subject: [PATCH 09/17] Update docs (partially) --- docs/Makefile | 2 +- docs/content/getting-started.md | 8 +-- docs/content/partials.md | 21 ++++---- docs/content/render-trait.md | 91 --------------------------------- docs/content/splices-toggles.md | 2 + docs/content/text-escaping.md | 31 +++++++---- docs/content/web-frameworks.md | 18 +++---- 7 files changed, 46 insertions(+), 127 deletions(-) delete mode 100644 docs/content/render-trait.md diff --git a/docs/Makefile b/docs/Makefile index 5c108fbc..7686dbc1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,4 +1,4 @@ -slugs := index getting-started text-escaping elements-attributes splices-toggles control-structures partials render-trait web-frameworks faq +slugs := index getting-started text-escaping elements-attributes splices-toggles control-structures partials web-frameworks faq slug_to_md = content/$(1).md slug_to_html = site/$(1).html diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index 751ec546..dc979216 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -24,19 +24,19 @@ use maud::html; fn main() { let name = "Lyra"; - let markup = html! { + let hello = html! { p { "Hi, " (name) "!" } }; - println!("{}", markup.into_string()); + println!("{}", hello.into_string()); } ``` `html!` takes a single argument: a template using Maud's custom syntax. -This call expands to an expression of type [`Markup`][Markup], +This call expands to an expression of type [`Html`][Html], which can then be converted to a `String` using `.into_string()`. -[Markup]: https://docs.rs/maud/*/maud/type.Markup.html +[Html]: https://docs.rs/maud/*/maud/struct.Html.html Run this program with `cargo run`, and you should get the following: diff --git a/docs/content/partials.md b/docs/content/partials.md index 8c402a9a..9ce7afae 100644 --- a/docs/content/partials.md +++ b/docs/content/partials.md @@ -2,16 +2,16 @@ Maud does not have a built-in concept of partials or sub-templates. Instead, -you can compose your markup with any function that returns `Markup`. +you can compose your markup with any function that returns `Html`. The following example defines a `header` and `footer` function. These functions are combined to form the final `page`. ```rust -use maud::{DOCTYPE, html, Markup}; +use maud::{DOCTYPE, html, Html}; /// A basic header with a dynamic `page_title`. -fn header(page_title: &str) -> Markup { +fn header(page_title: &str) -> Html { html! { (DOCTYPE) meta charset="utf-8"; @@ -20,7 +20,7 @@ fn header(page_title: &str) -> Markup { } /// A static footer. -fn footer() -> Markup { +fn footer() -> Html { html! { footer { a href="rss.atom" { "RSS Feed" } @@ -28,12 +28,11 @@ fn footer() -> Markup { } } -/// The final Markup, including `header` and `footer`. +/// The final page, including `header` and `footer`. /// -/// Additionally takes a `greeting_box` that's `Markup`, not `&str`. -pub fn page(title: &str, greeting_box: Markup) -> Markup { +/// Additionally takes a `greeting_box` that's `Html`, not `&str`. +pub fn page(title: &str, greeting_box: Html) -> Html { html! { - // Add the header markup to the page (header(title)) h1 { (title) } (greeting_box) @@ -42,12 +41,12 @@ pub fn page(title: &str, greeting_box: Markup) -> Markup { } ``` -Using the `page` function will return the markup for the whole page. +Using the `page` function will return the HTML for the whole page. Here's an example: ```rust -# use maud::{html, Markup}; -# fn page(title: &str, greeting_box: Markup) -> Markup { greeting_box } +# use maud::{html, Html}; +# fn page(title: &str, greeting_box: Html) -> Html { greeting_box } page("Hello!", html! { div { "Greetings, Maud." } }); diff --git a/docs/content/render-trait.md b/docs/content/render-trait.md deleted file mode 100644 index e49b73ab..00000000 --- a/docs/content/render-trait.md +++ /dev/null @@ -1,91 +0,0 @@ -# The `Render` trait - -Maud uses the [`Render`][Render] trait to convert [`(spliced)`](splices-toggles.md) values to HTML. -This is implemented for many Rust primitive types (`&str`, `i32`) by default, but you can implement it for your own types as well. - -Below are some examples of implementing `Render`. -Feel free to use these snippets in your own project! - -## Example: a shorthand for including CSS stylesheets - -When writing a web page, -it can be annoying to write `link rel="stylesheet"` over and over again. -This example provides a shorthand for linking to CSS stylesheets. - -```rust -use maud::{html, Markup, Render}; - -/// Links to a CSS stylesheet at the given path. -struct Css(&'static str); - -impl Render for Css { - fn render(&self) -> Markup { - html! { - link rel="stylesheet" type="text/css" href=(self.0); - } - } -} -``` - -## Example: a wrapper that calls `std::fmt::Debug` - -When debugging an application, -it can be useful to see its internal state. -But these internal data types often don't implement `Display`. -This wrapper lets us use the [`Debug`][Debug] trait instead. - -To avoid extra allocation, -we override the `.render_to()` method instead of `.render()`. -This doesn't do any escaping by default, -so we wrap the output in an `Escaper` as well. - -```rust -use maud::{Escaper, html, Render}; -use std::fmt; -use std::fmt::Write as _; - -/// Renders the given value using its `Debug` implementation. -struct Debug(T); - -impl Render for Debug { - fn render_to(&self, output: &mut String) { - let mut escaper = Escaper::new(output); - write!(escaper, "{:?}", self.0).unwrap(); - } -} -``` - -## Example: rendering Markdown using `pulldown-cmark` and `ammonia` - -[`pulldown-cmark`][pulldown-cmark] is a popular library -for converting Markdown to HTML. - -We also use the [`ammonia`][ammonia] library, -which sanitizes the resulting markup. - -```rust -use ammonia; -use maud::{Markup, PreEscaped, Render}; -use pulldown_cmark::{Parser, html}; - -/// Renders a block of Markdown using `pulldown-cmark`. -struct Markdown>(T); - -impl> Render for Markdown { - fn render(&self) -> Markup { - // Generate raw HTML - let mut unsafe_html = String::new(); - let parser = Parser::new(self.0.as_ref()); - html::push_html(&mut unsafe_html, parser); - // Sanitize it with ammonia - let safe_html = ammonia::clean(&unsafe_html); - PreEscaped(safe_html) - } -} -``` - -[Debug]: https://doc.rust-lang.org/std/fmt/trait.Debug.html -[Display]: https://doc.rust-lang.org/std/fmt/trait.Display.html -[Render]: https://docs.rs/maud/*/maud/trait.Render.html -[pulldown-cmark]: https://docs.rs/pulldown-cmark/0.0.8/pulldown_cmark/index.html -[ammonia]: https://github.com/notriddle/ammonia diff --git a/docs/content/splices-toggles.md b/docs/content/splices-toggles.md index a5fbabd1..edff46e1 100644 --- a/docs/content/splices-toggles.md +++ b/docs/content/splices-toggles.md @@ -94,6 +94,8 @@ html! { ### What can be spliced? +TODO: update this + You can splice any value that implements [`Render`][Render]. Most primitive types (such as `str` and `i32`) implement this trait, so they should work out of the box. diff --git a/docs/content/text-escaping.md b/docs/content/text-escaping.md index da673137..e2d1e650 100644 --- a/docs/content/text-escaping.md +++ b/docs/content/text-escaping.md @@ -39,24 +39,33 @@ html! { [raw strings]: https://doc.rust-lang.org/reference/tokens.html#raw-string-literals -## Escaping and `PreEscaped` +## Escaping By default, HTML special characters are escaped automatically. -Wrap the string in `(PreEscaped())` to disable this escaping. -(See the section on [splices](splices-toggles.md) to -learn more about how this works.) ```rust -use maud::PreEscaped; -# let _ = maud:: -html! { - "" // <script>... - (PreEscaped("")) // "; +let markup = html! { + (unsafe_input) +}; +assert_eq!(markup.into_string(), "<script>alert('Bwahahaha!')</script>"); +``` + +[xss]: https://owasp.org/www-community/attacks/xss/ + ## The `DOCTYPE` constant If you want to add a `` declaration to your page, diff --git a/docs/content/web-frameworks.md b/docs/content/web-frameworks.md index 8c52fb09..262fdc89 100644 --- a/docs/content/web-frameworks.md +++ b/docs/content/web-frameworks.md @@ -24,7 +24,7 @@ that implements the `actix_web::Responder` trait. ```rust,no_run use actix_web::{get, App, HttpServer, Result as AwResult}; -use maud::{html, Markup}; +use maud::{html, Html}; use std::io; #[get("/")] @@ -59,18 +59,18 @@ maud = { version = "*", features = ["rocket"] } # ... ``` -This adds a `Responder` implementation for the `Markup` type, +This adds a `Responder` implementation for the `Html` type, so you can return the result directly: ```rust,no_run #![feature(decl_macro)] -use maud::{html, Markup}; +use maud::{html, Html}; use rocket::{get, routes}; use std::borrow::Cow; #[get("/")] -fn hello<'a>(name: Cow<'a, str>) -> Markup { +fn hello<'a>(name: Cow<'a, str>) -> Html { html! { h1 { "Hello, " (name) "!" } p { "Nice to meet you!" } @@ -86,7 +86,7 @@ fn main() { Unlike with the other frameworks, Rouille doesn't need any extra features at all! -Calling `Response::html` on the rendered `Markup` will Just Work®. +Calling `Response::html` on the rendered `Html` will Just Work®. ```rust,no_run use maud::html; @@ -118,7 +118,7 @@ maud = { version = "*", features = ["tide"] } # ... ``` -This adds an implementation of `From>` +This adds an implementation of `From` for the `Response` struct. Once provided, callers may return results of `html!` directly as responses: @@ -154,14 +154,14 @@ maud = { version = "*", features = ["axum"] } # ... ``` -This adds an implementation of `IntoResponse` for `Markup`/`PreEscaped`. +This adds an implementation of `IntoResponse` for `Html`. This then allows you to use it directly as a response! ```rust,no_run -use maud::{html, Markup}; +use maud::{html, Html}; use axum::{Router, routing::get}; -async fn hello_world() -> Markup { +async fn hello_world() -> Html { html! { h1 { "Hello, World!" } } From 046d11bd4ee478a76c757afa0aa35c8bf8c90084 Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Wed, 22 Dec 2021 15:40:13 +1100 Subject: [PATCH 10/17] Rename `from_const` to `from_const_unchecked` --- maud/src/lib.rs | 120 +++++++++++++++++++++++++++++++----------- maud/tests/splices.rs | 2 +- 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 51528cad..dd5181aa 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -140,42 +140,45 @@ impl_render_with_itoa! { /// A fragment of HTML. /// /// This is the type that's returned by the [`html!`] macro. +/// +/// # Security +/// +/// All instances of `Html` must be: +/// +/// 1. **Trusted.** Any embedded scripts (in a `"); - /// ``` - /// - /// # Security - /// - /// The given string must be a *compile-time constant*: either a - /// literal, or a reference to a `const` value. This ensures that - /// the string is as trustworthy as the code itself. - /// - /// If the string is not a compile-time constant, use - /// [`Html::from_unchecked`] instead, and document why the call is - /// safe. - /// - /// In the future, when [`const` string parameters] are available on - /// Rust stable, this rule will be enforced by the API. - /// - /// [`const` string parameters]: https://blog.rust-lang.org/inside-rust/2021/09/06/Splitting-const-generics.html#featureadt_const_params - pub const fn from_const(html_string: &'static str) -> Self { - Html { - inner: Cow::Borrowed(html_string), - } - } - #[cfg(feature = "sanitize")] /// Takes an untrusted HTML fragment and makes it safe. /// @@ -209,6 +212,8 @@ impl Html { /// /// # Security /// + /// See [`Html`]. + /// /// It is your responsibility to ensure that the string comes from a /// trusted source. Misuse of this function can lead to [cross-site /// scripting attacks (XSS)][xss]. @@ -226,6 +231,44 @@ impl Html { } } + /// Creates an HTML fragment from a constant string. + /// + /// This is similar to [`Html::from_unchecked`], but can be called + /// in a `const` context. + /// + /// # Example + /// + /// ```rust + /// use maud::Html; + /// + /// const ANALYTICS_SCRIPT: Html = Html::from_const_unchecked( + /// "", + /// ); + /// ``` + /// + /// # Security + /// + /// As long as the string is a compile-time constant, it is + /// guaranteed to be as *trusted* as its surrounding code. + /// + /// However, this doesn't guarantee that it's *composable*: + /// + /// ```rust + /// use maud::Html; + /// + /// // BROKEN - DO NOT USE! + /// const UNCLOSED_SCRIPT: Html = Html::from_const_unchecked("")) + } + # ; + ``` + +[from_const_unchecked]: https://docs.rs/maud/*/maud/struct.Html.html#method.from_const_unchecked + +When Maud implements [context-aware escaping], +these workarounds will no longer be needed. + +[context-aware escaping]: https://github.com/lambda-fairy/maud/issues/181 + +## Custom escaping + +If your use case isn't covered by these examples, +check out the [advanced API]. + +[advanced API]: https://docs.rs/maud/*/maud/struct.Html.html diff --git a/maud/src/lib.rs b/maud/src/lib.rs index b46c583d..6d09c72a 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -145,34 +145,47 @@ impl_to_html_with_itoa! { /// /// All instances of `Html` must be: /// -/// 1. **Trusted.** Any embedded scripts (in a `")) diff --git a/docs/content/web-frameworks.md b/docs/content/web-frameworks.md index 262fdc89..a9992cd8 100644 --- a/docs/content/web-frameworks.md +++ b/docs/content/web-frameworks.md @@ -28,7 +28,7 @@ use maud::{html, Html}; use std::io; #[get("/")] -async fn index() -> AwResult { +async fn index() -> AwResult { Ok(html! { html { body { From 37a216d22da980cdba3f4192fb46c50dd0ea746d Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Wed, 22 Dec 2021 17:44:12 +1100 Subject: [PATCH 15/17] Add `#[allow(clippy::new_without_default)]` --- maud/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 5217decf..a6127717 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -337,6 +337,7 @@ pub struct HtmlBuilder { impl HtmlBuilder { /// For internal use only. #[doc(hidden)] + #[allow(clippy::new_without_default)] pub fn new() -> Self { Self { inner: Cow::Owned(String::new()), From 613ecb8d25a855f65317999555af5f136a38ec38 Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Wed, 22 Dec 2021 17:51:00 +1100 Subject: [PATCH 16/17] Fix docs builder --- docs/src/views.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/views.rs b/docs/src/views.rs index 9267bc47..a46ef115 100644 --- a/docs/src/views.rs +++ b/docs/src/views.rs @@ -1,5 +1,5 @@ use comrak::nodes::AstNode; -use maud::{html, Html, ToHtml, DOCTYPE}; +use maud::{html, Html, HtmlBuilder, ToHtml, DOCTYPE}; use std::str; use crate::{ @@ -10,12 +10,12 @@ use crate::{ struct Comrak<'a>(&'a AstNode<'a>); impl<'a> ToHtml for Comrak<'a> { - fn push_html_to(&self, buffer: &mut Html) { + fn push_html_to(&self, builder: &mut HtmlBuilder) { // XSS-Safety: The input Markdown comes from docs, which are trusted. comrak::format_html( self.0, &COMRAK_OPTIONS, - &mut TextWriter(buffer.as_mut_string_unchecked()), + &mut TextWriter(builder.as_mut_string_unchecked()), ) .unwrap(); } @@ -43,8 +43,8 @@ impl<'a> ToHtml for ComrakRemovePTags<'a> { struct ComrakText<'a>(&'a AstNode<'a>); impl<'a> ToHtml for ComrakText<'a> { - fn push_html_to(&self, buffer: &mut Html) { - comrak::format_commonmark(self.0, &COMRAK_OPTIONS, &mut TextWriter(buffer)).unwrap(); + fn push_html_to(&self, builder: &mut HtmlBuilder) { + comrak::format_commonmark(self.0, &COMRAK_OPTIONS, &mut TextWriter(builder)).unwrap(); } } From 3f6d41bbaa4fbecefb3469ad5e6622241d2b346a Mon Sep 17 00:00:00 2001 From: Chris Wong Date: Wed, 22 Dec 2021 18:18:10 +1100 Subject: [PATCH 17/17] Make `HtmlBuilder::new` private --- maud/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/maud/src/lib.rs b/maud/src/lib.rs index a6127717..08a8aed6 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -336,9 +336,7 @@ pub struct HtmlBuilder { impl HtmlBuilder { /// For internal use only. - #[doc(hidden)] - #[allow(clippy::new_without_default)] - pub fn new() -> Self { + fn new() -> Self { Self { inner: Cow::Owned(String::new()), }