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