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};
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/// Returned by the standalone `confusable_string_checker` function, which checks a policy set for potentially confusing/obfuscating text.
26#[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    /// A string contains mixed scripts. Different scripts can contain visually similar characters which may be confused for each other.
59    #[error("string \"{0}\" contains mixed scripts")]
60    MixedScriptString(String),
61    /// A string contains BIDI control characters. These can be used to create crafted pieces of code that obfuscate true control flow.
62    #[error("string \"{0}\" contains BIDI control characters")]
63    BidiCharsInString(String),
64    /// An id contains BIDI control characters. These can be used to create crafted pieces of code that obfuscate true control flow.
65    #[error("identifier `{0}` contains BIDI control characters")]
66    BidiCharsInIdentifier(String),
67    /// An id contains mixed scripts. This can cause characters to be confused for each other.
68    #[error("identifier `{0}` contains mixed scripts")]
69    MixedScriptIdentifier(String),
70    /// An id 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.
71    #[error("identifier `{0}` contains characters that fall outside of the General Security Profile for Identifiers")]
72    ConfusableIdentifier(String),
73}
74
75/// Perform identifier and string safety checks.
76pub 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
132/// List of BIDI chars to warn on
133/// Source: https://doc.rust-lang.org/nightly/nightly-rustc/rustc_lint/hidden_unicode_codepoints/static.TEXT_DIRECTION_CODEPOINT_IN_LITERAL.html
134/// We could instead parse the structure of BIDI overrides and make sure it's well balanced.
135/// This is less prone to error, and since it's only a warning can be ignored by a user if need be.
136const 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// PANIC SAFETY unit tests
142#[allow(clippy::panic)]
143// PANIC SAFETY unit tests
144#[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("is​Admin") {
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["is​Admin"] == "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}