diff --git a/changelog.d/5-internal/improve-acl b/changelog.d/5-internal/improve-acl new file mode 100644 index 0000000000..15f0545ef7 --- /dev/null +++ b/changelog.d/5-internal/improve-acl @@ -0,0 +1 @@ +Add regular expression support to libzauth ACL language diff --git a/libs/libzauth/libzauth-c/Cargo.lock b/libs/libzauth/libzauth-c/Cargo.lock index 71636e381b..7acceb85ae 100644 --- a/libs/libzauth/libzauth-c/Cargo.lock +++ b/libs/libzauth/libzauth-c/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" +dependencies = [ + "memchr", +] + [[package]] name = "asexp" version = "0.3.2" @@ -23,6 +32,12 @@ dependencies = [ "signature", ] +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + [[package]] name = "libc" version = "0.2.125" @@ -41,12 +56,35 @@ dependencies = [ "walkdir", ] +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + [[package]] name = "pkg-config" version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + [[package]] name = "rustc-serialize" version = "0.3.24" @@ -130,9 +168,11 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "zauth" -version = "3.0.0" +version = "3.1.0" dependencies = [ "asexp", + "lazy_static", + "regex", "rustc-serialize", "sodiumoxide", ] diff --git a/libs/libzauth/libzauth/Cargo.toml b/libs/libzauth/libzauth/Cargo.toml index bcff7126d8..34920bd6f2 100644 --- a/libs/libzauth/libzauth/Cargo.toml +++ b/libs/libzauth/libzauth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zauth" -version = "3.0.0" +version = "3.1.0" authors = ["Wire Swiss GmbH "] license = "AGPL-3.0" @@ -11,6 +11,8 @@ name = "zauth" asexp = ">= 0.3" rustc-serialize = ">= 0.3" sodiumoxide = "^0.2.7" +regex = "1.6" +lazy_static = "1.4" [dev-dependencies] clap = ">= 2.0" diff --git a/libs/libzauth/libzauth/src/acl.rs b/libs/libzauth/libzauth/src/acl.rs index a14ab697f5..0920d06602 100644 --- a/libs/libzauth/libzauth/src/acl.rs +++ b/libs/libzauth/libzauth/src/acl.rs @@ -15,31 +15,34 @@ // You should have received a copy of the GNU Affero General Public License along // with this program. If not, see . -use std::collections::HashMap; use asexp::Sexp; -use tree::Tree; +use matcher::{Item, Matcher}; +use std::collections::HashMap; #[derive(Debug, Clone)] pub enum Error { - Parse(&'static str) + Parse(&'static str), } pub type AclResult = Result; #[derive(Debug, Clone)] pub struct Acl { - acl: HashMap + acl: HashMap, } impl Acl { pub fn new() -> Acl { - Acl { acl: HashMap::new() } + Acl { + acl: HashMap::new(), + } } pub fn from_str(s: &str) -> AclResult { - match Sexp::parse_toplevel(s) { - Err(()) => Err(Error::Parse("invalid s-expressions")), - Ok(sexp) => Acl::from_sexp(&sexp) + let sexp = Sexp::parse_toplevel(s); + match sexp { + Err(()) => Err(Error::Parse("invalid s-expressions")), + Ok(sexp) => Acl::from_sexp(&sexp), } } @@ -51,31 +54,30 @@ impl Acl { if let Some(k) = key.get_str().map(String::from) { acl.insert(k, List::from_sexp(&list)?); } else { - return Err(Error::Parse("not a string")) + return Err(Error::Parse("not a string")); } } Ok(Acl { acl }) } - _ => Err(Error::Parse("expected key and values")) + _ => Err(Error::Parse("expected key and values")), } } pub fn allowed(&self, key: &str, path: &str) -> bool { - self.acl.get(key).map(|list| { - match *list { - List::Black(Some(ref t)) => !t.contains(path), - List::Black(None) => true, - List::White(Some(ref t)) => t.contains(path), - List::White(None) => false - } - }).unwrap_or(false) + self.acl + .get(key) + .map(|list| match *list { + List::Black(ref t) => !t.contains(path), + List::White(ref t) => t.contains(path), + }) + .unwrap_or(false) } } #[derive(Debug, Clone)] enum List { - Black(Option), - White(Option) + Black(Matcher), + White(Matcher), } impl List { @@ -83,50 +85,38 @@ impl List { let items = match *s { Sexp::Tuple(ref a) => a.as_slice(), Sexp::Array(ref a) => a.as_slice(), - _ => return Err(Error::Parse("s-expr not a list")) + _ => return Err(Error::Parse("s-expr not a list")), }; if items.is_empty() { - return Err(Error::Parse("list is empty")) + return Err(Error::Parse("list is empty")); } match items[0].get_str() { - Some("blacklist") => List::items(&items[1 ..]).map(List::Black), - Some("whitelist") => List::items(&items[1 ..]).map(List::White), - _ => Err(Error::Parse("'blacklist' or 'whitelist' expected")) + Some("blacklist") => List::items(&items[1..]).map(List::Black), + Some("whitelist") => List::items(&items[1..]).map(List::White), + _ => Err(Error::Parse("'blacklist' or 'whitelist' expected")), } } - fn items(xs: &[Sexp]) -> AclResult> { - match xs.len() { - 0 => Ok(None), - 1 if List::is_unit(&xs[0]) => Ok(None), - _ => { - let mut t = Tree::new(); - for x in xs { - t.add(&List::read_path(x)?) - } - Ok(Some(t)) - } - } + fn items(xs: &[Sexp]) -> AclResult { + let items: AclResult> = xs.iter().map(List::read_path).collect(); + let m = Matcher::new(&items?); + Ok(m) } - fn is_unit(s: &Sexp) -> bool { - match *s { - Sexp::Tuple(ref a) if a.is_empty() => true, - _ => false - } - } - - fn read_path(s: &Sexp) -> AclResult { + fn read_path(s: &Sexp) -> AclResult { match *s { Sexp::Tuple(ref a) | Sexp::Array(ref a) if a.len() == 2 => { match (a[0].get_str(), a[1].get_str()) { - (Some("path"), Some(x)) => Ok(String::from(x)), - _ => Err(Error::Parse("'path' not found")) + (Some("path"), Some(x)) => Ok(Item::Str(String::from(x))), + (Some("regex"), Some(x)) => { + Ok(Item::Regex(String::from(x))) + } + _ => Err(Error::Parse("'path' not found")), } } - _ => return Err(Error::Parse("s-expr not a list")) + _ => return Err(Error::Parse("s-expr not a list")), } } } @@ -144,14 +134,15 @@ mod tests { (path "/a/**")) b (whitelist (path "/conversation/message") - (path "/foo/bar/*")) + (path "/foo/bar/*") + (regex "(/v[0-9]+)?/foo/baz/[^/]+")) # this is a comment that should not lead to a parse failure. la (whitelist (path "/legalhold/**")) - x (blacklist ()) + x (blacklist) - y (whitelist ()) + y (whitelist) "#; #[test] @@ -165,8 +156,12 @@ mod tests { assert!(!acl.allowed("u", "/x/here/z")); assert!(acl.allowed("u", "/x/here/z/x")); assert!(acl.allowed("b", "/conversation/message")); - assert!(acl.allowed("b", "/foo/bar/baz")); + assert!(acl.allowed("b", "/foo/bar/quux")); + assert!(!acl.allowed("b", "/foo/bar/")); + assert!(acl.allowed("b", "/foo/baz/quux")); assert!(!acl.allowed("b", "/foo/bar/")); + assert!(acl.allowed("b", "/v97/foo/baz/quux")); + assert!(!acl.allowed("b", "/voo/foo/baz/quux")); assert!(!acl.allowed("b", "/anywhere/else/")); assert!(acl.allowed("x", "/everywhere")); assert!(acl.allowed("x", "/")); diff --git a/libs/libzauth/libzauth/src/lib.rs b/libs/libzauth/libzauth/src/lib.rs index 192fa31b6f..7aed0ff371 100644 --- a/libs/libzauth/libzauth/src/lib.rs +++ b/libs/libzauth/libzauth/src/lib.rs @@ -16,6 +16,8 @@ // with this program. If not, see . extern crate asexp; +extern crate lazy_static; +extern crate regex; extern crate rustc_serialize; extern crate sodiumoxide; @@ -23,7 +25,7 @@ pub mod acl; pub mod error; pub mod zauth; -mod tree; +mod matcher; pub use acl::Acl; pub use error::Error; diff --git a/libs/libzauth/libzauth/src/matcher.rs b/libs/libzauth/libzauth/src/matcher.rs new file mode 100644 index 0000000000..622ba8be98 --- /dev/null +++ b/libs/libzauth/libzauth/src/matcher.rs @@ -0,0 +1,118 @@ +// This file is part of the Wire Server implementation. +// +// Copyright (C) 2022 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License along +// with this program. If not, see . + +use lazy_static::lazy_static; +use regex::Regex; + +pub enum Item { + Str(String), + Regex(String), +} + +#[derive(Debug, Clone)] +pub struct Matcher { + regex: Option, +} + +lazy_static! { + static ref SLASHES: Regex = Regex::new("/+").unwrap(); + static ref DOUBLE_STAR_PATTERN: Regex = Regex::new(r#"\\\*\\\*"#).unwrap(); + static ref STAR_PATTERN: Regex = Regex::new(r#"\\\*"#).unwrap(); +} + +impl Matcher { + pub fn new(items: &Vec) -> Self { + if items.len() == 0 { + return Self { regex: None }; + } + + let items = items + .iter() + .map(|item| match item { + Item::Str(item) => { + let item = SLASHES.replace_all(item, "/"); + let item = item.trim_end_matches("/"); + let pattern = regex::escape(item); + let pattern = + DOUBLE_STAR_PATTERN.replace_all(&pattern, ".*"); + let pattern = STAR_PATTERN.replace_all(&pattern, "[^/]+"); + + let mut text = String::new(); + text.push_str("("); + text.push_str(&pattern); + text.push_str(")"); + text + } + Item::Regex(r) => r.clone(), + }) + .collect::>(); + + let mut pattern = String::new(); + pattern.push_str("^("); + pattern.push_str(&items.join("|")); + pattern.push_str(")$"); + Self { + regex: Some(Regex::new(&pattern).unwrap()), + } + } + + pub fn contains(&self, s: &str) -> bool { + match &self.regex { + None => false, + Some(r) => { + let s = SLASHES.replace_all(s, "/"); + let s = s.trim_end_matches("/"); + r.is_match(&s) + } + } + } +} + +// Tests //////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test() { + let mut items = Vec::new(); + items.push(Item::Str("/foo".to_string())); + items.push(Item::Str("/foo/bar/baz".to_string())); + items.push(Item::Str("/x/y/".to_string())); + items.push(Item::Str("/i/**".to_string())); + items.push(Item::Str("/j/*".to_string())); + items.push(Item::Str("/k/v*".to_string())); + items.push(Item::Str("/a//c".to_string())); + items.push(Item::Regex("(/v[0-9]+)?/notifications".to_string())); + let t = Matcher::new(&items); + + assert!(t.contains("/foo")); + assert!(t.contains("/foo/bar/baz")); + assert!(t.contains("/x/y")); + assert!(!t.contains("/foo/bar")); + assert!(t.contains("/a/c")); + assert!(!t.contains("/a")); + assert!(t.contains("/i/foo")); + assert!(t.contains("/i/foo/zoo")); + assert!(t.contains("/j/foo")); + assert!(!t.contains("/j/foo/zoo")); + assert!(t.contains("/notifications")); + assert!(t.contains("/v33/notifications")); + assert!(!t.contains("/versions/notifications")); + } +} diff --git a/libs/libzauth/libzauth/src/tree.rs b/libs/libzauth/libzauth/src/tree.rs deleted file mode 100644 index 2a55075e0f..0000000000 --- a/libs/libzauth/libzauth/src/tree.rs +++ /dev/null @@ -1,97 +0,0 @@ -// This file is part of the Wire Server implementation. -// -// Copyright (C) 2022 Wire Swiss GmbH -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License as published by the Free -// Software Foundation, either version 3 of the License, or (at your option) any -// later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more -// details. -// -// You should have received a copy of the GNU Affero General Public License along -// with this program. If not, see . - -//! Internal module to provide efficient lookup trees for paths. -//! Actually a port of wai-zauth's Network.Wai.Zauth.Tree with -//! the addtional support for "deep wildcards" (specified with "**"). - -use std::collections::HashMap; -use std::collections::hash_map::Entry; - -#[derive(Debug, Clone)] -pub struct Tree { - end_marker: bool, - subtree: HashMap -} - -impl Tree { - pub fn new() -> Tree { - Tree { - end_marker: false, - subtree: HashMap::new() - } - } - - pub fn add(&mut self, s: &str) { - add_parts(self, s.split('/').filter(|s| !s.is_empty())) - } - - pub fn contains(&self, s: &str) -> bool { - let mut tree = self; - for p in s.split('/').filter(|s| !s.is_empty()) { - match tree.subtree.get(p).or_else(|| tree.subtree.get("*")) { - None => return tree.subtree.get("**").is_some(), - Some(t) => tree = t - } - } - tree.end_marker - } -} - -fn add_parts<'a, I>(tree: &mut Tree, mut s: I) - where I: Iterator { - match s.next() { - None => tree.end_marker = true, - Some(p) => { - let next = - match tree.subtree.entry(String::from(p)) { - Entry::Vacant(e) => e.insert(Tree::new()), - Entry::Occupied(e) => e.into_mut() - }; - add_parts(next, s) - } - } -} - -// Tests //////////////////////////////////////////////////////////////////// - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test() { - let mut t = Tree::new(); - t.add("/foo"); - t.add("/foo/bar/baz"); - t.add("/x/y/"); - t.add("/i/**"); - t.add("/j/*"); - t.add("/a//c"); - - assert!(t.contains("/foo")); - assert!(t.contains("/foo/bar/baz")); - assert!(t.contains("/x/y")); - assert!(!t.contains("/foo/bar")); - assert!(t.contains("/a/c")); - assert!(!t.contains("/a")); - assert!(t.contains("/i/foo")); - assert!(t.contains("/i/foo/zoo")); - assert!(t.contains("/j/foo")); - assert!(!t.contains("/j/foo/zoo")); - } -} diff --git a/nix/pkgs/zauth/default.nix b/nix/pkgs/zauth/default.nix index 1f256b7c9e..5ae2d991a6 100644 --- a/nix/pkgs/zauth/default.nix +++ b/nix/pkgs/zauth/default.nix @@ -15,7 +15,7 @@ rustPlatform.buildRustPackage rec { src = nix-gitignore.gitignoreSourcePure [ ../../../.gitignore ] ../../../libs/libzauth; sourceRoot = "libzauth/libzauth-c"; - cargoSha256 = "0p81bjbwchq8v0ybvx8r1xcxsah7fjdq2fc2dy4l4k2v18hi9z91"; + cargoSha256 = "sha256-od+O5dhAVC1KhDUz8U2fhjyqjXkqHjeEEhvVE0N9orI="; patchLibs = lib.optionalString stdenv.isDarwin '' install_name_tool -id $out/lib/libzauth.dylib $out/lib/libzauth.dylib