cedar_policy_validator/
str_checks.rs

1/*
2 * Copyright 2022-2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
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::{Pattern, PolicyID, Template};
18
19use crate::expr_iterator::expr_text;
20use crate::expr_iterator::TextKind;
21use unicode_security::GeneralSecurityProfile;
22use unicode_security::MixedScript;
23
24#[derive(Debug, Clone)]
25pub struct ValidationWarning<'a> {
26    location: &'a PolicyID,
27    kind: ValidationWarningKind,
28}
29
30impl<'a> ValidationWarning<'a> {
31    pub fn location(&self) -> &'a PolicyID {
32        self.location
33    }
34
35    pub fn kind(&self) -> &ValidationWarningKind {
36        &self.kind
37    }
38
39    pub fn to_kind_and_location(self) -> (&'a PolicyID, ValidationWarningKind) {
40        (self.location, self.kind)
41    }
42}
43
44impl std::fmt::Display for ValidationWarning<'_> {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(
47            f,
48            "Warning: {} in policy with ID {}",
49            self.kind, self.location
50        )
51    }
52}
53
54#[derive(Debug, Clone, PartialEq)]
55pub enum ValidationWarningKind {
56    MixedScriptString(String),
57    BidiCharsInString(String),
58    BidiCharsInIdentifier(String),
59    MixedScriptIdentifier(String),
60    ConfusableIdentifier(String),
61}
62
63impl std::fmt::Display for ValidationWarningKind {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            ValidationWarningKind::MixedScriptString(s) =>
67                write!(f, "The string \"{s}\" contains mixed scripts. This can cause characters to be confused for each other."),
68            ValidationWarningKind::MixedScriptIdentifier(s) => write!(f, "The identifier \"{s}\" contains mixed scripts. This can cause characters to be confused for each other."),
69            ValidationWarningKind::BidiCharsInString(s) => write!(f, "The string \"{s}\" contains BIDI control characters. These can be used to create crafted pieces of code that obfuscate true control flow."),
70            ValidationWarningKind::BidiCharsInIdentifier(s) => write!(f, "The identifier \"{s}\" contains BIDI control characters. These can be used to create crafted pieces of code that obfuscate true control flow."),
71            ValidationWarningKind::ConfusableIdentifier(s) => write!(f, "The identifier \"{s}\" contains characters that fall outside of the General Security Profile for Identifiers. We recommend adhering to this if possible. See Unicode® Technical Standard #39 for more info."),
72        }
73    }
74}
75
76/// Perform identifier and string safety checks.
77pub fn confusable_string_checks<'a>(
78    p: impl Iterator<Item = &'a Template>,
79) -> impl Iterator<Item = ValidationWarning<'a>> {
80    let mut warnings = vec![];
81
82    for policy in p {
83        let e = policy.condition();
84        for str in expr_text(&e) {
85            let warning = match str {
86                TextKind::String(s) => permissable_str(s),
87                TextKind::Identifier(i) => permissable_ident(i),
88                TextKind::Pattern(p) => {
89                    let pat = Pattern::new(p.iter().copied());
90                    let as_str = format!("{pat}");
91                    permissable_str(&as_str)
92                }
93            };
94
95            if let Some(w) = warning {
96                warnings.push(ValidationWarning {
97                    location: policy.id(),
98                    kind: w,
99                })
100            }
101        }
102    }
103
104    warnings.into_iter()
105}
106
107fn permissable_str(s: &str) -> Option<ValidationWarningKind> {
108    if s.chars().any(is_bidi_char) {
109        Some(ValidationWarningKind::BidiCharsInString(s.to_string()))
110    } else if !s.is_single_script() {
111        Some(ValidationWarningKind::MixedScriptString(s.to_string()))
112    } else {
113        None
114    }
115}
116
117fn permissable_ident(s: &str) -> Option<ValidationWarningKind> {
118    if s.chars().any(is_bidi_char) {
119        Some(ValidationWarningKind::BidiCharsInIdentifier(s.to_string()))
120    } else if !s.chars().all(|c| c.identifier_allowed()) {
121        Some(ValidationWarningKind::ConfusableIdentifier(s.to_string()))
122    } else if !s.is_single_script() {
123        Some(ValidationWarningKind::MixedScriptIdentifier(s.to_string()))
124    } else {
125        None
126    }
127}
128
129fn is_bidi_char(c: char) -> bool {
130    BIDI_CHARS.iter().any(|bidi| bidi == &c)
131}
132
133/// List of BIDI chars to warn on
134/// Source: https://doc.rust-lang.org/nightly/nightly-rustc/rustc_lint/hidden_unicode_codepoints/static.TEXT_DIRECTION_CODEPOINT_IN_LITERAL.html
135/// We could instead parse the structure of BIDI overrides and make sure it's well balanced.
136/// This is less prone to error, and since it's only a warning can be ignored by a user if need be.
137const BIDI_CHARS: [char; 9] = [
138    '\u{202A}', '\u{202B}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{202C}',
139    '\u{2069}',
140];
141
142#[cfg(test)]
143mod test {
144
145    use super::*;
146    use cedar_policy_core::{ast::PolicySet, parser::parse_policy};
147
148    #[test]
149    fn strs() {
150        assert!(permissable_str("test").is_none());
151        assert!(permissable_str("test\t\t").is_none());
152        match permissable_str("say_һello") {
153            Some(ValidationWarningKind::MixedScriptString(_)) => (),
154            o => panic!("should have produced MixedScriptString: {:?}", o),
155        };
156    }
157
158    #[test]
159    #[allow(clippy::invisible_characters)]
160    fn idents() {
161        assert!(permissable_ident("test").is_none());
162        match permissable_ident("is​Admin") {
163            Some(ValidationWarningKind::ConfusableIdentifier(_)) => (),
164            o => panic!("should have produced ConfusableIdentifier: {:?}", o),
165        };
166        match permissable_ident("say_һello") {
167            Some(ValidationWarningKind::MixedScriptIdentifier(_)) => (),
168            o => panic!("should have produced MixedScriptIdentifier: {:?}", o),
169        };
170    }
171
172    #[test]
173    fn a() {
174        let src = r#"
175        permit(principal == test::"say_һello", action, resource);
176        "#;
177
178        let mut s = PolicySet::new();
179        let p = parse_policy(Some("test".to_string()), src).unwrap();
180        s.add_static(p).unwrap();
181        let warnings =
182            confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
183        assert_eq!(warnings.len(), 1);
184        let warning = &warnings[0];
185        let kind = warning.kind().clone();
186        let location = warning.location();
187        assert_eq!(
188            kind,
189            ValidationWarningKind::MixedScriptIdentifier(r#"say_һello"#.to_string())
190        );
191        assert_eq!(format!("{warning}"), "Warning: The identifier \"say_һello\" contains mixed scripts. This can cause characters to be confused for each other. in policy with ID test");
192        assert_eq!(location, &PolicyID::from_string("test"));
193        assert_eq!(warning.clone().to_kind_and_location(), (location, kind));
194    }
195
196    #[test]
197    #[allow(clippy::invisible_characters)]
198    fn b() {
199        let src = r#"
200        permit(principal, action, resource) when {
201            principal["is​Admin"] == "say_һello"
202        };
203        "#;
204        let mut s = PolicySet::new();
205        let p = parse_policy(Some("test".to_string()), src).unwrap();
206        s.add_static(p).unwrap();
207        let warnings = confusable_string_checks(s.policies().map(|p| p.template()));
208        assert_eq!(warnings.count(), 2);
209    }
210
211    #[test]
212    fn problem_in_pattern() {
213        let src = r#"
214        permit(principal, action, resource) when {
215            principal.name like "*_һ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 =
222            confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
223        assert_eq!(warnings.len(), 1);
224        let warning = &warnings[0];
225        let kind = warning.kind().clone();
226        let location = warning.location();
227        assert_eq!(
228            kind,
229            ValidationWarningKind::MixedScriptString(r#"*_һello"#.to_string())
230        );
231        assert_eq!(format!("{warning}"), "Warning: The string \"*_һello\" contains mixed scripts. This can cause characters to be confused for each other. in policy with ID test");
232        assert_eq!(location, &PolicyID::from_string("test"));
233        assert_eq!(warning.clone().to_kind_and_location(), (location, kind));
234    }
235
236    #[test]
237    #[allow(text_direction_codepoint_in_literal)]
238    fn trojan_source() {
239        let src = r#"
240        permit(principal, action, resource) when {
241            principal.access_level != "user‮ ⁦&& principal.is_admin⁩ ⁦"
242        };
243        "#;
244        let mut s = PolicySet::new();
245        let p = parse_policy(Some("test".to_string()), src).unwrap();
246        s.add_static(p).unwrap();
247        let warnings =
248            confusable_string_checks(s.policies().map(|p| p.template())).collect::<Vec<_>>();
249        assert_eq!(warnings.len(), 1);
250        let warning = &warnings[0];
251        let kind = warning.kind().clone();
252        let location = warning.location();
253        assert_eq!(
254            kind,
255            ValidationWarningKind::BidiCharsInString(r#"user‮ ⁦&& principal.is_admin⁩ ⁦"#.to_string())
256        );
257        assert_eq!(format!("{warning}"), "Warning: The string \"user‮ ⁦&& principal.is_admin⁩ ⁦\" contains BIDI control characters. These can be used to create crafted pieces of code that obfuscate true control flow. in policy with ID test");
258        assert_eq!(location, &PolicyID::from_string("test"));
259        assert_eq!(warning.clone().to_kind_and_location(), (location, kind));
260    }
261}