feat: reimplements the captcha and related options

This commit is contained in:
Lewis Wynne 2026-04-09 19:11:45 +01:00
parent 726fe55eb8
commit 75f1644cc1
5 changed files with 242 additions and 30 deletions

View file

@ -25,16 +25,31 @@ BOOK_ENABLE_WEBSITE_LINKS=true
# Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. # Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped.
# Website URLs are always escaped regardless of this setting. # Website URLs are always escaped regardless of this setting.
BOOK_ENABLE_HTML_INJECTION=true BOOK_ENABLE_HTML_INJECTION=false
# Enable captcha on submission form.
BOOK_ENABLE_CAPTCHA=false
# Captcha question displayed as a label.
# BOOK_CAPTCHA_QUESTION=What is my name?
# Captcha answer to validate against.
# BOOK_CAPTCHA_ANSWER=lew
# Require exact match (true) or just "contains" (false).
BOOK_CAPTCHA_EXACT=false
# Require case-sensitive match.
BOOK_CAPTCHA_CASESENSITIVE=false
# Maximum length for names. 0 for unlimited. # Maximum length for names. 0 for unlimited.
BOOK_MAX_NAME_LENGTH=50 BOOK_MAX_NAME_LENGTH=0
# Maximum length for messages. 0 for unlimited. # Maximum length for messages. 0 for unlimited.
BOOK_MAX_MESSAGE_LENGTH=1000 BOOK_MAX_MESSAGE_LENGTH=0
# Maximum length for website URLs. 0 for unlimited. # Maximum length for website URLs. 0 for unlimited.
BOOK_MAX_WEBSITE_LENGTH=100 BOOK_MAX_WEBSITE_LENGTH=0
# Separator between guestbook entries. # Separator between guestbook entries.
BOOK_SEPARATOR=------------------------------------------------------------ BOOK_SEPARATOR=------------------------------------------------------------
@ -49,7 +64,7 @@ BOOK_SEPARATOR=------------------------------------------------------------
# BOOK_STYLE= # BOOK_STYLE=
# Text shown above the form. # Text shown above the form.
BOOK_FORM_PROMPT=If you visited my site, please sign my guestbook! BOOK_FORM_PROMPT=Thanks for visiting. Sign the guestbook!
# Submit button text. # Submit button text.
BOOK_BUTTON_TEXT=sign BOOK_BUTTON_TEXT=sign

View file

