From a7887b29b607e1f603099c6f8f6916c9a1d9cdcf Mon Sep 17 00:00:00 2001 From: 4cuck <40539855+4cuck@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:34:28 -0400 Subject: [PATCH] security: harden media and preference boundaries Default signing keys made media proxy URLs forgeable, and unauthenticated media fetches let arbitrary upstream hosts consume server-side network access. This commit makes those boundaries explicit, limits buffered upstream bodies, and keeps externally sourced text escaped at raw rendering sinks. --- config/teapot.example.toml | 33 +++++++------ src/api/http.rs | 44 +++++++++++++---- src/config.rs | 28 +++++++---- src/error.rs | 3 ++ src/routes/debug.rs | 44 +++++++++++++++-- src/routes/media.rs | 99 ++++++++++++++++++++++++++++++++------ src/routes/mod.rs | 8 ++- src/routes/preferences.rs | 55 +++++++++++++++------ src/transcode.rs | 4 +- src/types/prefs.rs | 24 +++++++++ src/utils/hmac.rs | 6 ++- src/views/notes.rs | 4 +- src/views/renderutils.rs | 45 +++++++++++++++-- 13 files changed, 318 insertions(+), 79 deletions(-) diff --git a/config/teapot.example.toml b/config/teapot.example.toml index 99a21c7..3416321 100644 --- a/config/teapot.example.toml +++ b/config/teapot.example.toml @@ -2,23 +2,24 @@ [server] # Domain for generating links - change this to your own domain/ip -hostname = "localhost" -title = "teapot" -address = "0.0.0.0" -port = 8080 -https = false # Set to true when using HTTPS +address = "0.0.0.0" +hostname = "localhost" httpMaxConnections = 100 -staticDir = "./public" +https = false # Set to true when using HTTPS +port = 8080 +staticDir = "./public" +title = "teapot" [cache] # Cache TTL settings (in-process cache, no external dependencies) listMinutes = 240 # How long to cache list info -rssMinutes = 10 # How long to cache RSS queries -maxEntries = 50000 # Cap on in-memory entries; over cap, soonest-expiring are evicted +maxEntries = 50000 # Cap on in-memory entries; over cap, soonest-expiring are evicted +rssMinutes = 10 # How long to cache RSS queries [config] -# Secret key for cryptographic signing of video URLs -hmacKey = "secretkey" +# Secret key for cryptographic signing of media URLs. +# Generate a unique value for every instance, e.g. with openssl rand -hex 32 +hmacKey = "" # Use base64 encoding for proxied media URLs base64Media = false @@ -26,11 +27,15 @@ base64Media = false # Enable RSS feeds enableRSS = true -# Enable debug endpoints (/.sessions) +# Enable debug endpoints (/.health, /.sessions) enableDebug = false +# Bearer token required for debug endpoints when enableDebug = true. +# Generate a unique value with at least 32 characters. +debugToken = "" + # HTTP/HTTPS proxy (SOCKS proxies not supported) -proxy = "" +proxy = "" proxyAuth = "" # API proxy host, e.g. localhost:7000 @@ -45,7 +50,7 @@ maxConcurrentReqs = 2 # Kagi Translate: server-side translation for non-English tweets # Set either kagiToken (inline) or kagiTokenFile (path to file) # Get your token from kagi.com/settings → Session Link -kagiToken = "" +kagiToken = "" kagiTokenFile = "" [gifTranscoding] @@ -64,8 +69,8 @@ externalDomain = "" theme = "teapot" # URL replacements for privacy +replaceReddit = "" replaceTwitter = "" replaceYouTube = "" -replaceReddit = "" infiniteScroll = false diff --git a/src/api/http.rs b/src/api/http.rs index 5c2740b..175619f 100644 --- a/src/api/http.rs +++ b/src/api/http.rs @@ -51,6 +51,8 @@ use crate::error::{ type Connector = hyper_rustls::HttpsConnector; +const DEFAULT_BODY_LIMIT: usize = 32 * 1024 * 1024; // 32 MiB + /// Parsed proxy configuration. #[derive(Clone)] struct ProxyConfig { @@ -388,29 +390,53 @@ impl Response { &self.headers } + pub fn into_body(self) -> hyper_body::Incoming { + self.body + } + /// Collect the response body as bytes, decompressing gzip if needed. pub async fn bytes(self) -> Result { + self.bytes_limited(DEFAULT_BODY_LIMIT).await + } + + /// Collect the response body as bytes, rejecting oversized bodies. + pub async fn bytes_limited(self, max_bytes: usize) -> Result { let is_gzip = self .headers .get(header::CONTENT_ENCODING) .and_then(|val| val.to_str().ok()) .is_some_and(|val| val.contains("gzip")); - let collected = self - .body - .collect() - .await - .map_err(|err| Error::Internal(format!("read body: {err}")))? - .to_bytes(); + let mut body = self.body; + let mut collected = Vec::new(); + while let Some(frame) = body.frame().await { + let frame = frame.map_err(|err| Error::Internal(format!("read body: {err}")))?; + let Ok(chunk) = frame.into_data() else { + continue; + }; + if collected.len().saturating_add(chunk.len()) > max_bytes { + return Err(Error::Internal(format!( + "response body exceeded {max_bytes} bytes" + ))); + } + collected.extend_from_slice(&chunk); + } if is_gzip { - let mut gz = GzDecoder::new(&*collected); + let gz = GzDecoder::new(&*collected); let mut decoded = Vec::new(); - gz.read_to_end(&mut decoded) + let mut limited = gz.take(max_bytes.saturating_add(1) as u64); + limited + .read_to_end(&mut decoded) .map_err(|err| Error::Internal(format!("gzip decode: {err}")))?; + if decoded.len() > max_bytes { + return Err(Error::Internal(format!( + "decoded response body exceeded {max_bytes} bytes" + ))); + } Ok(Bytes::from(decoded)) } else { - Ok(collected) + Ok(Bytes::from(collected)) } } diff --git a/src/config.rs b/src/config.rs index 8a4a6d1..9289570 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,7 +6,10 @@ use std::{ use serde::Deserialize; -use crate::error::Result; +use crate::error::{ + Error, + Result, +}; #[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -104,7 +107,7 @@ pub struct CacheConfig { reason = "mirrors TOML config booleans" )] pub struct AppConfig { - #[serde(default = "default_hmac_key", rename = "hmacKey")] + #[serde(rename = "hmacKey")] pub hmac_key: String, #[serde(default, rename = "base64Media")] pub base64_media: bool, @@ -112,6 +115,8 @@ pub struct AppConfig { pub enable_rss: bool, #[serde(default, rename = "enableDebug")] pub enable_debug: bool, + #[serde(default, rename = "debugToken")] + pub debug_token: String, #[serde(default)] pub proxy: String, #[serde(default, rename = "proxyAuth")] @@ -176,9 +181,6 @@ const fn default_rss_minutes() -> u64 { const fn default_max_entries() -> usize { 50_000 } -fn default_hmac_key() -> String { - "secretkey".to_owned() -} const fn default_max_concurrent_reqs() -> u32 { 2 } @@ -200,11 +202,17 @@ impl Config { pub fn load>(path: P) -> Result { let content = fs::read_to_string(path)?; let mut config = toml::from_str::(&content)?; - if config.config.hmac_key == "secretkey" { - tracing::warn!( - "Using default HMAC key — video proxy URLs are forgeable. Set config.hmacKey in your \ - config" - ); + let hmac_key = config.config.hmac_key.trim(); + if hmac_key.is_empty() || hmac_key == "secretkey" || hmac_key.len() < 32 { + return Err(Error::InvalidConfig( + "config.hmacKey must be a non-default secret with at least 32 characters".into(), + )); + } + if config.config.enable_debug && config.config.debug_token.len() < 32 { + return Err(Error::InvalidConfig( + "config.debugToken must be set to at least 32 characters when enableDebug is true" + .into(), + )); } // Validate GIF transcoding config match config.gif_transcoding.mode { diff --git a/src/error.rs b/src/error.rs index 62c5ed9..974a5c5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -29,6 +29,9 @@ pub enum Error { #[error("Configuration error: {0}")] Config(#[from] TomlError), + #[error("Configuration error: {0}")] + InvalidConfig(String), + #[error("IO error: {0}")] Io(#[from] io::Error), diff --git a/src/routes/debug.rs b/src/routes/debug.rs index 009a285..6057459 100644 --- a/src/routes/debug.rs +++ b/src/routes/debug.rs @@ -2,7 +2,11 @@ use axum::{ Json, Router, extract::State, - http::StatusCode, + http::{ + HeaderMap, + StatusCode, + header, + }, response::{ IntoResponse as _, Response, @@ -19,17 +23,47 @@ pub fn router() -> Router { } /// Health check endpoint returning session pool statistics. -async fn health_check(State(state): State) -> Response { - if !state.config.config.enable_debug { +async fn health_check(State(state): State, headers: HeaderMap) -> Response { + if !debug_authorized(&state, &headers) { return StatusCode::NOT_FOUND.into_response(); } Json(state.api.get_session_health().await).into_response() } /// Detailed sessions debug endpoint. -async fn sessions_debug(State(state): State) -> Response { - if !state.config.config.enable_debug { +async fn sessions_debug(State(state): State, headers: HeaderMap) -> Response { + if !debug_authorized(&state, &headers) { return StatusCode::NOT_FOUND.into_response(); } Json(state.api.get_session_debug().await).into_response() } + +fn debug_authorized(state: &AppState, headers: &HeaderMap) -> bool { + if !state.config.config.enable_debug || state.config.config.debug_token.is_empty() { + return false; + } + + let Some(auth) = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let Some(token) = auth.strip_prefix("Bearer ") else { + return false; + }; + + constant_time_eq(token.as_bytes(), state.config.config.debug_token.as_bytes()) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + + let mut diff = 0_u8; + for (&lhs, &rhs) in left.iter().zip(right) { + diff |= lhs ^ rhs; + } + diff == 0 +} diff --git a/src/routes/media.rs b/src/routes/media.rs index 3764846..8fdfb29 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -8,6 +8,7 @@ use axum::{ http::{ HeaderMap, StatusCode, + Uri, header, }, response::{ @@ -16,6 +17,7 @@ use axum::{ }, routing::get, }; +use http_body_util::Limited; use tokio::fs; use crate::{ @@ -30,6 +32,16 @@ use crate::{ }, }; +const MAX_IMAGE_BYTES: usize = 25 * 1024 * 1024; +const MAX_VIDEO_BYTES: usize = 1024 * 1024 * 1024; +const MAX_GIF_BYTES: u64 = 100 * 1024 * 1024; + +#[derive(Clone, Copy)] +enum MediaKind { + Image, + Video, +} + pub fn router() -> Router { Router::new() .route("/pic/{url}", get(pic_proxy)) @@ -124,17 +136,21 @@ async fn proxy_image(state: &AppState, url: &str, original: bool) -> Result Result Result { match transcoder.get_or_transcode(mp4_url).await { Ok(path) => { + let meta = fs::metadata(&path) + .await + .map_err(|err| Error::Internal(format!("stat cached GIF: {err}")))?; + if meta.len() > MAX_GIF_BYTES { + return Err(Error::Internal("cached GIF exceeds response limit".into())); + } let bytes = fs::read(&path) .await .map_err(|err| Error::Internal(format!("read cached GIF: {err}")))?; @@ -229,6 +251,8 @@ async fn serve_gif(state: &AppState, mp4_url: &str) -> Result { } async fn proxy_video(state: &AppState, url: &str, req_headers: &HeaderMap) -> Result { + validate_media_url(url, MediaKind::Video)?; + // Forward Range header to upstream for seeking support let mut upstream_headers = HeaderMap::new(); if let Some(range) = req_headers.get(header::RANGE) { @@ -248,6 +272,7 @@ async fn proxy_video(state: &AppState, url: &str, req_headers: &HeaderMap) -> Re } let resp_headers = response.headers(); + ensure_content_length_under(resp_headers, MAX_VIDEO_BYTES as u64)?; let content_type = resp_headers .get(header::CONTENT_TYPE) @@ -273,9 +298,53 @@ async fn proxy_video(state: &AppState, url: &str, req_headers: &HeaderMap) -> Re builder = builder.header(header::CONTENT_RANGE, cr); } - let bytes = response.bytes().await?; + let body = Body::new(Limited::new(response.into_body(), MAX_VIDEO_BYTES)); builder - .body(Body::from(bytes)) + .body(body) .map_err(|err| Error::Internal(format!("build video response: {err}"))) } + +fn validate_media_url(url: &str, kind: MediaKind) -> Result<()> { + let uri = url + .parse::() + .map_err(|err| Error::InvalidUrl(format!("invalid media URL: {err}")))?; + if uri.scheme_str() != Some("https") { + return Err(Error::InvalidUrl("media URL must use HTTPS".into())); + } + let host = uri + .host() + .ok_or_else(|| Error::InvalidUrl("media URL is missing host".into()))? + .to_ascii_lowercase(); + + let allowed = match kind { + MediaKind::Image => is_twimg_host(&host), + MediaKind::Video => host == "video.twimg.com", + }; + if allowed { + Ok(()) + } else { + Err(Error::InvalidUrl("media URL host is not allowed".into())) + } +} + +fn is_twimg_host(host: &str) -> bool { + host == "twimg.com" || host.ends_with(".twimg.com") +} + +fn ensure_content_length_under(headers: &HeaderMap, max_bytes: u64) -> Result<()> { + let Some(value) = headers.get(header::CONTENT_LENGTH) else { + return Ok(()); + }; + let length = value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| Error::InvalidUrl("invalid upstream Content-Length".into()))?; + if length > max_bytes { + return Err(Error::InvalidUrl( + "upstream media response is too large".into(), + )); + } + Ok(()) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a6e9430..e3c0c07 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -31,7 +31,10 @@ use axum::{ }; use axum_extra::extract::{ CookieJar, - cookie::Cookie, + cookie::{ + Cookie, + SameSite, + }, }; use maud::html; use time::Duration; @@ -110,7 +113,7 @@ pub async fn prefs_middleware(request: Request, next: Next) -> Response { if let Some(prefs_value) = prefs_param { // Parse prefs: "key=val,key2=val2" let mut jar = CookieJar::new(); - let pref_names = Prefs::COOKIE_NAMES; + let pref_names = Prefs::URL_PREF_NAMES; for pair in prefs_value.split(',') { let (key, value) = match pair.split_once('=') { @@ -124,6 +127,7 @@ pub async fn prefs_middleware(request: Request, next: Next) -> Response { .path("/") .max_age(Duration::days(365)) .http_only(true) + .same_site(SameSite::Lax) .build(); jar = jar.add(cookie); } diff --git a/src/routes/preferences.rs b/src/routes/preferences.rs index e0d6de0..399cbbd 100644 --- a/src/routes/preferences.rs +++ b/src/routes/preferences.rs @@ -28,6 +28,11 @@ use axum::{ use axum_extra::extract::cookie::{ Cookie, CookieJar, + SameSite, +}; +use percent_encoding::{ + NON_ALPHANUMERIC, + utf8_percent_encode, }; use serde::Deserialize; use time::Duration; @@ -44,11 +49,12 @@ use crate::{ }; /// Convert preferences to HTTP cookies for storage. -fn prefs_to_cookies(prefs: &Prefs) -> Vec> { +fn prefs_to_cookies(prefs: &Prefs, config: &Config) -> Vec> { let max_age = Duration::days(365); + let secure = config.server.https; let bool_cookie = - |name: &str, val: bool| make_cookie(name, if val { "on" } else { "" }, max_age); - let str_cookie = |name: &str, val: &str| make_cookie(name, val, max_age); + |name: &str, val: bool| make_cookie(name, if val { "on" } else { "" }, max_age, secure); + let str_cookie = |name: &str, val: &str| make_cookie(name, val, max_age, secure); vec![ str_cookie("theme", &prefs.theme), @@ -123,17 +129,19 @@ fn encode_prefs(prefs: &Prefs, config: &Config) -> String { enc_string!("replaceTwitter", replace_twitter); enc_string!("replaceYouTube", replace_youtube); enc_string!("replaceReddit", replace_reddit); - enc_string!("kagiToken", kagi_token); - pairs.join(",") } -fn make_cookie(name: &str, value: &str, max_age: Duration) -> Cookie<'static> { - Cookie::build((name.to_owned(), value.to_owned())) +fn make_cookie(name: &str, value: &str, max_age: Duration, secure: bool) -> Cookie<'static> { + let mut builder = Cookie::build((name.to_owned(), value.to_owned())) .path("/") .max_age(max_age) .http_only(true) - .build() + .same_site(SameSite::Lax); + if secure { + builder = builder.secure(true); + } + builder.build() } pub fn router() -> Router { @@ -236,10 +244,11 @@ async fn settings( let prefs = Prefs::from_cookies(&jar, &state.config); let themes = find_themes(&state.config.server.static_dir); - let referer = query.referer.as_deref().unwrap_or("/"); + let referer = local_redirect_target(query.referer.as_deref(), "/"); let prefs_code = encode_prefs(&prefs, &state.config); - let prefs_url = format!("{}/?prefs={}", state.config.url_prefix(), prefs_code); - let content = pref_view::render_preferences_form(&prefs, &themes, referer, &prefs_url); + let encoded_prefs = utf8_percent_encode(&prefs_code, NON_ALPHANUMERIC).to_string(); + let prefs_url = format!("{}/?prefs={encoded_prefs}", state.config.url_prefix()); + let content = pref_view::render_preferences_form(&prefs, &themes, &referer, &prefs_url); let markup = layout::PageLayout::new(&state.config, "Settings", content) .description("Customize your teapot experience") .prefs(&prefs) @@ -254,7 +263,7 @@ async fn save_prefs( Form(form): Form, ) -> Result { let prefs = form.to_prefs(&state.config); - let cookies = prefs_to_cookies(&prefs); + let cookies = prefs_to_cookies(&prefs, &state.config); // Add all cookies to the jar let mut updated_jar = jar; @@ -263,9 +272,9 @@ async fn save_prefs( } // Redirect back to referer or settings page - let redirect_to = form.referer.as_deref().unwrap_or("/settings"); + let redirect_to = local_redirect_target(form.referer.as_deref(), "/settings"); - Ok((updated_jar, Redirect::to(redirect_to)).into_response()) + Ok((updated_jar, Redirect::to(&redirect_to)).into_response()) } #[derive(Debug, Deserialize)] @@ -285,8 +294,8 @@ async fn reset_prefs(jar: CookieJar, Form(form): Form) -> Result } // Redirect to referer or settings page - let redirect_to = form.referer.as_deref().unwrap_or("/settings"); - Ok((updated_jar, Redirect::to(redirect_to)).into_response()) + let redirect_to = local_redirect_target(form.referer.as_deref(), "/settings"); + Ok((updated_jar, Redirect::to(&redirect_to)).into_response()) } async fn enable_mp4(jar: CookieJar, headers: HeaderMap) -> Result { @@ -300,11 +309,25 @@ async fn enable_mp4(jar: CookieJar, headers: HeaderMap) -> Result { let referer = headers .get(REFERER) .and_then(|hv| hv.to_str().ok()) + .filter(|value| is_local_path(value)) .unwrap_or("/settings"); Ok((updated_jar, Redirect::to(referer)).into_response()) } +fn local_redirect_target(value: Option<&str>, fallback: &str) -> String { + value + .filter(|target| is_local_path(target)) + .unwrap_or(fallback) + .to_owned() +} + +fn is_local_path(value: &str) -> bool { + value.starts_with('/') + && !value.starts_with("//") + && !value.chars().any(|ch| matches!(ch, '\r' | '\n')) +} + /// Discover available themes from CSS files in the static directory. #[expect( clippy::absolute_paths, diff --git a/src/transcode.rs b/src/transcode.rs index ffeb85f..ada291e 100644 --- a/src/transcode.rs +++ b/src/transcode.rs @@ -23,6 +23,8 @@ use crate::{ config::GifTranscodingConfig, }; +const MAX_TRANSCODE_INPUT_BYTES: usize = 100 * 1024 * 1024; + pub struct GifTranscoder { cache: GifCache, http_client: HttpClient, @@ -115,7 +117,7 @@ impl GifTranscoder { } let bytes = response - .bytes() + .bytes_limited(MAX_TRANSCODE_INPUT_BYTES) .await .map_err(|err| eyre::eyre!("Failed to read MP4 body: {err}"))?; fs::write(&input, &bytes).await?; diff --git a/src/types/prefs.rs b/src/types/prefs.rs index 3f0787f..670f650 100644 --- a/src/types/prefs.rs +++ b/src/types/prefs.rs @@ -125,4 +125,28 @@ impl Prefs { "replaceReddit", "kagiToken", ]; + + /// Preference names accepted from shareable URL preference imports. + pub const URL_PREF_NAMES: &[&str] = &[ + "theme", + "infiniteScroll", + "stickyProfile", + "bidiSupport", + "mp4Playback", + "autoplayGifs", + "muteVideos", + "hideTweetStats", + "hideBanner", + "hidePins", + "hideReplies", + "hideCommunityNotes", + "squareAvatars", + "useTwemoji", + "stickyNav", + "proxyMedia", + "fontSize", + "replaceTwitter", + "replaceYouTube", + "replaceReddit", + ]; } diff --git a/src/utils/hmac.rs b/src/utils/hmac.rs index cd2281f..062d320 100644 --- a/src/utils/hmac.rs +++ b/src/utils/hmac.rs @@ -1,12 +1,14 @@ use data_encoding::HEXLOWER; use ring::hmac; -/// Sign a URL using HMAC-SHA256, truncated to 13 hex chars. +const SIGNATURE_HEX_LEN: usize = 32; + +/// Sign a URL using HMAC-SHA256, truncated to 128 bits. pub fn sign(url: &str, key: &str) -> String { let key = hmac::Key::new(hmac::HMAC_SHA256, key.as_bytes()); let tag = hmac::sign(&key, url.as_bytes()); let full = HEXLOWER.encode(tag.as_ref()); - full[..13].to_string() + full[..SIGNATURE_HEX_LEN].to_owned() } /// Verify an HMAC signature. diff --git a/src/views/notes.rs b/src/views/notes.rs index e89f52e..1158792 100644 --- a/src/views/notes.rs +++ b/src/views/notes.rs @@ -320,7 +320,7 @@ fn render_styled_text(para: &ArticleParagraph, chars: &[char], start: usize, end .get(start..end) .map(|slice| slice.iter().collect()) .unwrap_or_default(); - return html! { (PreEscaped(text)) }; + return html! { (text) }; } let mut parts = Vec::new(); @@ -361,7 +361,7 @@ fn render_styled_text(para: &ArticleParagraph, chars: &[char], start: usize, end .map(|slice| slice.iter().collect()) .unwrap_or_default(); - let mut markup = html! { (PreEscaped(&chunk)) }; + let mut markup = html! { (chunk) }; if is_strike { markup = html! { s { (markup) } }; } diff --git a/src/views/renderutils.rs b/src/views/renderutils.rs index 7ddbca4..fe89fe4 100644 --- a/src/views/renderutils.rs +++ b/src/views/renderutils.rs @@ -225,7 +225,7 @@ pub fn render_community_note(note: Option<&CommunityNote>, hide_notes: bool) -> /// ranges with `` tags, preserving the rest as plain text. pub fn community_note_to_html(note: &CommunityNote) -> String { if note.links.is_empty() { - return note.text.clone(); + return html_escape(¬e.text).into_owned(); } let mut result = String::new(); @@ -235,9 +235,14 @@ pub fn community_note_to_html(note: &CommunityNote) -> String { let mut pos = 0; for &(from, to, ref url) in &sorted_links { + let from = from.min(chars.len()); let to = to.min(chars.len()); + if from < pos || from >= to { + continue; + } if from > pos { - result.extend(&chars[pos..from]); + let plain = chars[pos..from].iter().collect::(); + result.push_str(&html_escape(&plain)); } let display = chars[from..to].iter().collect::(); let url = html_escape(url); @@ -246,7 +251,8 @@ pub fn community_note_to_html(note: &CommunityNote) -> String { pos = to; } if pos < chars.len() { - result.extend(&chars[pos..]); + let plain = chars[pos..].iter().collect::(); + result.push_str(&html_escape(&plain)); } result } @@ -265,3 +271,36 @@ pub fn render_bio_html(bio: &str, config: &Config) -> String { pub fn short_link(url: &str) -> String { short_url(url, 0) } + +#[cfg(test)] +mod tests { + use super::community_note_to_html; + use crate::api::schema::CommunityNote; + + #[test] + fn community_note_escapes_plain_text() { + let note = CommunityNote { + text: "".to_owned(), + links: Vec::new(), + }; + + let rendered = community_note_to_html(¬e); + + assert_eq!(rendered, "<img src=x onerror=alert(1)>"); + } + + #[test] + fn community_note_escapes_text_around_links() { + let note = CommunityNote { + text: "link".to_owned(), + links: vec![(3, 7, r#"https://example.com/?q="x""#.to_owned())], + }; + + let rendered = community_note_to_html(¬e); + + assert_eq!( + rendered, + r#"<b>link</b>"# + ); + } +}