cedar_policy_validator/
str_checks.rs

1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use 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
26/// Perform identifier and string safety checks.
27pub 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
106/// List of BIDI chars to warn on.
107/// Source: <`https://doc.rust-lang.org/nightly/nightly-rustc/rustc_lint/hidden_unicode_codepoints/static.TEXT_DIRECTION_CODEPOINT_IN_LITERAL.html`>
108///
109/// We could instead parse the structure of BIDI overrides and make sure it's well balanced.
110/// This is less prone to error, and since it's only a warning can be ignored by a user if need be.
111const 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// PANIC SAFETY unit tests
117#[allow(clippy::panic)]
118// PANIC SAFETY unit tests
119#[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"), "is​Admin"), 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["is​Admin"] == "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}