feat: config option for enabling the website field, and allowing html injection in messages
This commit is contained in:
parent
aa5de7a7a4
commit
21f8d5a6a5
3 changed files with 209 additions and 15 deletions
|
|
@ -14,6 +14,8 @@ pub struct Config {
|
||||||
pub max_message_length: usize,
|
pub max_message_length: usize,
|
||||||
pub max_website_length: usize,
|
pub max_website_length: usize,
|
||||||
pub open_registration: bool,
|
pub open_registration: bool,
|
||||||
|
pub enable_website_field: bool,
|
||||||
|
pub allow_html_injection: bool,
|
||||||
pub template: Option<String>,
|
pub template: Option<String>,
|
||||||
pub separator: String,
|
pub separator: String,
|
||||||
pub style: String,
|
pub style: String,
|
||||||
|
|
@ -66,6 +68,12 @@ impl Config {
|
||||||
open_registration: env::var("BOOK_OPEN_REGISTRATION")
|
open_registration: env::var("BOOK_OPEN_REGISTRATION")
|
||||||
.map(|v| v != "false")
|
.map(|v| v != "false")
|
||||||
.unwrap_or(true),
|
.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")
|
separator: env::var("BOOK_SEPARATOR")
|
||||||
.unwrap_or_else(|_| "------------------------------------------------------------".into()),
|
.unwrap_or_else(|_| "------------------------------------------------------------".into()),
|
||||||
template: env::var("BOOK_TEMPLATE").ok().map(|path| {
|
template: env::var("BOOK_TEMPLATE").ok().map(|path| {
|
||||||
|
|
@ -157,4 +165,62 @@ mod tests {
|
||||||
let result = Config::from_env();
|
let result = Config::from_env();
|
||||||
assert!(result.is_err());
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
src/render.rs
125
src/render.rs
|
|
@ -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 const DEFAULT_STYLE: &str = include_str!("../templates/default.css");
|
||||||
|
|
||||||
pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String {
|
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() {
|
let css = if config.style.is_empty() {
|
||||||
DEFAULT_STYLE
|
DEFAULT_STYLE
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -20,15 +20,21 @@ pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_form(config: &Config) -> String {
|
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!(
|
format!(
|
||||||
r#"<span class="guestbook-prompt">{prompt}</span>
|
r#"<span class="guestbook-prompt">{prompt}</span>
|
||||||
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
|
||||||
<label class="guestbook-label">{label_name}</label>
|
<label class="guestbook-label">{label_name}</label>
|
||||||
<input class="guestbook-input" name="name" required>
|
<input class="guestbook-input" name="name" required>
|
||||||
|
{website_section}
|
||||||
<label class="guestbook-label">{label_website}</label>
|
|
||||||
<input class="guestbook-input" name="website">
|
|
||||||
|
|
||||||
<label class="guestbook-label">{label_message}</label>
|
<label class="guestbook-label">{label_message}</label>
|
||||||
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
|
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
|
||||||
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
|
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
|
||||||
|
|
@ -36,7 +42,7 @@ pub fn render_form(config: &Config) -> String {
|
||||||
</form>"#,
|
</form>"#,
|
||||||
prompt = config.form_prompt,
|
prompt = config.form_prompt,
|
||||||
label_name = config.label_name,
|
label_name = config.label_name,
|
||||||
label_website = config.label_website,
|
website_section = website_section,
|
||||||
label_message = config.label_message,
|
label_message = config.label_message,
|
||||||
rows = config.textarea_rows,
|
rows = config.textarea_rows,
|
||||||
cols = config.textarea_cols,
|
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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_entries(entries: &[Entry], config: &Config) -> String {
|
||||||
let mut html = String::new();
|
let mut html = String::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
html.push_str(&render_entry(entry, separator));
|
html.push_str(&render_entry(entry, config));
|
||||||
}
|
}
|
||||||
html
|
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!(
|
let mut header = format!(
|
||||||
"<span class=\"entry-header\">{} - <span class=\"entry-name\">{}</span>",
|
"<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!(
|
header.push_str(&format!(
|
||||||
" (<a class=\"entry-website\" href=\"{}\">{}</a>)",
|
" (<a class=\"entry-website\" href=\"{}\">{}</a>)",
|
||||||
entry.meta.website, entry.meta.website
|
website, website
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
header.push_str("</span>");
|
header.push_str("</span>");
|
||||||
|
let body = if config.allow_html_injection {
|
||||||
|
entry.body.clone()
|
||||||
|
} else {
|
||||||
|
escape_html(&entry.body)
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"\n{header}\n\n<span class=\"entry-body\">{}</span>\n\n<span class=\"entry-separator\">{separator}</span>\n",
|
"\n{header}\n\n<span class=\"entry-body\">{body}</span>\n\n<span class=\"entry-separator\">{}</span>\n",
|
||||||
entry.body
|
config.separator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,6 +114,8 @@ mod tests {
|
||||||
max_message_length: 1000,
|
max_message_length: 1000,
|
||||||
max_website_length: 100,
|
max_website_length: 100,
|
||||||
open_registration: true,
|
open_registration: true,
|
||||||
|
enable_website_field: true,
|
||||||
|
allow_html_injection: true,
|
||||||
template: None,
|
template: None,
|
||||||
separator: "---".into(),
|
separator: "---".into(),
|
||||||
style: String::new(),
|
style: String::new(),
|
||||||
|
|
@ -203,4 +230,74 @@ mod tests {
|
||||||
assert!(form.contains("rows=\"12\""));
|
assert!(form.contains("rows=\"12\""));
|
||||||
assert!(form.contains("cols=\"40\""));
|
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&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("<b>hacker</b>"));
|
||||||
|
assert!(html.contains("<script>alert('xss')</script>"));
|
||||||
|
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'"),
|
||||||
|
"<b>test</b> & "quotes" 'apos'"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
src/web.rs
33
src/web.rs
|
|
@ -68,7 +68,11 @@ async fn submit(
|
||||||
// Validation
|
// Validation
|
||||||
let name = form.name.trim().to_string();
|
let name = form.name.trim().to_string();
|
||||||
let message = form.message.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() {
|
if name.is_empty() || message.is_empty() {
|
||||||
return Html("Name and message are required.".to_string());
|
return Html("Name and message are required.".to_string());
|
||||||
|
|
@ -137,6 +141,8 @@ mod tests {
|
||||||
max_message_length: 1000,
|
max_message_length: 1000,
|
||||||
max_website_length: 100,
|
max_website_length: 100,
|
||||||
open_registration: true,
|
open_registration: true,
|
||||||
|
enable_website_field: true,
|
||||||
|
allow_html_injection: true,
|
||||||
template: None,
|
template: None,
|
||||||
separator: "---".into(),
|
separator: "---".into(),
|
||||||
style: String::new(),
|
style: String::new(),
|
||||||
|
|
@ -302,4 +308,29 @@ mod tests {
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(count, 1);
|
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\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue