feat: config option for enabling the website field, and allowing html injection in messages

This commit is contained in:
Lewis Wynne 2026-04-09 18:36:59 +01:00
parent aa5de7a7a4
commit 21f8d5a6a5
3 changed files with 209 additions and 15 deletions

View file

@ -14,6 +14,8 @@ pub struct Config {
pub max_message_length: usize,
pub max_website_length: usize,
pub open_registration: bool,
pub enable_website_field: bool,
pub allow_html_injection: bool,
pub template: Option<String>,
pub separator: String,
pub style: String,
@ -66,6 +68,12 @@ impl Config {
open_registration: env::var("BOOK_OPEN_REGISTRATION")
.map(|v| v != "false")
.unwrap_or(true),
enable_website_field: env::var("BOOK_ENABLE_WEBSITE_FIELD")
.map(|v| v != "false")
.unwrap_or(true),
allow_html_injection: env::var("BOOK_ALLOW_HTML_INJECTION")
.map(|v| v != "false")
.unwrap_or(true),
separator: env::var("BOOK_SEPARATOR")
.unwrap_or_else(|_| "------------------------------------------------------------".into()),
template: env::var("BOOK_TEMPLATE").ok().map(|path| {
@ -157,4 +165,62 @@ mod tests {
let result = Config::from_env();
assert!(result.is_err());
}
#[test]
fn test_enable_website_field_default() {
let _lock = ENV_LOCK.lock().unwrap();
env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
env::remove_var("BOOK_ENABLE_WEBSITE_FIELD");
let config = Config::from_env().unwrap();
assert!(config.enable_website_field);
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
}
#[test]
fn test_enable_website_field_false() {
let _lock = ENV_LOCK.lock().unwrap();
env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
env::set_var("BOOK_ENABLE_WEBSITE_FIELD", "false");
let config = Config::from_env().unwrap();
assert!(!config.enable_website_field);
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
env::remove_var("BOOK_ENABLE_WEBSITE_FIELD");
}
#[test]
fn test_allow_html_injection_default() {
let _lock = ENV_LOCK.lock().unwrap();
env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
env::remove_var("BOOK_ALLOW_HTML_INJECTION");
let config = Config::from_env().unwrap();
assert!(config.allow_html_injection);
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
}
#[test]
fn test_allow_html_injection_false() {
let _lock = ENV_LOCK.lock().unwrap();
env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
env::set_var("BOOK_ALLOW_HTML_INJECTION", "false");
let config = Config::from_env().unwrap();
assert!(!config.allow_html_injection);
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
env::remove_var("BOOK_ALLOW_HTML_INJECTION");
}
}

View file

@ -5,7 +5,7 @@ pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/default.html");
pub const DEFAULT_STYLE: &str = include_str!("../templates/default.css");
pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String {
let entries_html = render_entries(entries, &config.separator);
let entries_html = render_entries(entries, config);
let css = if config.style.is_empty() {
DEFAULT_STYLE
} else {
@ -20,15 +20,21 @@ pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html
}
pub fn render_form(config: &Config) -> String {
let website_section = if config.enable_website_field {
format!(
"\n<label class=\"guestbook-label\">{}</label>\n<input class=\"guestbook-input\" name=\"website\">\n",
config.label_website
)
} else {
String::new()
};
format!(
r#"<span class="guestbook-prompt">{prompt}</span>
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
<label class="guestbook-label">{label_name}</label>
<input class="guestbook-input" name="name" required>
<label class="guestbook-label">{label_website}</label>
<input class="guestbook-input" name="website">
{website_section}
<label class="guestbook-label">{label_message}</label>
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
@ -36,7 +42,7 @@ pub fn render_form(config: &Config) -> String {
</form>"#,
prompt = config.form_prompt,
label_name = config.label_name,
label_website = config.label_website,
website_section = website_section,
label_message = config.label_message,
rows = config.textarea_rows,
cols = config.textarea_cols,
@ -44,29 +50,48 @@ pub fn render_form(config: &Config) -> String {
)
}
fn render_entries(entries: &[Entry], separator: &str) -> String {
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
fn render_entries(entries: &[Entry], config: &Config) -> String {
let mut html = String::new();
for entry in entries {
html.push_str(&render_entry(entry, separator));
html.push_str(&render_entry(entry, config));
}
html
}
fn render_entry(entry: &Entry, separator: &str) -> String {
fn render_entry(entry: &Entry, config: &Config) -> String {
let name = if config.allow_html_injection {
entry.meta.name.clone()
} else {
escape_html(&entry.meta.name)
};
let mut header = format!(
"<span class=\"entry-header\">{} - <span class=\"entry-name\">{}</span>",
entry.meta.date, entry.meta.name
entry.meta.date, name
);
if !entry.meta.website.is_empty() {
if config.enable_website_field && !entry.meta.website.is_empty() {
let website = escape_html(&entry.meta.website);
header.push_str(&format!(
" (<a class=\"entry-website\" href=\"{}\">{}</a>)",
entry.meta.website, entry.meta.website
website, website
));
}
header.push_str("</span>");
let body = if config.allow_html_injection {
entry.body.clone()
} else {
escape_html(&entry.body)
};
format!(
"\n{header}\n\n<span class=\"entry-body\">{}</span>\n\n<span class=\"entry-separator\">{separator}</span>\n",
entry.body
"\n{header}\n\n<span class=\"entry-body\">{body}</span>\n\n<span class=\"entry-separator\">{}</span>\n",
config.separator
)
}
@ -89,6 +114,8 @@ mod tests {
max_message_length: 1000,
max_website_length: 100,
open_registration: true,
enable_website_field: true,
allow_html_injection: true,
template: None,
separator: "---".into(),
style: String::new(),
@ -203,4 +230,74 @@ mod tests {
assert!(form.contains("rows=\"12\""));
assert!(form.contains("cols=\"40\""));
}
#[test]
fn test_render_form_hides_website_when_disabled() {
let mut config = test_config();
config.enable_website_field = false;
let form = render_form(&config);
assert!(!form.contains("name=\"website\""));
assert!(!form.contains(&config.label_website));
}
#[test]
fn test_render_form_shows_website_when_enabled() {
let config = test_config();
let form = render_form(&config);
assert!(form.contains("name=\"website\""));
assert!(form.contains(&config.label_website));
}
#[test]
fn test_render_entry_always_escapes_website() {
let config = test_config();
let mut entry = make_entry("bob", "2026-04-09", "Hi!");
entry.meta.website = "https://example.com?a=1&b=2".into();
let form = render_form(&config);
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(html.contains("href=\"https://example.com?a=1&amp;b=2\""));
assert!(!html.contains("href=\"https://example.com?a=1&b=2\""));
}
#[test]
fn test_render_entry_hides_website_when_disabled() {
let mut config = test_config();
config.enable_website_field = false;
let mut entry = make_entry("bob", "2026-04-09", "Hi!");
entry.meta.website = "https://bob.com".into();
let form = render_form(&config);
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(!html.contains("href=\"https://bob.com\""));
assert!(!html.contains("class=\"entry-website\""));
}
#[test]
fn test_render_entry_escapes_html_when_injection_disabled() {
let mut config = test_config();
config.allow_html_injection = false;
let entry = make_entry("<b>hacker</b>", "2026-04-09", "<script>alert('xss')</script>");
let form = render_form(&config);
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(html.contains("&lt;b&gt;hacker&lt;/b&gt;"));
assert!(html.contains("&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"));
assert!(!html.contains("<script>"));
}
#[test]
fn test_render_entry_preserves_html_when_injection_enabled() {
let mut config = test_config();
config.allow_html_injection = true;
let entry = make_entry("carol", "2026-04-09", "<b>Bold</b>");
let form = render_form(&config);
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(html.contains("<b>Bold</b>"));
}
#[test]
fn test_escape_html() {
assert_eq!(
escape_html("<b>test</b> & \"quotes\" 'apos'"),
"&lt;b&gt;test&lt;/b&gt; &amp; &quot;quotes&quot; &#x27;apos&#x27;"
);
}
}

View file

@ -68,7 +68,11 @@ async fn submit(
// Validation
let name = form.name.trim().to_string();
let message = form.message.trim().to_string();
let website = form.website.trim().to_string();
let website = if state.config.enable_website_field {
form.website.trim().to_string()
} else {
String::new()
};
if name.is_empty() || message.is_empty() {
return Html("Name and message are required.".to_string());
@ -137,6 +141,8 @@ mod tests {
max_message_length: 1000,
max_website_length: 100,
open_registration: true,
enable_website_field: true,
allow_html_injection: true,
template: None,
separator: "---".into(),
style: String::new(),
@ -302,4 +308,29 @@ mod tests {
.count();
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_website_field_disabled_ignores_website() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_website_field = false;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello&website=http://evil.com").await;
assert!(body.contains("pending approval"));
let entries_dir = dir.path().join("entries");
let files: Vec<_> = std::fs::read_dir(&entries_dir).unwrap().collect();
assert_eq!(files.len(), 1);
let content = std::fs::read_to_string(files[0].as_ref().unwrap().path()).unwrap();
assert!(content.contains("website = \"\""));
}
#[tokio::test]
async fn test_website_field_disabled_hides_form_field() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_website_field = false;
let (app, _rx) = test_app(config);
let html = get_index(&app).await;
assert!(!html.contains("name=\"website\""));
}
}