fix(handlers): escape user-supplied text in log lines

A Telegram display name, callback payload or channel handle is attacker-controlled text, and it went straight into info/error lines: one embedded newline forged a second log entry, and control characters could hide inside a line (log injection, audit SEC-006). handlers::log_escape replaces newlines, carriage returns and other control characters with visible escapes — clean input borrows, so logging allocates nothing extra — and every site that prints such a value uses it: the callback arrival line, the set-forward-channel info line (name + handle) and both get_chat error lines. The test pins that no raw newline can survive; AGENTS' logging convention names the helper.
This commit is contained in:
2026-09-24 15:02:31 +08:00
parent 9bae46eb9a
commit 4d6ec7924b
4 changed files with 52 additions and 6 deletions
+4 -1
View File
@@ -71,7 +71,10 @@ async fn handle_callback(
return;
}
log::info!("callback from {chat_id} on prompt {prompt_message_id}: {data}");
log::info!(
"callback from {chat_id} on prompt {prompt_message_id}: {}",
super::log_escape(data)
);
if data == SKIP {
// Skip works with or without a forward channel: it is the explicit
// "do not forward this" answer, and it drops the record so the forward
+12 -4
View File
@@ -205,14 +205,18 @@ async fn set_forward_channel_handler(
if let Some(from) = &message.from {
log::info!(
"Set forward channel for {} ({}) to {}",
from.full_name(),
super::log_escape(&from.full_name()),
message.chat.id,
channel
super::log_escape(&channel.to_string())
);
}
let chat = match bot.get_chat(channel.clone()).await {
Err(e) => {
log::error!("Failed to get channel {}: {}", channel, e);
log::error!(
"Failed to get channel {}: {}",
super::log_escape(&channel.to_string()),
e
);
return Err(SetForwardChannelError::NotBotAdmin(e));
}
Ok(chat) => chat,
@@ -229,7 +233,11 @@ async fn set_forward_channel_handler(
};
match bot.get_chat_administrators(channel.clone()).await {
Err(e) => {
log::error!("Failed to get channel administrators {}: {}", channel, e);
log::error!(
"Failed to get channel administrators {}: {}",
super::log_escape(&channel.to_string()),
e
);
return Err(SetForwardChannelError::NotBotAdmin(e));
}
Ok(admins) => {
+35
View File
@@ -56,6 +56,26 @@ pub fn log_key(url: &str) -> String {
x_media::site::cache_key(url).unwrap_or_else(|| "<unsupported>".to_string())
}
/// Makes user-supplied text (a display name, callback data, a channel handle)
/// fit one log line: newlines and other control characters are escaped, so a
/// crafted value cannot forge a second log entry or hide inside one. Tab is
/// kept — it cannot break the line.
pub(crate) fn log_escape(s: &str) -> std::borrow::Cow<'_, str> {
if !s.chars().any(|c| c.is_control() && c != '\t') {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
c if c != '\t' && c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
std::borrow::Cow::Owned(out)
}
/// How long a caption edit may sleep before it gives up on retrying: the reply
/// (or button press) that carried the text is already consumed, so the update
/// must not stall the chat's queue behind a long flood-control wait — the user
@@ -282,6 +302,21 @@ mod tests {
/// edit (the prompt was deleted).
const API_ERROR: &str = "Bad Request: message not found";
#[test]
fn log_escape_cannot_forge_a_second_log_line() {
let forged = log_escape("alice\nINFO injected entry");
assert!(!forged.contains('\n'), "no raw newline may survive");
assert!(
forged.contains("\\n"),
"the break stays visible as an escape"
);
// The common case (clean input) borrows — logging must not allocate.
assert!(matches!(
log_escape("plain text"),
std::borrow::Cow::Borrowed(_)
));
}
#[tokio::test]
async fn reply_to_a_prompt_swaps_the_caption_through_its_template() {
let sender = MockSender::scripted(vec![Outcome::EditOk], || api_error(API_ERROR));