use rustlavel::prelude::*;
use crate::support::{page, settings::Settings};
pub struct AppearanceController;
const LOGO_DIR: &str = "storage/app/public/logos";
const LOGO_URL: &str = "/storage/logos/";
const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
const LOGIN_KEYS: &[&str] = &[
"theme.login.light.from",
"theme.login.light.to",
"theme.login.dark.from",
"theme.login.dark.to",
];
const SIDEBAR_KEYS: &[&str] = &[
"theme.sidebar.light.bg",
"theme.sidebar.light.text",
"theme.sidebar.light.active_bg",
"theme.sidebar.light.active_text",
"theme.sidebar.dark.bg",
"theme.sidebar.dark.text",
"theme.sidebar.dark.active_bg",
"theme.sidebar.dark.active_text",
];
const LOGO_KEYS: &[&str] = &["theme.logo.light", "theme.logo.dark"];
const BRAND_KEYS: &[&str] = &["theme.brand"];
impl AppearanceController {
pub async fn save_brand(req: Request) -> Result<Response> {
Self::save_colours(req, BRAND_KEYS, "The brand colour has been saved. Every page uses it.").await
}
pub async fn save_login(req: Request) -> Result<Response> {
Self::save_colours(req, LOGIN_KEYS, "The login panel colours have been saved.").await
}
pub async fn save_sidebar(req: Request) -> Result<Response> {
Self::save_colours(req, SIDEBAR_KEYS, "The sidebar colours have been saved.").await
}
async fn save_colours(mut req: Request, keys: &[&str], done: &str) -> Result<Response> {
let store = settings(&req)?.clone();
let mut values = Vec::with_capacity(keys.len());
let mut coerced = 0;
for key in keys {
let submitted = req.input(key).unwrap_or_default();
let safe = colour(&submitted);
if !submitted.trim().is_empty() && safe != submitted.trim() {
coerced += 1;
}
values.push(((*key).to_string(), safe));
}
store.put_all(&values).await?;
if coerced > 0 {
page::flash(
&req,
"error",
format!(
"{coerced} value{} was not a hex colour and has been set to black instead.",
if coerced == 1 { "" } else { "s" }
),
);
} else {
if let Some(audit) = crate::support::audit::of(&req, "settings.updated") {
audit
.describe(format!("Updated the appearance: {}", done.to_lowercase()))
.with("keys", Json::from(keys.join(", ")))
.record()
.await;
}
page::flash(&req, "success", done);
}
Ok(Response::see_other("/admin/settings/appearance"))
}
pub async fn save_logos(mut req: Request) -> Result<Response> {
let store = settings(&req)?.clone();
let mut values = Vec::with_capacity(LOGO_KEYS.len());
for key in LOGO_KEYS {
let submitted = req.input(key).unwrap_or_default();
let next = logo_path(&submitted);
let previous = logo_path(&store.get(key).await);
discard(&previous, &next).await;
values.push(((*key).to_string(), next));
}
store.put_all(&values).await?;
if let Some(audit) = crate::support::audit::of(&req, "logo.updated") {
audit.describe("Updated the application logos").record().await;
}
page::flash(&req, "success", "The logos have been saved.");
Ok(Response::see_other("/admin/settings/appearance"))
}
pub async fn upload(req: Request) -> Result<Response> {
let bytes = req.body();
if bytes.is_empty() {
return Ok(refused(Status::BAD_REQUEST, "No file was received."));
}
if bytes.len() > MAX_LOGO_BYTES {
return Ok(refused(
Status::PAYLOAD_TOO_LARGE,
"That file is larger than 2 MB. Please use a smaller one.",
));
}
let format = match sniff(bytes) {
Ok(format) => format,
Err(reason) => return Ok(refused(Status::UNPROCESSABLE, &reason)),
};
let name = format!("{}.{}", rustlavel::auth::random::hex(16), format.extension());
let directory = std::path::Path::new(LOGO_DIR);
rustlavel::tokio::fs::create_dir_all(directory).await?;
rustlavel::tokio::fs::write(directory.join(&name), bytes).await?;
Ok(Response::json(Json::object([
("path", Json::from(format!("{LOGO_URL}{name}"))),
("format", Json::from(format.extension())),
])))
}
pub async fn logo(req: Request) -> Result<Response> {
let name = req.param("file").unwrap_or_default();
let Some(format) = stored_name(name) else { return Ok(Response::not_found()) };
let path = std::path::Path::new(LOGO_DIR).join(name);
let Ok(bytes) = rustlavel::tokio::fs::read(&path).await else {
return Ok(Response::not_found());
};
Ok(Response::ok()
.with_header("content-type", format.mime())
.with_header("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'")
.with_header("x-content-type-options", "nosniff")
.with_header("cache-control", "public, max-age=300")
.with_body(bytes))
}
}
fn settings(req: &Request) -> Result<&Settings> {
req.state::<Settings>().ok_or_else(|| {
Error::msg(
"the settings store is not registered. Add `.state(Settings::from_config(db.clone(), \
app.config())?)` in main.rs.",
)
})
}
fn refused(status: Status, message: &str) -> Response {
Response::new(status).with_json(Json::object([("message", Json::from(message))]))
}
async fn discard(previous: &str, next: &str) {
if previous.is_empty() || previous == next {
return;
}
let Some(name) = previous.strip_prefix(LOGO_URL) else { return };
if stored_name(name).is_none() {
return;
}
let _ = rustlavel::tokio::fs::remove_file(std::path::Path::new(LOGO_DIR).join(name)).await;
}
fn colour(value: &str) -> String {
let candidate = value.trim();
let body = candidate.strip_prefix('#').unwrap_or("");
let valid = matches!(body.len(), 3 | 6) && body.chars().all(|c| c.is_ascii_hexdigit());
if valid { format!("#{body}") } else { "#000000".to_string() }
}
fn logo_path(value: &str) -> String {
let candidate = value.trim();
if candidate.is_empty() {
return String::new();
}
let acceptable = candidate.starts_with('/')
&& !candidate.starts_with("//")
&& !candidate.contains("..")
&& candidate.len() <= 200
&& candidate.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'));
if acceptable { candidate.to_string() } else { String::new() }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Png,
Jpeg,
Svg,
}
impl Format {
fn extension(self) -> &'static str {
match self {
Format::Png => "png",
Format::Jpeg => "jpg",
Format::Svg => "svg",
}
}
fn mime(self) -> &'static str {
match self {
Format::Png => "image/png",
Format::Jpeg => "image/jpeg",
Format::Svg => "image/svg+xml",
}
}
}
fn stored_name(name: &str) -> Option<Format> {
let (stem, extension) = name.rsplit_once('.')?;
if stem.len() != 32 || !stem.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
match extension {
"png" => Some(Format::Png),
"jpg" => Some(Format::Jpeg),
"svg" => Some(Format::Svg),
_ => None,
}
}
fn sniff(bytes: &[u8]) -> std::result::Result<Format, String> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Ok(Format::Png);
}
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Ok(Format::Jpeg);
}
match std::str::from_utf8(bytes) {
Ok(text) => check_svg(text).map(|()| Format::Svg),
Err(_) => Err("That file is not a PNG, a JPEG or an SVG.".to_string()),
}
}
const SVG_ELEMENTS: &[&str] = &[
"svg", "g", "defs", "symbol", "use", "title", "desc", "style", "switch", "a", "path", "rect",
"circle", "ellipse", "line", "polyline", "polygon", "text", "tspan", "textpath",
"lineargradient", "radialgradient", "stop", "clippath", "mask", "pattern", "image", "marker",
"filter", "fegaussianblur", "feoffset", "feblend", "feflood", "fecomposite", "femerge",
"femergenode", "fecolormatrix", "fedropshadow", "femorphology", "fetile", "feturbulence",
];
fn check_svg(source: &str) -> std::result::Result<(), String> {
let bytes: Vec<char> = source.chars().collect();
let mut index = 0;
let mut stack: Vec<String> = Vec::new();
let mut root_seen = false;
let refuse = |what: &str| Err(format!("That SVG was refused: {what}."));
while index < bytes.len() {
if bytes[index] != '<' {
index += 1;
continue;
}
index += 1;
match bytes.get(index) {
None => return refuse("it ends in the middle of a tag"),
Some('?') => {
index = skip_to(&bytes, index, "?>").ok_or("unterminated <?…?>".to_string())?;
continue;
}
Some('!') => {
if bytes[index..].starts_with(&['!', '-', '-']) {
index = skip_to(&bytes, index, "-->").ok_or("unterminated comment".to_string())?;
continue;
}
return refuse("a DOCTYPE or CDATA section is not allowed in a logo");
}
Some('/') => {
index += 1;
let (name, next) = read_name(&bytes, index);
index = skip_to(&bytes, next, ">").ok_or("unterminated closing tag".to_string())?;
match stack.pop() {
Some(open) if open == name => {}
_ => return refuse("its tags do not nest, so it is not XML"),
}
continue;
}
Some(_) => {}
}
let (name, next) = read_name(&bytes, index);
index = next;
if name.is_empty() {
return refuse("a tag has no name, so it is not XML");
}
if !root_seen {
if name != "svg" {
return refuse("its root element is not <svg>");
}
root_seen = true;
}
if !SVG_ELEMENTS.contains(&name.as_str()) {
return refuse(&format!("<{name}> is not an element a logo may contain"));
}
let mut empty_element = false;
loop {
index = skip_space(&bytes, index);
match bytes.get(index) {
None => return refuse("it ends in the middle of a tag"),
Some('>') => {
index += 1;
stack.push(name.clone());
break;
}
Some('/') if bytes.get(index + 1) == Some(&'>') => {
index += 2;
empty_element = true;
break;
}
Some(_) => {}
}
let (attribute, after_name) = read_name(&bytes, index);
if attribute.is_empty() {
return refuse("an attribute has no name, so it is not XML");
}
index = skip_space(&bytes, after_name);
if bytes.get(index) != Some(&'=') {
return refuse("an attribute has no value, so it is not XML");
}
index = skip_space(&bytes, index + 1);
let quote = match bytes.get(index) {
Some(&q @ ('"' | '\'')) => q,
_ => return refuse("an attribute value is not quoted, so it is not XML"),
};
let start = index + 1;
let end = (start..bytes.len())
.find(|&i| bytes[i] == quote)
.ok_or("unterminated attribute value".to_string())?;
let value: String = bytes[start..end].iter().collect();
index = end + 1;
check_attribute(&attribute, &value).map_err(|what| format!("That SVG was refused: {what}."))?;
}
if name == "style" && !empty_element {
let end = find(&bytes, index, "</style>").ok_or("unterminated <style>".to_string())?;
let css: String = bytes[index..end].iter().collect::<String>().to_lowercase();
if css.contains("@import") || css.contains("javascript:") || css.contains("expression(")
{
return refuse("its <style> block fetches or runs something");
}
if css.split("url(").skip(1).any(|rest| !rest.trim_start().starts_with('#')) {
return refuse("its <style> block loads something from outside the file");
}
}
}
if !root_seen {
return refuse("it contains no elements");
}
if !stack.is_empty() {
return refuse("a tag is left open, so it is not XML");
}
Ok(())
}
fn check_attribute(name: &str, value: &str) -> std::result::Result<(), String> {
if name.starts_with("on") {
return Err(format!("`{name}` is an event handler"));
}
let flattened = value.to_lowercase().replace(char::is_whitespace, "");
if flattened.contains("javascript:") || flattened.contains("data:text/html") {
return Err(format!("`{name}` holds a script URL"));
}
let reference = matches!(name, "href" | "src") || name.ends_with(":href");
if reference && !(flattened.starts_with('#') || flattened.starts_with("data:image/")) {
return Err(format!("`{name}` points outside the file"));
}
Ok(())
}
fn read_name(source: &[char], index: usize) -> (String, usize) {
let mut end = index;
while end < source.len()
&& (source[end].is_ascii_alphanumeric() || matches!(source[end], '-' | '_' | ':' | '.'))
{
end += 1;
}
let raw: String = source[index..end].iter().collect::<String>().to_lowercase();
(raw, end)
}
fn skip_space(source: &[char], mut index: usize) -> usize {
while index < source.len() && source[index].is_whitespace() {
index += 1;
}
index
}
fn skip_to(source: &[char], from: usize, needle: &str) -> Option<usize> {
find(source, from, needle).map(|at| at + needle.chars().count())
}
fn find(source: &[char], from: usize, needle: &str) -> Option<usize> {
let pattern: Vec<char> = needle.chars().collect();
if pattern.is_empty() || source.len() < pattern.len() {
return None;
}
(from..=source.len() - pattern.len()).find(|&start| {
source[start..start + pattern.len()]
.iter()
.zip(&pattern)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
})
}
#[cfg(test)]
mod tests {
use super::{check_svg, colour, logo_path, sniff, stored_name, AppearanceController, Format};
use rustlavel::testing::TestClient;
use rustlavel::{Method, Request, Router};
fn client() -> TestClient {
let mut router = Router::new();
router.post("/upload", AppearanceController::upload);
router.get("/storage/logos/{file}", AppearanceController::logo);
TestClient::new(router)
}
fn png() -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend_from_slice(b"\x00\x00\x00\x0dIHDR");
bytes.extend_from_slice(&[0u8; 32]);
bytes
}
fn jpeg() -> Vec<u8> {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
bytes.extend_from_slice(b"\x00\x10JFIF\x00");
bytes.extend_from_slice(&[0u8; 32]);
bytes
}
#[test]
fn a_colour_is_six_or_three_hex_digits_and_nothing_else() {
assert_eq!(colour("#3b82f6"), "#3b82f6");
assert_eq!(colour(" #FFF "), "#FFF");
assert_eq!(colour("#1E3A5F"), "#1E3A5F");
}
#[test]
fn a_colour_that_could_close_the_declaration_is_replaced() {
for hostile in [
"red; } body { display: none",
"#fff; background: url(https://evil.example/x)",
"url(javascript:alert(1))",
"3b82f6",
"",
"#12345",
"#gggggg",
"#fff)",
] {
assert_eq!(colour(hostile), "#000000", "{hostile} should not have been accepted");
}
}
#[test]
fn a_logo_path_has_to_be_a_path_on_this_origin() {
assert_eq!(logo_path("/storage/logos/abc.png"), "/storage/logos/abc.png");
assert_eq!(logo_path(" /storage/logos/abc.svg "), "/storage/logos/abc.svg");
assert_eq!(logo_path(""), "");
for hostile in [
"//evil.example/logo.png",
"https://evil.example/logo.png",
"javascript:alert(1)",
"data:image/svg+xml,<svg onload=alert(1)>",
"/storage/logos/../../../.env",
"storage/logos/abc.png",
"/storage/logos/a\"onerror=\"alert(1).png",
] {
assert_eq!(logo_path(hostile), "", "{hostile} should not have been accepted");
}
}
#[test]
fn the_bytes_decide_which_format_a_file_is() {
assert_eq!(sniff(&png()), Ok(Format::Png));
assert_eq!(sniff(&jpeg()), Ok(Format::Jpeg));
assert_eq!(sniff(br#"<svg xmlns="http://www.w3.org/2000/svg"><rect width="4" height="4"/></svg>"#), Ok(Format::Svg));
}
#[test]
fn a_renamed_executable_is_not_a_logo() {
for disguise in [
b"\xcf\xfa\xed\xfe\x07\x00\x00\x01".as_slice(),
b"\x7fELF\x02\x01\x01\x00".as_slice(),
b"MZ\x90\x00\x03\x00\x00\x00".as_slice(),
b"PK\x03\x04\x14\x00\x00\x00".as_slice(),
b"\xff\xd8\xfe\x00".as_slice(),
b"\x89PNG\r\n\x1a\x0bIHDR".as_slice(),
] {
assert!(sniff(disguise).is_err(), "{disguise:?} should not have been accepted");
}
}
#[test]
fn a_plain_svg_logo_is_accepted() {
let logo = r##"<?xml version="1.0" encoding="UTF-8"?>
<!-- exported from a drawing program -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<title>Acme</title>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b82f6"/>
<stop offset="1" stop-color="#2563eb"/>
</linearGradient>
</defs>
<style>.mark { fill: url(#g); }</style>
<path class="mark" d="M4 4h40v40H4z"/>
<use href="#g"/>
</svg>"##;
assert_eq!(check_svg(logo), Ok(()));
}
#[test]
fn an_svg_that_can_run_something_is_refused() {
for hostile in [
r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><rect onmouseover="alert(1)"/></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)"><rect/></a></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><iframe src="x"/></foreignObject></svg>"#,
r#"<!DOCTYPE svg [<!ENTITY a "aaaa">]><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><![CDATA[<script>alert(1)</script>]]></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><use href="https://evil.example/x#a"/></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/pixel.png"/></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/x.css);</style></svg>"#,
r#"<html><body><script>alert(1)</script></body></html>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg" onload><rect/></svg>"#,
r#"<svg xmlns=http://www.w3.org/2000/svg><rect/></svg>"#,
] {
assert!(check_svg(hostile).is_err(), "should have been refused: {hostile}");
}
}
#[test]
fn an_svg_carrying_a_script_never_reaches_the_disk() {
let hostile = br#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#;
assert!(sniff(hostile).is_err());
}
#[test]
fn only_a_name_this_controller_generated_is_served() {
assert_eq!(stored_name("0123456789abcdef0123456789abcdef.png"), Some(Format::Png));
assert_eq!(stored_name("0123456789abcdef0123456789abcdef.jpg"), Some(Format::Jpeg));
assert_eq!(stored_name("0123456789abcdef0123456789abcdef.svg"), Some(Format::Svg));
for hostile in [
"../../../.env",
"0123456789abcdef0123456789abcdef.php",
"0123456789abcdef0123456789abcde.png",
"0123456789abcdef0123456789abcdefg.png",
"..%2f..%2f.env",
"logo.png",
"",
] {
assert_eq!(stored_name(hostile), None, "{hostile} should not have been served");
}
}
#[rustlavel::test]
async fn a_png_survives_the_upload_and_comes_back_out_of_the_route() {
let client = client();
let response = client
.send(
Request::new(Method::Post, "/upload")
.with_body(png())
.with_header("content-type", "image/png"),
)
.await
.assert_ok();
let path = response
.json()
.get("path")
.and_then(|value| value.as_str().map(str::to_string))
.expect("the response says where the file went");
assert!(path.starts_with("/storage/logos/"), "{path}");
client
.get(&path)
.await
.assert_ok()
.assert_header("content-type", "image/png")
.assert_header("x-content-type-options", "nosniff");
let name = path.rsplit('/').next().unwrap();
std::fs::remove_file(std::path::Path::new(super::LOGO_DIR).join(name)).unwrap();
}
#[rustlavel::test]
async fn the_route_refuses_a_renamed_executable_whatever_it_claims_to_be() {
client()
.send(
Request::new(Method::Post, "/upload")
.with_body(b"\xcf\xfa\xed\xfe\x07\x00\x00\x01".to_vec())
.with_header("content-type", "image/png"),
)
.await
.assert_status(422);
}
#[rustlavel::test]
async fn the_route_refuses_an_svg_carrying_a_script() {
client()
.send(
Request::new(Method::Post, "/upload")
.with_body(
br#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#
.to_vec(),
)
.with_header("content-type", "image/svg+xml"),
)
.await
.assert_status(422);
}
#[rustlavel::test]
async fn the_route_serves_nothing_it_did_not_name_itself() {
client().get("/storage/logos/..%2f..%2f.env").await.assert_not_found();
client().get("/storage/logos/anything.png").await.assert_not_found();
}
}