From dfe902de4bcbb6ff2a70c66566e0f5d386222fac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 26 Feb 2022 05:29:35 -0500 Subject: [PATCH] Begin work on "rule reorder", plus tests for unsafe query --- Cargo.lock | 2 + crates/lib/database_queries/Cargo.toml | 4 + .../queries/twitch/twitch_event_rules/mod.rs | 1 + .../reorder_twitch_event_rules.rs | 98 ++++++++++ .../endpoints/twitch/event_rules/mod.rs | 1 + .../event_rules/reorder_twitch_event_rules.rs | 178 ++++++++++++++++++ 6 files changed, 284 insertions(+) create mode 100644 crates/lib/database_queries/src/queries/twitch/twitch_event_rules/reorder_twitch_event_rules.rs create mode 100644 crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/reorder_twitch_event_rules.rs diff --git a/Cargo.lock b/Cargo.lock index 23c484d7ad..6b549d6abb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1254,6 +1254,8 @@ dependencies = [ "container_common", "dotenv", "log", + "once_cell", + "regex", "serde", "serde_derive", "serde_json", diff --git a/crates/lib/database_queries/Cargo.toml b/crates/lib/database_queries/Cargo.toml index b15ef8bd7f..ab092d51d2 100644 --- a/crates/lib/database_queries/Cargo.toml +++ b/crates/lib/database_queries/Cargo.toml @@ -25,6 +25,10 @@ chrono = { version = "0.4.19", features = ["serde"] } dotenv = "0.15.0" log = "0.4.14" +# Needed to safety check some queries we can't use escaping for +once_cell = "1.9.0" +regex = "1.5.4" + # Serialization serde = "1.0.125" serde_derive = "1.0.125" diff --git a/crates/lib/database_queries/src/queries/twitch/twitch_event_rules/mod.rs b/crates/lib/database_queries/src/queries/twitch/twitch_event_rules/mod.rs index 287e5bc6ef..b0db364aac 100644 --- a/crates/lib/database_queries/src/queries/twitch/twitch_event_rules/mod.rs +++ b/crates/lib/database_queries/src/queries/twitch/twitch_event_rules/mod.rs @@ -2,4 +2,5 @@ pub mod delete_twitch_event_rule; pub mod get_twitch_event_rule_for_user; pub mod insert_twitch_event_rule_builder; pub mod list_twitch_event_rules_for_user; +pub mod reorder_twitch_event_rules; pub mod update_twitch_event_rule_builder; \ No newline at end of file diff --git a/crates/lib/database_queries/src/queries/twitch/twitch_event_rules/reorder_twitch_event_rules.rs b/crates/lib/database_queries/src/queries/twitch/twitch_event_rules/reorder_twitch_event_rules.rs new file mode 100644 index 0000000000..3f62e126f1 --- /dev/null +++ b/crates/lib/database_queries/src/queries/twitch/twitch_event_rules/reorder_twitch_event_rules.rs @@ -0,0 +1,98 @@ +use anyhow::anyhow; +use container_common::anyhow_result::AnyhowResult; +use once_cell::sync::Lazy; +use regex::Regex; +use sqlx::MySqlPool; +use std::collections::HashMap; + +/// Check that tokens are alphanumeric or can have colons. +static SAFE_TOKENS_REGEX : Lazy = Lazy::new(|| { + Regex::new("^[a-zA-Z0-9:]+$").unwrap() +}); + +/// Check that tokens are alphanumeric or can have colons. +#[must_use] +fn check_token_safety(token: &str) -> AnyhowResult<()> { + if SAFE_TOKENS_REGEX.is_match(token) { + Ok(()) + } else { + Err(anyhow!("Token contains invalid characters")) + } +} + +pub async fn reorder_twitch_event_rules( + rule_token_to_order_map: HashMap, + user_token: &str, + ip_address_update: &str, + mysql_pool: &MySqlPool +) -> AnyhowResult { + + // THIS IS NOT SANITIZED, so we check for injection here. + for (rule_token, _position) in rule_token_to_order_map.iter() { + check_token_safety(rule_token.as_str())?; + } + + // THIS IS NOT SANITIZED, so we check for injection here. + check_token_safety(user_token)?; + + let query_inner = rule_token_to_order_map.iter() + .map(|(token, position)| { + format!("(\"{}\", {}, \"{}\")", token.as_str(), position, ip_address_update) + }) + .collect::>() + .join(", "); + + // Upsert to accomplish multiple updates at once. + // tl;dr https://stackoverflow.com/a/34866431 + // ALSO THIS IS NOT SANITIZED. + let query = format!(r#" +INSERT INTO twitch_event_rules (token, user_specified_rule_order, ip_address_update) +VALUES + {} +ON DUPLICATE KEY UPDATE + token=VALUES(token), + user_specified_rule_order=VALUES(user_specified_rule_order), + ip_address_update=VALUES(ip_address_update) +WHERE + user_token = {} + "#, query_inner, user_token); + + // NB: This, unfortunately, cannot be statically checked. + let mut query = sqlx::query(&query); + + let result = query.execute(mysql_pool).await; + + match result { + Err(err) => Err(anyhow!("error with query: {:?}", err)), + Ok(_r) => Ok(true), + } +} + +#[cfg(test)] +mod tests { + use crate::queries::twitch::twitch_event_rules::reorder_twitch_event_rules::check_token_safety; + + #[test] + fn safe_tokens() { + assert!(check_token_safety("foo").is_ok()); + assert!(check_token_safety("FOO").is_ok()); + assert!(check_token_safety("12345").is_ok()); + assert!(check_token_safety("abcdefABCDEF012345679").is_ok()); + assert!(check_token_safety("PREFIX:TOKEN1234").is_ok()); + } + + #[test] + fn unsafe_tokens() { + assert!(check_token_safety("").is_err()); + assert!(check_token_safety(",").is_err()); + assert!(check_token_safety("ABCD,").is_err()); + assert!(check_token_safety("foo;").is_err()); + assert!(check_token_safety("foo;foo").is_err()); + assert!(check_token_safety("foo;foo").is_err()); + assert!(check_token_safety("\"foo").is_err()); + assert!(check_token_safety("foo\"").is_err()); + assert!(check_token_safety("foo'").is_err()); + assert!(check_token_safety("foo'bar").is_err()); + assert!(check_token_safety("foo`foo").is_err()); + } +} diff --git a/crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/mod.rs b/crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/mod.rs index 55446634a8..56d071b288 100644 --- a/crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/mod.rs +++ b/crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/mod.rs @@ -3,4 +3,5 @@ pub mod delete_event_rule; pub mod edit_event_rule; pub mod get_event_rule; pub mod list_event_rules_for_user; +pub mod reorder_twitch_event_rules; pub mod validations; \ No newline at end of file diff --git a/crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/reorder_twitch_event_rules.rs b/crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/reorder_twitch_event_rules.rs new file mode 100644 index 0000000000..cbfe0b9128 --- /dev/null +++ b/crates/service/storyteller_web/src/http_server/endpoints/twitch/event_rules/reorder_twitch_event_rules.rs @@ -0,0 +1,178 @@ +use actix_http::Error; +use actix_http::http::header; +use actix_web::HttpResponseBuilder; +use actix_web::cookie::Cookie; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::{Path, Json}; +use actix_web::{Responder, web, HttpResponse, error, HttpRequest}; +use crate::http_server::web_utils::ip_address::get_request_ip; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::response_success_helpers::simple_json_success; +use crate::server_state::ServerState; +use database_queries::complex_models::event_match_predicate::EventMatchPredicate; +use database_queries::complex_models::event_responses::EventResponse; +use database_queries::queries::twitch::twitch_event_rules::get_twitch_event_rule_for_user::get_twitch_event_rule_for_user; +use database_queries::queries::twitch::twitch_event_rules::update_twitch_event_rule_builder::UpdateTwitchEventRuleBuilder; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use log::{info, warn, log, error}; +use sqlx::MySqlPool; +use sqlx::error::DatabaseError; +use sqlx::error::Error::Database; +use sqlx::mysql::MySqlDatabaseError; +use std::fmt; +use std::sync::Arc; +use crate::http_server::endpoints::twitch::event_rules::validations::validate_event_response::validate_event_response; +use crate::http_server::endpoints::twitch::event_rules::validations::validate_event_match_predicate::validate_event_match_predicate; + +// =============== Request =============== + +#[derive(Deserialize)] +pub struct EditTwitchEventRulePathInfo { + token: String, +} + +// These are not sparse updates! +#[derive(Deserialize)] +pub struct EditTwitchEventRuleRequest { + pub event_match_predicate: Option, + pub event_response: Option, + pub rule_is_disabled: bool, +} + +// =============== Success Response =============== + +#[derive(Serialize)] +pub struct EditTwitchEventRuleResponse { + pub success: bool, +} + +// =============== Error Response =============== + +#[derive(Debug, Serialize)] +pub enum EditTwitchEventRuleError { + BadInput(String), + NotFound, + NotAuthorized, + ServerError, +} + +impl ResponseError for EditTwitchEventRuleError { + fn status_code(&self) -> StatusCode { + match *self { + EditTwitchEventRuleError::BadInput(_) => StatusCode::BAD_REQUEST, + EditTwitchEventRuleError::NotFound => StatusCode::NOT_FOUND, + EditTwitchEventRuleError::NotAuthorized => StatusCode::UNAUTHORIZED, + EditTwitchEventRuleError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for EditTwitchEventRuleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +// =============== Handler =============== + +pub async fn edit_twitch_event_rule_handler( + http_request: HttpRequest, + path: Path, + request: web::Json, + server_state: web::Data>) -> Result +{ + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session(&http_request, &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + EditTwitchEventRuleError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(EditTwitchEventRuleError::NotAuthorized); + } + }; + + if user_session.is_banned { + warn!("banned users cannot edit"); + return Err(EditTwitchEventRuleError::NotAuthorized); + } + + let twitch_event_rule = get_twitch_event_rule_for_user( + &path.token, &user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|e| { + error!("Error with query: {:?}", e); + return EditTwitchEventRuleError::ServerError; + })?; + + if twitch_event_rule.is_none() { + info!("event rule not found"); + return Err(EditTwitchEventRuleError::NotFound); + } + + let creator_ip_address = get_request_ip(&http_request); + + let event_match_predicate = request.event_match_predicate + .clone() + .unwrap_or(EventMatchPredicate::NotSet {}); + + validate_event_match_predicate(&event_match_predicate) + .map_err(|reason| EditTwitchEventRuleError::BadInput(reason))?; + + let mut event_match_predicate = serde_json::to_string(&event_match_predicate) + .map_err(|e| { + return EditTwitchEventRuleError::BadInput( + "improper EventMatchPredicate".to_string()); + })?; + + let event_response = request.event_response + .clone() + .unwrap_or(EventResponse::NotSet {}); + + validate_event_response(&event_response) + .map_err(|reason| EditTwitchEventRuleError::BadInput(reason))?; + + let mut event_response = serde_json::to_string(&event_response) + .map_err(|e| { + return EditTwitchEventRuleError::BadInput( + "improper EventResponse".to_string()); + })?; + + let update_builder = UpdateTwitchEventRuleBuilder { + token: path.token.clone(), + event_match_predicate, + event_response, + rule_is_disabled: request.rule_is_disabled, + ip_address_update: creator_ip_address, + }; + + let _r = update_builder.update(&server_state.mysql_pool) + .await + .map_err(|e| { + error!("Error with query: {:?}", e); + return EditTwitchEventRuleError::ServerError; + }); + + let response = EditTwitchEventRuleResponse { + success: true, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| EditTwitchEventRuleError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +}