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