From 75f1644cc1d9e4cdf7c96e06346f513f1aa4eb56 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 19:11:45 +0100 Subject: [PATCH] feat: reimplements the captcha and related options --- .env.example | 25 +++++++-- module.nix | 43 ++++++++++++++-- src/config.rs | 36 +++++++++---- src/render.rs | 29 ++++++++--- src/web.rs | 139 ++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 242 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index b7065d8..4fda217 100644 --- a/.env.example +++ b/.env.example @@ -25,16 +25,31 @@ BOOK_ENABLE_WEBSITE_LINKS=true # Allow raw HTML/JS in entry names and message bodies. When false, HTML is escaped. # 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. -BOOK_MAX_NAME_LENGTH=50 +BOOK_MAX_NAME_LENGTH=0 # 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. -BOOK_MAX_WEBSITE_LENGTH=100 +BOOK_MAX_WEBSITE_LENGTH=0 # Separator between guestbook entries. BOOK_SEPARATOR=------------------------------------------------------------ @@ -49,7 +64,7 @@ BOOK_SEPARATOR=------------------------------------------------------------ # BOOK_STYLE= # 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. BOOK_BUTTON_TEXT=sign diff --git a/module.nix b/module.nix index 498c6f3..9a4a241 100644 --- a/module.nix +++ b/module.nix @@ -60,7 +60,7 @@ in enableHtmlInjection = mkOption { 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."; }; @@ -75,6 +75,34 @@ in default = true; 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 = { @@ -92,19 +120,19 @@ in limits = { name = mkOption { type = types.int; - default = 50; + default = 0; description = "Maximum length for names. 0 for unlimited."; }; message = mkOption { type = types.int; - default = 1000; + default = 0; description = "Maximum length for messages. 0 for unlimited."; }; website = mkOption { type = types.int; - default = 100; + default = 0; description = "Maximum length for website URLs. 0 for unlimited."; }; }; @@ -136,7 +164,7 @@ in greeting = mkOption { 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."; }; @@ -199,6 +227,11 @@ in 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_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_MESSAGE_LENGTH = toString cfg.limits.message; BOOK_MAX_WEBSITE_LENGTH = toString cfg.limits.website; diff --git a/src/config.rs b/src/config.rs index 308c15f..f5872c3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,11 @@ pub struct Config { pub enable_submissions: bool, pub enable_website_links: 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, pub separator: String, pub style: String, @@ -54,15 +59,15 @@ impl Config { .map(|v| v != "false") .unwrap_or(true), max_name_length: env::var("BOOK_MAX_NAME_LENGTH") - .unwrap_or_else(|_| "50".into()) + .unwrap_or_else(|_| "0".into()) .parse() .map_err(|_| "BOOK_MAX_NAME_LENGTH must be a number")?, max_message_length: env::var("BOOK_MAX_MESSAGE_LENGTH") - .unwrap_or_else(|_| "1000".into()) + .unwrap_or_else(|_| "0".into()) .parse() .map_err(|_| "BOOK_MAX_MESSAGE_LENGTH must be a number")?, max_website_length: env::var("BOOK_MAX_WEBSITE_LENGTH") - .unwrap_or_else(|_| "100".into()) + .unwrap_or_else(|_| "0".into()) .parse() .map_err(|_| "BOOK_MAX_WEBSITE_LENGTH must be a number")?, enable_submissions: env::var("BOOK_ENABLE_SUBMISSIONS") @@ -73,7 +78,20 @@ impl Config { .unwrap_or(true), enable_html_injection: env::var("BOOK_ENABLE_HTML_INJECTION") .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") .unwrap_or_else(|_| "------------------------------------------------------------".into()), template: env::var("BOOK_TEMPLATE").ok().map(|path| { @@ -89,7 +107,7 @@ impl Config { .or_else(|| env::var("BOOK_STYLE").ok()) .unwrap_or_default(), 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") .unwrap_or_else(|_| "sign".into()), label_name: env::var("BOOK_LABEL_NAME") @@ -203,21 +221,21 @@ mod tests { env::remove_var("BOOK_ENABLE_HTML_INJECTION"); 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_CHAT_ID"); } #[test] - fn test_enable_html_injection_false() { + fn test_enable_html_injection_true() { 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_HTML_INJECTION", "false"); + env::set_var("BOOK_ENABLE_HTML_INJECTION", "true"); 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_CHAT_ID"); diff --git a/src/render.rs b/src/render.rs index eb04c3e..e535424 100644 --- a/src/render.rs +++ b/src/render.rs @@ -29,6 +29,15 @@ pub fn render_form(config: &Config) -> String { String::new() }; + let captcha_section = if config.enable_captcha { + format!( + "\n\n\n", + config.captcha_question + ) + } else { + String::new() + }; + format!( r#"{prompt}
@@ -37,6 +46,7 @@ pub fn render_form(config: &Config) -> String { {website_section} +{captcha_section}
"#, @@ -46,6 +56,7 @@ pub fn render_form(config: &Config) -> String { label_message = config.label_message, rows = config.textarea_rows, cols = config.textarea_cols, + captcha_section = captcha_section, button = config.button_text, ) } @@ -110,16 +121,21 @@ mod tests { telegram_bot_token: "fake".into(), telegram_chat_id: 0, enable_honeypot: true, - max_name_length: 50, - max_message_length: 1000, - max_website_length: 100, + max_name_length: 0, + max_message_length: 0, + max_website_length: 0, enable_submissions: 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, separator: "---".into(), style: String::new(), - form_prompt: "Sign my guestbook!".into(), + form_prompt: "Thanks for visiting. Sign the guestbook!".into(), button_text: "sign".into(), label_name: "Your name:".into(), label_website: "Your website (optional):".into(), @@ -186,7 +202,8 @@ mod tests { #[test] 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", "Bold"); let form = render_form(&config); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form); diff --git a/src/web.rs b/src/web.rs index 57cc9ee..820a011 100644 --- a/src/web.rs +++ b/src/web.rs @@ -25,6 +25,8 @@ pub struct SubmitForm { message: String, #[serde(default)] url: String, // honeypot + #[serde(default)] + captcha: String, } pub fn router(state: Arc) -> Router { @@ -74,6 +76,30 @@ async fn submit( 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() { return Html("Name and message are required.".to_string()); } @@ -137,16 +163,21 @@ mod tests { telegram_bot_token: "fake".into(), telegram_chat_id: 0, enable_honeypot: true, - max_name_length: 50, - max_message_length: 1000, - max_website_length: 100, + max_name_length: 0, + max_message_length: 0, + max_website_length: 0, enable_submissions: 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, separator: "---".into(), style: String::new(), - form_prompt: "Sign my guestbook!".into(), + form_prompt: "Thanks for visiting. Sign the guestbook!".into(), button_text: "sign".into(), label_name: "Your name:".into(), label_website: "Your website (optional):".into(), @@ -333,4 +364,102 @@ mod tests { let html = get_index(&app).await; 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\"")); + } }