1use cedar_policy_core::ast::{PolicyID, Template};
18use cedar_policy_core::parser::Loc;
19
20use crate::expr_iterator::expr_text;
21use crate::expr_iterator::TextKind;
22use crate::ValidationWarning;
23use unicode_security::GeneralSecurityProfile;
24use unicode_security::MixedScript;
25
26pub fn confusable_string_checks<'a>(
28 p: impl Iterator<Item = &'a Template>,
29) -> impl Iterator<Item = ValidationWarning> {
30 let mut warnings = vec![];
31
32 for policy in p {
33 let e = policy.condition();
34 for str in expr_text(&e) {
35 let warning = match str {
36 TextKind::String(span, s) => permissable_str(span, policy.id(), s),
37 TextKind::Identifier(span, i) => permissable_ident(span, policy.id(), i),
38 TextKind::Pattern(span, pat) => {
39 permissable_str(span, policy.id(), &pat.to_string())
40 }
41 };
42
43 if let Some(warning) = warning {
44 warnings.push(warning)
45 }
46 }
47 }
48
49 warnings.into_iter()
50}
51
52fn permissable_str(loc: Option<&Loc>, policy_id: &PolicyID, s: &str) -> Option<ValidationWarning> {
53 if s.chars().any(is_bidi_char) {
54 Some(ValidationWarning::bidi_chars_strings(
55 loc.cloned(),
56 policy_id.clone(),
57 s.to_string(),
58 ))
59 } else if !s.is_single_script() {
60 Some(ValidationWarning::mixed_script_string(
61 loc.cloned(),
62 policy_id.clone(),
63 s.to_string(),
64 ))
65 } else {
66 None
67 }
68}
69
70fn permissable_ident(
71 loc: Option<&Loc>,
72 policy_id: &PolicyID,
73 s: &str,
74) -> Option<ValidationWarning> {
75 if s.chars().any(is_bidi_char) {
76 Some(ValidationWarning::bidi_chars_identifier(
77 loc.cloned(),
78 policy_id.clone(),
79 s,
80 ))
81 } else if let Some(c) = s
82 .chars()
83 .find(|c| *c != ' ' && !c.is_ascii_graphic() && !c.identifier_allowed())
84 {
85 Some(ValidationWarning::confusable_identifier(
86 loc.cloned(),
87 policy_id.clone(),
88 s,
89 c,
90 ))
91 } else if !s.is_single_script() {
92 Some(ValidationWarning::mixed_script_identifier(
93 loc.cloned(),
94 policy_id.clone(),
95 s,
96 ))
97 } else {
98 None
99 }
100}
101
102fn is_bidi_char(c: char) -> bool {
103 BIDI_CHARS.iter().any(|bidi| bidi == &c)
104}
105
106const BIDI_CHARS: [char; 9] = [
112 '\u{202A}', '\u{202B}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{202C}',
113 '\u{2069}',
114];
115
116#[allow(clippy::panic)]
118#[allow(clippy::indexing_slicing)]
120#[cfg(test)]
121mod test {
122 use super::*;
123 use cedar_policy_core::{
124 ast::PolicySet,
125 parser::{parse_policy, Loc},
126 test_utils::{expect_err, ExpectedErrorMessageBuilder},
127 };
128 use cool_asserts::assert_matches;
129 use std::sync::Arc;
130 #[test]
131 fn strs() {
132 assert_eq!(
133 permissable_str(None, &PolicyID::from_string("0"), "test"),
134 None
135 );
136 assert_eq!(
137 permissable_str(None, &PolicyID::from_string("0"), "test\t\t"),
138 None
139 );
140 assert_eq!(
141 permissable_str(None, &PolicyID::from_string("0"), "say_һello"),
142 Some(ValidationWarning::mixed_script_string(
143 None,
144 PolicyID::from_string("0"),
145 "say_һello"
146 ))
147 );
148 }
149
150 #[test]
151 #[allow(clippy::invisible_characters)]
152 fn idents() {
153 assert_eq!(
154 permissable_ident(None, &PolicyID::from_string("0"), "test"),
155 None
156 );
157 assert_eq!(
158 permissable_ident(
159 None,
160 &PolicyID::from_string("0"),
161 "https://www.example.com/test?foo=bar&bar=baz#buz"
162 ),
163 None
164 );
165 assert_eq!(
166 permissable_ident(
167 None,
168 &PolicyID::from_string("0"),
169 "http://example.com/query{firstName}-{lastName}"
170 ),
171 None
172 );
173 assert_eq!(
174 permissable_ident(
175 None,
176 &PolicyID::from_string("0"),
177 "example_user+1@example.com"
178 ),
179 None
180 );
181 assert_eq!(
182 permissable_ident(None, &PolicyID::from_string("0"), "get /pets/{petId}"),
183 None
184 );
185
186 assert_matches!(permissable_ident(None, &PolicyID::from_string("0"), "isAdmin"), Some(warning) => {
187 expect_err(
188 "",
189 &miette::Report::new(warning),
190 &ExpectedErrorMessageBuilder::error(r#"for policy `0`, identifier `is\u{200b}Admin` contains the character `\u{200b}` which is not a printable ASCII character and falls outside of the General Security Profile for Identifiers"#)
191 .build());
192 });
193 assert_matches!(permissable_ident(None, &PolicyID::from_string("0"), "new\nline"), Some(warning) => {
194 expect_err(
195 "",
196 &miette::Report::new(warning),
197 &ExpectedErrorMessageBuilder::error(r#"for policy `0`, identifier `new\nline` contains the character `\n` which is not a printable ASCII character and falls outside of the General Security Profile for Identifiers"#)
198 .build());
199 });
200 assert_matches!(permissable_ident(None, &PolicyID::from_string("0"), "null\0"), Some(warning) => {
201 expect_err(
202 "",
203 &miette::Report::new(warning),
204 &ExpectedErrorMessageBuilder::error(r#"for policy `0`, identifier `null\0` contains the character `\0` which is not a printable ASCII character and falls outside of the General Security Profile for Identifiers"#)
205 .build());
206 });
207 assert_matches!(permissable_ident(None, &PolicyID::from_string("0"), "delete\x7f"), Some(warning) => {
208 expect_err(
209 "",
210 &miette::Report::new(warning),
211 &ExpectedErrorMessageBuilder::error(r#"for policy `0`, identifier `delete\u{7f}` contains the character `\u{7f}` which is not a printable ASCII character and falls outside of the General Security Profile for Identifiers"#)
212 .build());
213 });
214 assert_matches!(permissable_ident(None, &PolicyID::from_string("0"), "🍌"), Some(warning) => {
215 expect_err(
216 "",
217 &miette::Report::new(warning),
218 &ExpectedErrorMessageBuilder::error(r#"for policy `0`, identifier `🍌` contains the character `🍌` which is not a printable ASCII character and falls outside of the General Security Profile for Identifiers"#)
219 .build());
220 });
221 assert_matches!(permissable_ident(None, &PolicyID::from_string("0"), "say_һello") , Some(warning) => {
222 expect_err(
223 "",
224 &miette::Report::new(warning),
225 &ExpectedErrorMessageBuilder::error(r#"for policy `0`, identifier `say_һello` contains mixed scripts"#)
226 .build());
227 });
228 }
229
230 #[test]
231 fn a() {
232 let src = r#"
233 permit(principal == test::"say_һello", action, resource);
234 "#;
235
236 let mut s = PolicySet::new();
237 let p = parse_policy(Some(PolicyID::from_string("test")), src).unwrap();
238 s.add_static(p).unwrap();
239 let warnings =
240 confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
241 assert_eq!(warnings.len(), 1);
242 let warning = &warnings[0];
243 assert_eq!(
244 warning,
245 &ValidationWarning::mixed_script_identifier(
246 None,
247 PolicyID::from_string("test"),
248 r#"say_һello"#
249 )
250 );
251 assert_eq!(
252 format!("{warning}"),
253 "for policy `test`, identifier `say_һello` contains mixed scripts"
254 );
255 }
256
257 #[test]
258 #[allow(clippy::invisible_characters)]
259 fn b() {
260 let src = r#"
261 permit(principal, action, resource) when {
262 principal["isAdmin"] == "say_һello"
263 };
264 "#;
265 let mut s = PolicySet::new();
266 let p = parse_policy(Some(PolicyID::from_string("test")), src).unwrap();
267 s.add_static(p).unwrap();
268 let warnings = confusable_string_checks(s.policies().map(|p| p.template()));
269 assert_eq!(warnings.count(), 2);
270 }
271
272 #[test]
273 fn problem_in_pattern() {
274 let src = r#"
275 permit(principal, action, resource) when {
276 principal.name like "*_һello"
277 };
278 "#;
279 let mut s = PolicySet::new();
280 let p = parse_policy(Some(PolicyID::from_string("test")), src).unwrap();
281 s.add_static(p).unwrap();
282 let warnings =
283 confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
284 assert_eq!(warnings.len(), 1);
285 let warning = &warnings[0];
286 assert_eq!(
287 warning,
288 &ValidationWarning::mixed_script_string(
289 Some(Loc::new(64..94, Arc::from(src))),
290 PolicyID::from_string("test"),
291 r#"*_һello"#
292 )
293 );
294 assert_eq!(
295 format!("{warning}"),
296 "for policy `test`, string `\"*_һello\"` contains mixed scripts"
297 );
298 }
299
300 #[test]
301 #[allow(text_direction_codepoint_in_literal)]
302 fn trojan_source() {
303 let src = r#"
304 permit(principal, action, resource) when {
305 principal.access_level != "user && principal.is_admin "
306 };
307 "#;
308 let mut s = PolicySet::new();
309 let p = parse_policy(Some(PolicyID::from_string("test")), src).unwrap();
310 s.add_static(p).unwrap();
311 let warnings =
312 confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
313 assert_eq!(warnings.len(), 1);
314 let warning = &warnings[0];
315 assert_eq!(
316 warning,
317 &ValidationWarning::bidi_chars_strings(
318 Some(Loc::new(90..131, Arc::from(src))),
319 PolicyID::from_string("test"),
320 r#"user && principal.is_admin "#
321 )
322 );
323 assert_eq!(
324 format!("{warning}"),
325 "for policy `test`, string `\"user && principal.is_admin \"` contains BIDI control characters"
326 );
327 }
328}