cedar_policy_validator/
str_checks.rs1use cedar_policy_core::ast::{Pattern, PolicyID, Template};
18use thiserror::Error;
19
20use crate::expr_iterator::expr_text;
21use crate::expr_iterator::TextKind;
22use unicode_security::GeneralSecurityProfile;
23use unicode_security::MixedScript;
24
25#[derive(Debug, Clone)]
27pub struct ValidationWarning<'a> {
28 location: &'a PolicyID,
29 kind: ValidationWarningKind,
30}
31
32impl<'a> ValidationWarning<'a> {
33 pub fn location(&self) -> &'a PolicyID {
34 self.location
35 }
36
37 pub fn kind(&self) -> &ValidationWarningKind {
38 &self.kind
39 }
40
41 pub fn to_kind_and_location(self) -> (&'a PolicyID, ValidationWarningKind) {
42 (self.location, self.kind)
43 }
44}
45
46impl std::fmt::Display for ValidationWarning<'_> {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 write!(
49 f,
50 "warning: {} in policy with id {}",
51 self.kind, self.location
52 )
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Error, Eq)]
57pub enum ValidationWarningKind {
58 #[error("string \"{0}\" contains mixed scripts")]
60 MixedScriptString(String),
61 #[error("string \"{0}\" contains BIDI control characters")]
63 BidiCharsInString(String),
64 #[error("identifier `{0}` contains BIDI control characters")]
66 BidiCharsInIdentifier(String),
67 #[error("identifier `{0}` contains mixed scripts")]
69 MixedScriptIdentifier(String),
70 #[error("identifier `{0}` contains characters that fall outside of the General Security Profile for Identifiers")]
72 ConfusableIdentifier(String),
73}
74
75pub fn confusable_string_checks<'a>(
77 p: impl Iterator<Item = &'a Template>,
78) -> impl Iterator<Item = ValidationWarning<'a>> {
79 let mut warnings = vec![];
80
81 for policy in p {
82 let e = policy.condition();
83 for str in expr_text(&e) {
84 let warning = match str {
85 TextKind::String(s) => permissable_str(s),
86 TextKind::Identifier(i) => permissable_ident(i),
87 TextKind::Pattern(p) => {
88 let pat = Pattern::new(p.iter().copied());
89 let as_str = format!("{pat}");
90 permissable_str(&as_str)
91 }
92 };
93
94 if let Some(w) = warning {
95 warnings.push(ValidationWarning {
96 location: policy.id(),
97 kind: w,
98 })
99 }
100 }
101 }
102
103 warnings.into_iter()
104}
105
106fn permissable_str(s: &str) -> Option<ValidationWarningKind> {
107 if s.chars().any(is_bidi_char) {
108 Some(ValidationWarningKind::BidiCharsInString(s.to_string()))
109 } else if !s.is_single_script() {
110 Some(ValidationWarningKind::MixedScriptString(s.to_string()))
111 } else {
112 None
113 }
114}
115
116fn permissable_ident(s: &str) -> Option<ValidationWarningKind> {
117 if s.chars().any(is_bidi_char) {
118 Some(ValidationWarningKind::BidiCharsInIdentifier(s.to_string()))
119 } else if !s.chars().all(|c| c.identifier_allowed()) {
120 Some(ValidationWarningKind::ConfusableIdentifier(s.to_string()))
121 } else if !s.is_single_script() {
122 Some(ValidationWarningKind::MixedScriptIdentifier(s.to_string()))
123 } else {
124 None
125 }
126}
127
128fn is_bidi_char(c: char) -> bool {
129 BIDI_CHARS.iter().any(|bidi| bidi == &c)
130}
131
132const BIDI_CHARS: [char; 9] = [
137 '\u{202A}', '\u{202B}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{202C}',
138 '\u{2069}',
139];
140
141#[allow(clippy::panic)]
143#[allow(clippy::indexing_slicing)]
145#[cfg(test)]
146mod test {
147
148 use super::*;
149 use cedar_policy_core::{ast::PolicySet, parser::parse_policy};
150
151 #[test]
152 fn strs() {
153 assert!(permissable_str("test").is_none());
154 assert!(permissable_str("test\t\t").is_none());
155 match permissable_str("say_һello") {
156 Some(ValidationWarningKind::MixedScriptString(_)) => (),
157 o => panic!("should have produced MixedScriptString: {:?}", o),
158 };
159 }
160
161 #[test]
162 #[allow(clippy::invisible_characters)]
163 fn idents() {
164 assert!(permissable_ident("test").is_none());
165 match permissable_ident("isAdmin") {
166 Some(ValidationWarningKind::ConfusableIdentifier(_)) => (),
167 o => panic!("should have produced ConfusableIdentifier: {:?}", o),
168 };
169 match permissable_ident("say_һello") {
170 Some(ValidationWarningKind::MixedScriptIdentifier(_)) => (),
171 o => panic!("should have produced MixedScriptIdentifier: {:?}", o),
172 };
173 }
174
175 #[test]
176 fn a() {
177 let src = r#"
178 permit(principal == test::"say_һello", action, resource);
179 "#;
180
181 let mut s = PolicySet::new();
182 let p = parse_policy(Some("test".to_string()), src).unwrap();
183 s.add_static(p).unwrap();
184 let warnings =
185 confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
186 assert_eq!(warnings.len(), 1);
187 let warning = &warnings[0];
188 let kind = warning.kind().clone();
189 let location = warning.location();
190 assert_eq!(
191 kind,
192 ValidationWarningKind::MixedScriptIdentifier(r#"say_һello"#.to_string())
193 );
194 assert_eq!(
195 format!("{warning}"),
196 "warning: identifier `say_һello` contains mixed scripts in policy with id test"
197 );
198 assert_eq!(location, &PolicyID::from_string("test"));
199 assert_eq!(warning.clone().to_kind_and_location(), (location, kind));
200 }
201
202 #[test]
203 #[allow(clippy::invisible_characters)]
204 fn b() {
205 let src = r#"
206 permit(principal, action, resource) when {
207 principal["isAdmin"] == "say_һello"
208 };
209 "#;
210 let mut s = PolicySet::new();
211 let p = parse_policy(Some("test".to_string()), src).unwrap();
212 s.add_static(p).unwrap();
213 let warnings = confusable_string_checks(s.policies().map(|p| p.template()));
214 assert_eq!(warnings.count(), 2);
215 }
216
217 #[test]
218 fn problem_in_pattern() {
219 let src = r#"
220 permit(principal, action, resource) when {
221 principal.name like "*_һello"
222 };
223 "#;
224 let mut s = PolicySet::new();
225 let p = parse_policy(Some("test".to_string()), src).unwrap();
226 s.add_static(p).unwrap();
227 let warnings =
228 confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
229 assert_eq!(warnings.len(), 1);
230 let warning = &warnings[0];
231 let kind = warning.kind().clone();
232 let location = warning.location();
233 assert_eq!(
234 kind,
235 ValidationWarningKind::MixedScriptString(r#"*_һello"#.to_string())
236 );
237 assert_eq!(
238 format!("{warning}"),
239 "warning: string \"*_һello\" contains mixed scripts in policy with id test"
240 );
241 assert_eq!(location, &PolicyID::from_string("test"));
242 assert_eq!(warning.clone().to_kind_and_location(), (location, kind));
243 }
244
245 #[test]
246 #[allow(text_direction_codepoint_in_literal)]
247 fn trojan_source() {
248 let src = r#"
249 permit(principal, action, resource) when {
250 principal.access_level != "user && principal.is_admin "
251 };
252 "#;
253 let mut s = PolicySet::new();
254 let p = parse_policy(Some("test".to_string()), src).unwrap();
255 s.add_static(p).unwrap();
256 let warnings =
257 confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
258 assert_eq!(warnings.len(), 1);
259 let warning = &warnings[0];
260 let kind = warning.kind().clone();
261 let location = warning.location();
262 assert_eq!(
263 kind,
264 ValidationWarningKind::BidiCharsInString(r#"user && principal.is_admin "#.to_string())
265 );
266 assert_eq!(format!("{warning}"), "warning: string \"user && principal.is_admin \" contains BIDI control characters in policy with id test");
267 assert_eq!(location, &PolicyID::from_string("test"));
268 assert_eq!(warning.clone().to_kind_and_location(), (location, kind));
269 }
270}