@ -60,7 +60,7 @@ in
enableHtmlInjection = mkOption { enableHtmlInjection = mkOption {
type = types.bool; type = types.bool;
default = true; default = false;
description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped."; description = "Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. Website URLs are always escaped.";
}; };
@ -75,6 +75,34 @@ in
default = true; default = true;
description = "Enable honeypot field for spam prevention."; description = "Enable honeypot field for spam prevention.";
}; };
captcha = {
enable = mkEnableOption "captcha on submission form";
question = mkOption {
type = types.str;
default = "";
description = "Captcha question displayed as a label.";
};
answer = mkOption {
type = types.str;
default = "";
description = "Captcha answer to validate against.";
};
exact = mkOption {
type = types.bool;
default = false;
description = "Require exact match. When false, the answer just needs to be contained in the response.";
};
caseSensitive = mkOption {
type = types.bool;
default = false;
description = "Require case-sensitive match.";
};
};
}; };
telegram = { telegram = {
@ -92,19 +120,19 @@ in
limits = { limits = {
name = mkOption { name = mkOption {
type = types.int; type = types.int;
default = 50; default = 0;
description = "Maximum length for names. 0 for unlimited."; description = "Maximum length for names. 0 for unlimited.";
}; };
message = mkOption { message = mkOption {
type = types.int; type = types.int;
default = 1000; default = 0;
description = "Maximum length for messages. 0 for unlimited."; description = "Maximum length for messages. 0 for unlimited.";
}; };
website = mkOption { website = mkOption {
type = types.int; type = types.int;
default = 100; default = 0;
description = "Maximum length for website URLs. 0 for unlimited."; description = "Maximum length for website URLs. 0 for unlimited.";
}; };
}; };
@ -136,7 +164,7 @@ in
greeting = mkOption { greeting = mkOption {
type = types.str; type = types.str;
default = "If you visited my site, please sign my guestbook!"; default = "Thanks for visiting. Sign the guestbook!";
description = "Text shown above the form."; description = "Text shown above the form.";
}; };
@ -199,6 +227,11 @@ in
BOOK_ENABLE_SUBMISSIONS = if cfg.security.enableSubmissions then "true" else "false"; BOOK_ENABLE_SUBMISSIONS = if cfg.security.enableSubmissions then "true" else "false";
BOOK_ENABLE_HTML_INJECTION = if cfg.security.enableHtmlInjection then "true" else "false"; BOOK_ENABLE_HTML_INJECTION = if cfg.security.enableHtmlInjection then "true" else "false";
BOOK_ENABLE_WEBSITE_LINKS = if cfg.security.enableWebsiteLinks then "true" else "false"; BOOK_ENABLE_WEBSITE_LINKS = if cfg.security.enableWebsiteLinks then "true" else "false";
BOOK_ENABLE_CAPTCHA = if cfg.security.captcha.enable then "true" else "false";
BOOK_CAPTCHA_QUESTION = cfg.security.captcha.question;
BOOK_CAPTCHA_ANSWER = cfg.security.captcha.answer;
BOOK_CAPTCHA_EXACT = if cfg.security.captcha.exact then "true" else "false";
BOOK_CAPTCHA_CASESENSITIVE = if cfg.security.captcha.caseSensitive then "true" else "false";
BOOK_MAX_NAME_LENGTH = toString cfg.limits.name; BOOK_MAX_NAME_LENGTH = toString cfg.limits.name;
BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message; BOOK_MAX_MESSAGE_LENGTH = toString cfg.limits.message;
BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website; BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website;

View file

@ -16,6 +16,11 @@ pub struct Config {
pub enable_submissions: bool, pub enable_submissions: bool,
pub enable_website_links: bool, pub enable_website_links: bool,
pub enable_html_injection: bool, pub enable_html_injection: bool,
pub enable_captcha: bool,
pub captcha_question: String,
pub captcha_answer: String,
pub captcha_exact: bool,
pub captcha_casesensitive: bool,
pub template: Option<String>, pub template: Option<String>,
pub separator: String, pub separator: String,
pub style: String, pub style: String,
@ -54,15 +59,15 @@ impl Config {
.map(|v| v != "false") .map(|v| v != "false")
.unwrap_or(true), .unwrap_or(true),
max_name_length: env::var("BOOK_MAX_NAME_LENGTH") max_name_length: env::var("BOOK_MAX_NAME_LENGTH")
.unwrap_or_else(|_| "50".into()) .unwrap_or_else(|_| "0".into())
.parse() .parse()
.map_err(|_| "BOOK_MAX_NAME_LENGTH must be a number")?, .map_err(|_| "BOOK_MAX_NAME_LENGTH must be a number")?,
max_message_length: env::var("BOOK_MAX_MESSAGE_LENGTH") max_message_length: env::var("BOOK_MAX_MESSAGE_LENGTH")
.unwrap_or_else(|_| "1000".into()) .unwrap_or_else(|_| "0".into())
.parse() .parse()
.map_err(|_| "BOOK_MAX_MESSAGE_LENGTH must be a number")?, .map_err(|_| "BOOK_MAX_MESSAGE_LENGTH must be a number")?,
max_website_length: env::var("BOOK_MAX_WEBSITE_LENGTH") max_website_length: env::var("BOOK_MAX_WEBSITE_LENGTH")
.unwrap_or_else(|_| "100".into()) .unwrap_or_else(|_| "0".into())
.parse() .parse()
.map_err(|_| "BOOK_MAX_WEBSITE_LENGTH must be a number")?, .map_err(|_| "BOOK_MAX_WEBSITE_LENGTH must be a number")?,
enable_submissions: env::var("BOOK_ENABLE_SUBMISSIONS") enable_submissions: env::var("BOOK_ENABLE_SUBMISSIONS")
@ -73,7 +78,20 @@ impl Config {
.unwrap_or(true), .unwrap_or(true),
enable_html_injection: env::var("BOOK_ENABLE_HTML_INJECTION") enable_html_injection: env::var("BOOK_ENABLE_HTML_INJECTION")
.map(|v| v != "false") .map(|v| v != "false")
.unwrap_or(true), .unwrap_or(false),
enable_captcha: env::var("BOOK_ENABLE_CAPTCHA")
.map(|v| v != "false")
.unwrap_or(false),
captcha_question: env::var("BOOK_CAPTCHA_QUESTION")
.unwrap_or_default(),
captcha_answer: env::var("BOOK_CAPTCHA_ANSWER")
.unwrap_or_default(),
captcha_exact: env::var("BOOK_CAPTCHA_EXACT")
.map(|v| v != "false")
.unwrap_or(false),
captcha_casesensitive: env::var("BOOK_CAPTCHA_CASESENSITIVE")
.map(|v| v != "false")
.unwrap_or(false),
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| {
@ -89,7 +107,7 @@ impl Config {
.or_else(|| env::var("BOOK_STYLE").ok()) .or_else(|| env::var("BOOK_STYLE").ok())
.unwrap_or_default(), .unwrap_or_default(),
form_prompt: env::var("BOOK_FORM_PROMPT") form_prompt: env::var("BOOK_FORM_PROMPT")
.unwrap_or_else(|_| "If you visited my site, please sign my guestbook!".into()), .unwrap_or_else(|_| "Thanks for visiting. Sign the guestbook!".into()),
button_text: env::var("BOOK_BUTTON_TEXT") button_text: env::var("BOOK_BUTTON_TEXT")
.unwrap_or_else(|_| "sign".into()), .unwrap_or_else(|_| "sign".into()),
label_name: env::var("BOOK_LABEL_NAME") label_name: env::var("BOOK_LABEL_NAME")
@ -203,21 +221,21 @@ mod tests {
env::remove_var("BOOK_ENABLE_HTML_INJECTION"); env::remove_var("BOOK_ENABLE_HTML_INJECTION");
let config = Config::from_env().unwrap(); let config = Config::from_env().unwrap();
assert!(config.enable_html_injection); assert!(!config.enable_html_injection);
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID"); env::remove_var("BOOK_TELEGRAM_CHAT_ID");
} }
#[test] #[test]
fn test_enable_html_injection_false() { fn test_enable_html_injection_true() {
let _lock = ENV_LOCK.lock().unwrap(); let _lock = ENV_LOCK.lock().unwrap();
env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC"); env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345"); env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
env::set_var("BOOK_ENABLE_HTML_INJECTION", "false"); env::set_var("BOOK_ENABLE_HTML_INJECTION", "true");
let config = Config::from_env().unwrap(); let config = Config::from_env().unwrap();
assert!(!config.enable_html_injection); assert!(config.enable_html_injection);
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN"); env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID"); env::remove_var("BOOK_TELEGRAM_CHAT_ID");

View file

@ -29,6 +29,15 @@ pub fn render_form(config: &Config) -> String {
String::new() String::new()
}; };
let captcha_section = if config.enable_captcha {
format!(
"\n<label class=\"guestbook-label\">{}</label>\n<input class=\"guestbook-input\" name=\"captcha\" required>\n",
config.captcha_question
)
} 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">
@ -37,6 +46,7 @@ pub fn render_form(config: &Config) -> String {
{website_section} {website_section}
<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>
{captcha_section}
<input name="url" style="display:none" tabindex="-1" autocomplete="off"> <input name="url" style="display:none" tabindex="-1" autocomplete="off">
<button class="guestbook-button" type="submit">{button}</button> <button class="guestbook-button" type="submit">{button}</button>
</form>"#, </form>"#,
@ -46,6 +56,7 @@ pub fn render_form(config: &Config) -> String {
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,
captcha_section = captcha_section,
button = config.button_text, button = config.button_text,
) )
} }
@ -110,16 +121,21 @@ mod tests {
telegram_bot_token: "fake".into(), telegram_bot_token: "fake".into(),
telegram_chat_id: 0, telegram_chat_id: 0,
enable_honeypot: true, enable_honeypot: true,
max_name_length: 50, max_name_length: 0,
max_message_length: 1000, max_message_length: 0,
max_website_length: 100, max_website_length: 0,
enable_submissions: true, enable_submissions: true,
enable_website_links: true, enable_website_links: true,
enable_html_injection: true, enable_html_injection: false,
enable_captcha: false,
captcha_question: String::new(),
captcha_answer: String::new(),
captcha_exact: false,
captcha_casesensitive: false,
template: None, template: None,
separator: "---".into(), separator: "---".into(),
style: String::new(), style: String::new(),
form_prompt: "Sign my guestbook!".into(), form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
button_text: "sign".into(), button_text: "sign".into(),
label_name: "Your name:".into(), label_name: "Your name:".into(),
label_website: "Your website (optional):".into(), label_website: "Your website (optional):".into(),
@ -186,7 +202,8 @@ mod tests {
#[test] #[test]
fn test_render_preserves_html_in_body() { fn test_render_preserves_html_in_body() {
let config = test_config(); let mut config = test_config();
config.enable_html_injection = true;
let entry = make_entry("carol", "2026-04-09", "<b>Bold</b>"); let entry = make_entry("carol", "2026-04-09", "<b>Bold</b>");
let form = render_form(&config); let form = render_form(&config);
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);

View file

@ -25,6 +25,8 @@ pub struct SubmitForm {
message: String, message: String,
#[serde(default)] #[serde(default)]
url: String, // honeypot url: String, // honeypot
#[serde(default)]
captcha: String,
} }
pub fn router(state: Arc<AppState>) -> Router { pub fn router(state: Arc<AppState>) -> Router {
@ -74,6 +76,30 @@ async fn submit(
String::new() String::new()
}; };
// Captcha check
if state.config.enable_captcha {
let input = form.captcha.trim();
let answer = &state.config.captcha_answer;
let ok = if state.config.captcha_casesensitive {
if state.config.captcha_exact {
input == answer
} else {
input.contains(answer.as_str())
}
} else {
let input_lower = input.to_lowercase();
let answer_lower = answer.to_lowercase();
if state.config.captcha_exact {
input_lower == answer_lower
} else {
input_lower.contains(&answer_lower)
}
};
if !ok {
return Html("Wrong answer.".to_string());
}
}
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,16 +163,21 @@ mod tests {
telegram_bot_token: "fake".into(), telegram_bot_token: "fake".into(),
telegram_chat_id: 0, telegram_chat_id: 0,
enable_honeypot: true, enable_honeypot: true,
max_name_length: 50, max_name_length: 0,
max_message_length: 1000, max_message_length: 0,
max_website_length: 100, max_website_length: 0,
enable_submissions: true, enable_submissions: true,
enable_website_links: true, enable_website_links: true,
enable_html_injection: true, enable_html_injection: false,
enable_captcha: false,
captcha_question: String::new(),
captcha_answer: String::new(),
captcha_exact: false,
captcha_casesensitive: false,
template: None, template: None,
separator: "---".into(), separator: "---".into(),
style: String::new(), style: String::new(),
form_prompt: "Sign my guestbook!".into(), form_prompt: "Thanks for visiting. Sign the guestbook!".into(),
button_text: "sign".into(), button_text: "sign".into(),
label_name: "Your name:".into(), label_name: "Your name:".into(),
label_website: "Your website (optional):".into(), label_website: "Your website (optional):".into(),
@ -333,4 +364,102 @@ mod tests {
let html = get_index(&app).await; let html = get_index(&app).await;
assert!(!html.contains("name=\"website\"")); assert!(!html.contains("name=\"website\""));
} }
#[tokio::test]
async fn test_captcha_rejects_wrong_answer() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_captcha = true;
config.captcha_question = "What is my name?".into();
config.captcha_answer = "lew".into();
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=wrong").await;
assert!(body.contains("Wrong answer"));
}
#[tokio::test]
async fn test_captcha_accepts_correct_answer() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_captcha = true;
config.captcha_question = "What is my name?".into();
config.captcha_answer = "lew".into();
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=lew").await;
assert!(body.contains("pending approval"));
}
#[tokio::test]
async fn test_captcha_inexact_contains() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_captcha = true;
config.captcha_exact = false;
config.captcha_question = "What is my name?".into();
config.captcha_answer = "lew".into();
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=lewis").await;
assert!(body.contains("pending approval"));
}
#[tokio::test]
async fn test_captcha_inexact_rejects_no_match() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_captcha = true;
config.captcha_exact = false;
config.captcha_question = "What is my name?".into();
config.captcha_answer = "lew".into();
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=bob").await;
assert!(body.contains("Wrong answer"));
}
#[tokio::test]
async fn test_captcha_casesensitive() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_captcha = true;
config.captcha_question = "What is my name?".into();
config.captcha_answer = "lew".into();
config.captcha_casesensitive = true;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=Lew").await;
assert!(body.contains("Wrong answer"));
}
#[tokio::test]
async fn test_captcha_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_captcha = true;
config.captcha_question = "What is my name?".into();
config.captcha_answer = "lew".into();
config.captcha_casesensitive = false;
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello&captcha=LEW").await;
assert!(body.contains("pending approval"));
}
#[tokio::test]
async fn test_captcha_disabled_skips_check() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let (app, _rx) = test_app(config);
let (_, body) = post_form(&app, "name=alice&message=hello").await;
assert!(body.contains("pending approval"));
}
#[tokio::test]
async fn test_captcha_shows_in_form() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.enable_captcha = true;
config.captcha_question = "What is 2+2?".into();
config.captcha_answer = "4".into();
let (app, _rx) = test_app(config);
let html = get_index(&app).await;
assert!(html.contains("What is 2+2?"));
assert!(html.contains("name=\"captcha\""));
}
} }