Desktop alerts when suspicious unicode characters found in Recipe (#4080)

This commit is contained in:
Amed Rodriguez
2025-08-19 14:05:46 -07:00
committed by GitHub
parent 33bf5a44ed
commit 4f4e8ace33
11 changed files with 256 additions and 15 deletions
+4 -1
View File
@@ -389,7 +389,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
super::routes::schedule::sessions_handler,
super::routes::recipe::create_recipe,
super::routes::recipe::encode_recipe,
super::routes::recipe::decode_recipe
super::routes::recipe::decode_recipe,
super::routes::recipe::scan_recipe
),
components(schemas(
super::routes::config_management::UpsertConfigQuery,
@@ -456,6 +457,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
super::routes::recipe::EncodeRecipeResponse,
super::routes::recipe::DecodeRecipeRequest,
super::routes::recipe::DecodeRecipeResponse,
super::routes::recipe::ScanRecipeRequest,
super::routes::recipe::ScanRecipeResponse,
goose::recipe::Recipe,
goose::recipe::Author,
goose::recipe::Settings,
+30
View File
@@ -56,6 +56,16 @@ pub struct DecodeRecipeResponse {
recipe: Recipe,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ScanRecipeRequest {
recipe: Recipe,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ScanRecipeResponse {
has_security_warnings: bool,
}
#[utoipa::path(
post,
path = "/recipes/create",
@@ -164,11 +174,31 @@ async fn decode_recipe(
}
}
#[utoipa::path(
post,
path = "/recipes/scan",
request_body = ScanRecipeRequest,
responses(
(status = 200, description = "Recipe scanned successfully", body = ScanRecipeResponse),
),
tag = "Recipe Management"
)]
async fn scan_recipe(
Json(request): Json<ScanRecipeRequest>,
) -> Result<Json<ScanRecipeResponse>, StatusCode> {
let has_security_warnings = request.recipe.check_for_security_warnings();
Ok(Json(ScanRecipeResponse {
has_security_warnings,
}))
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/recipes/create", post(create_recipe))
.route("/recipes/encode", post(encode_recipe))
.route("/recipes/decode", post(decode_recipe))
.route("/recipes/scan", post(scan_recipe))
.with_state(state)
}
+57
View File
@@ -5,6 +5,7 @@ use std::fmt;
use crate::agents::extension::ExtensionConfig;
use crate::agents::types::RetryConfig;
use crate::utils::contains_unicode_tags;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -253,6 +254,25 @@ pub struct RecipeBuilder {
}
impl Recipe {
/// Returns true if harmful content is detected in instructions, prompt, or activities fields
pub fn check_for_security_warnings(&self) -> bool {
if [self.instructions.as_deref(), self.prompt.as_deref()]
.iter()
.flatten()
.any(|&field| contains_unicode_tags(field))
{
return true;
}
if let Some(activities) = &self.activities {
return activities
.iter()
.any(|activity| contains_unicode_tags(activity));
}
false
}
/// Creates a new RecipeBuilder to construct a Recipe instance
///
/// # Example
@@ -746,4 +766,41 @@ isGlobal: true"#;
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 0);
}
#[test]
fn test_check_for_security_warnings() {
let mut recipe = Recipe {
version: "1.0.0".to_string(),
title: "Test".to_string(),
description: "Test".to_string(),
instructions: Some("clean instructions".to_string()),
prompt: Some("clean prompt".to_string()),
extensions: None,
context: None,
settings: None,
activities: Some(vec!["clean activity 1".to_string()]),
author: None,
parameters: None,
response: None,
sub_recipes: None,
retry: None,
};
assert!(!recipe.check_for_security_warnings());
// Malicious activities
recipe.activities = Some(vec![
"clean activity".to_string(),
format!("malicious{}activity", '\u{E0041}'),
]);
assert!(recipe.check_for_security_warnings());
// Malicious instructions
recipe.instructions = Some(format!("instructions{}", '\u{E0041}'));
assert!(recipe.check_for_security_warnings());
// Malicious prompt
recipe.prompt = Some(format!("prompt{}", '\u{E0042}'));
assert!(recipe.check_for_security_warnings());
}
}
+22 -5
View File
@@ -1,17 +1,23 @@
use tokio_util::sync::CancellationToken;
use unicode_normalization::UnicodeNormalization;
/// Check if a character is in the Unicode Tags Block range (U+E0000-U+E007F)
/// These characters are invisible and can be used for steganographic attacks
fn is_in_unicode_tag_range(c: char) -> bool {
matches!(c, '\u{E0000}'..='\u{E007F}')
}
pub fn contains_unicode_tags(text: &str) -> bool {
text.chars().any(is_in_unicode_tag_range)
}
/// Sanitize Unicode Tags Block characters from text
/// Used to prevent Unicode-based prompt injection attacks
///
/// This function removes invisible Unicode Tags Block characters (U+E0000-U+E007F)
/// that can be used for steganographic attacks while preserving legitimate Unicode.
pub fn sanitize_unicode_tags(text: &str) -> String {
let normalized: String = text.nfc().collect();
normalized
.chars()
.filter(|&c| !matches!(c, '\u{E0000}'..='\u{E007F}'))
.filter(|&c| !is_in_unicode_tag_range(c))
.collect()
}
@@ -45,6 +51,17 @@ pub fn is_token_cancelled(cancellation_token: &Option<CancellationToken>) -> boo
mod tests {
use super::*;
#[test]
fn test_contains_unicode_tags() {
// Test detection of Unicode Tags Block characters
assert!(contains_unicode_tags("Hello\u{E0041}world"));
assert!(contains_unicode_tags("\u{E0000}"));
assert!(contains_unicode_tags("\u{E007F}"));
assert!(!contains_unicode_tags("Hello world"));
assert!(!contains_unicode_tags("Hello 世界 🌍"));
assert!(!contains_unicode_tags(""));
}
#[test]
fn test_sanitize_unicode_tags() {
// Test that Unicode Tags Block characters are removed