Skip to content

Commit

Permalink
Merge pull request #312 from bulwark-security/resource-route-flags
Browse files Browse the repository at this point in the history
Implement multi-route matching and expansion.
  • Loading branch information
sporkmonger committed May 26, 2024
2 parents a9428dc + 4d58e6a commit 0786cfa
Show file tree
Hide file tree
Showing 20 changed files with 237 additions and 168 deletions.
60 changes: 41 additions & 19 deletions crates/config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,38 +444,60 @@ impl Preset {
}
}

/// A route pattern is either a default route that matches anything or a path-based route pattern.
#[derive(Debug, Clone, PartialEq)]
pub enum RoutePattern {
/// Matches any request not matched by a path-based route.
Default,
/// Matches requests that correspond to the enclosed path pattern.
Path(String),
}

impl Display for RoutePattern {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Self::Default => write!(f, "[default route]"),
Self::Path(route) => write!(f, "[{}]", route),
}
}
}

/// A mapping between a route pattern and the plugins that should be run for matching requests.
#[derive(Debug, Clone)]
pub struct Resource {
/// The route pattern used to match requests with.
///
/// Uses `matchit` router patterns.
pub route: RoutePattern,
pub routes: Vec<String>,
/// The plugin references for this route.
pub plugins: Vec<Reference>,
/// The maximum amount of time a plugin may take for each execution phase.
pub timeout: Option<u64>,
}

impl Resource {
/// Expands routes to make them more user-friendly.
///
/// # Arguments
///
/// * `routes` - The route patterns to expand.
/// * `exact` - Whether route expansion should ignore trailing slashes or not.
/// * `prefix` - Whether route expansion should add a catch-all '/*suffix' pattern to each route.
pub(crate) fn expand_routes(routes: &[String], exact: bool, prefix: bool) -> Vec<String> {
let mut new_routes = routes.to_vec();
if !exact {
for route in routes.iter() {
let new_route = if route.ends_with('/') && route.len() > 1 {
route[..route.len() - 1].to_string()
} else if !route.contains('*') && !route.ends_with('/') {
route.clone() + "/"
} else {
continue;
};
if !new_routes.contains(&new_route) {
new_routes.push(new_route);
}
}
}
if prefix {
for route in new_routes.clone().iter() {
if !route.contains('*') {
let new_route = PathBuf::from(route)
.join("*suffix")
.to_string_lossy()
.to_string();
if !new_routes.contains(&new_route) {
new_routes.push(new_route);
}
}
}
}
new_routes.sort_by_key(|route| -(route.len() as i64));
new_routes
}

/// Resolves all references within a `Resource`, producing a flattened list of the corresponding [`Plugin`]s.
///
/// # Arguments
Expand Down
155 changes: 125 additions & 30 deletions crates/config/src/toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use crate::ConfigFileError;
use bytes::Bytes;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, ffi::OsString, fs, path::Path, path::PathBuf};
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use url::Url;
use validator::Validate;

Expand Down Expand Up @@ -460,13 +463,30 @@ struct Preset {
/// The TOML serialization for a [Resource](crate::Resource) structure.
#[derive(Serialize, Deserialize, Clone)]
struct Resource {
default: Option<bool>,
route: Option<String>,
routes: Vec<String>,
#[serde(default = "default_resource_prefix")]
prefix: bool,
#[serde(default = "default_resource_exact")]
exact: bool,
plugins: Vec<String>,
// TODO: default timeout
timeout: Option<u64>,
}

/// Resources default to being a prefix.
///
/// This allows the default resource to simply be the "/" route.
fn default_resource_prefix() -> bool {
true
}

/// Resources default to being inexact.
///
/// This avoids surprises when matching against "/resource" and a request for "/resource/" is made.
fn default_resource_exact() -> bool {
false
}

fn resolve_path<'a, B, P>(base: &'a B, path: &'a P) -> Result<PathBuf, ConfigFileError>
where
B: 'a + ?Sized + AsRef<Path>,
Expand Down Expand Up @@ -651,24 +671,16 @@ where
resources: root
.resources
.iter()
.map(|resource| {
let route = match (&resource.default, &resource.route) {
(None, Some(route)) => Ok(crate::config::RoutePattern::Path(route.clone())),
(Some(true), None) => Ok(crate::config::RoutePattern::Default),
_ => Err(ConfigFileError::InvalidResourceConfig(
"resource must either be default or specify a route pattern".to_string(),
)),
};
match route {
Ok(route) => Ok(crate::config::Resource {
route,
plugins: resource.plugins.iter().map(resolve_reference).collect(),
timeout: resource.timeout,
}),
Err(err) => Err(err),
}
.map(|resource| crate::config::Resource {
routes: crate::Resource::expand_routes(
&resource.routes,
resource.exact,
resource.prefix,
),
plugins: resource.plugins.iter().map(resolve_reference).collect(),
timeout: resource.timeout,
})
.collect::<Result<Vec<crate::Resource>, _>>()?,
.collect(),
};
for plugin in &config.plugins {
// Read plugin configs to surface type errors immediately
Expand Down Expand Up @@ -738,7 +750,6 @@ fn validate_plugin_config(
#[cfg(test)]
mod tests {
use super::*;
use crate::RoutePattern;

fn build_plugins() -> Result<(), Box<dyn std::error::Error>> {
let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Expand Down Expand Up @@ -807,7 +818,7 @@ mod tests {
plugins = ["evil-bit"]
[[resource]]
route = "/"
routes = ["/"]
plugins = ["custom"]
timeout = 25
"#,
Expand Down Expand Up @@ -854,7 +865,10 @@ mod tests {
assert_eq!(root.presets.first().unwrap().plugins, vec!["evil-bit"]);

assert_eq!(root.resources.len(), 1);
assert_eq!(root.resources.first().unwrap().route, Some("/".to_string()));
assert_eq!(
root.resources.first().unwrap().routes,
vec!["/".to_string()]
);
assert_eq!(root.resources.first().unwrap().plugins, vec!["custom"]);
assert_eq!(root.resources.first().unwrap().timeout, Some(25));

Expand Down Expand Up @@ -917,14 +931,10 @@ mod tests {
vec![crate::config::Reference::Plugin("blank_slate".to_string())]
);

assert_eq!(root.resources.len(), 2);
assert_eq!(
root.resources.first().unwrap().route,
RoutePattern::Path("/".to_string())
);
assert_eq!(root.resources.len(), 1);
assert_eq!(
root.resources.last().unwrap().route,
RoutePattern::Path("/*params".to_string())
root.resources.first().unwrap().routes,
vec!["/*suffix".to_string(), "/".to_string()]
);
assert_eq!(
root.resources.first().unwrap().plugins,
Expand Down Expand Up @@ -1075,6 +1085,91 @@ mod tests {
Ok(())
}

#[test]
fn test_load_config_exact_resource_route() -> Result<(), Box<dyn std::error::Error>> {
build_plugins()?;

let root = load_config("tests/exact_resource_route.toml")?;

assert_eq!(root.resources.len(), 1);
assert_eq!(
root.resources.first().unwrap().routes,
vec![
"/user/:userid/*suffix".to_string(),
"/user/:userid".to_string(),
"/*suffix".to_string(),
"/".to_string()
]
);

Ok(())
}

#[test]
fn test_load_config_inexact_resource_route() -> Result<(), Box<dyn std::error::Error>> {
build_plugins()?;

let root = load_config("tests/inexact_resource_route.toml")?;

assert_eq!(root.resources.len(), 1);
assert_eq!(
root.resources.first().unwrap().routes,
vec![
"/logout/*suffix".to_string(),
"/login/*suffix".to_string(),
"/api/*suffix".to_string(),
"/logout/".to_string(),
"/*suffix".to_string(),
"/login/".to_string(),
"/logout".to_string(),
"/login".to_string(),
"/".to_string(),
]
);

Ok(())
}

#[test]
fn test_load_config_prefixed_resource_route() -> Result<(), Box<dyn std::error::Error>> {
build_plugins()?;

let root = load_config("tests/prefixed_resource_route.toml")?;

assert_eq!(root.resources.len(), 1);
assert_eq!(
root.resources.first().unwrap().routes,
vec![
"/api/*suffix".to_string(),
"/*suffix".to_string(),
"/".to_string(),
]
);

Ok(())
}

#[test]
fn test_load_config_nonprefixed_resource_route() -> Result<(), Box<dyn std::error::Error>> {
build_plugins()?;

let root = load_config("tests/nonprefixed_resource_route.toml")?;

assert_eq!(root.resources.len(), 1);
assert_eq!(
root.resources.first().unwrap().routes,
vec![
"/logout/".to_string(),
"/login/".to_string(),
"/logout".to_string(),
"/login".to_string(),
"/".to_string(),
]
);

Ok(())
}

#[test]
fn test_resolve_path() -> Result<(), Box<dyn std::error::Error>> {
let base = PathBuf::new().join(".");
Expand Down
7 changes: 1 addition & 6 deletions crates/config/tests/circular_include.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@ path = "bulwark_blank_slate.wasm"
config = {}

[[resource]]
route = "/"
plugins = ["blank_slate"]
timeout = 25

[[resource]]
route = "/*params"
routes = ["/"]
plugins = ["blank_slate"]
timeout = 25
7 changes: 1 addition & 6 deletions crates/config/tests/circular_preset.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ ref = "circular_two"
plugins = ["circular_one", "another_blank_slate"]

[[resource]]
route = "/"
routes = ["/"]
plugins = ["circular_one"]
timeout = 25

[[resource]]
route = "/*params"
plugins = ["circular_two"]
timeout = 25
7 changes: 1 addition & 6 deletions crates/config/tests/duplicate_mixed.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ ref = "blank_slate"
plugins = ["blank_slate", "another_blank_slate"]

[[resource]]
route = "/"
plugins = ["blank_slate"]
timeout = 25

[[resource]]
route = "/*params"
routes = ["/"]
plugins = ["blank_slate"]
timeout = 25
7 changes: 1 addition & 6 deletions crates/config/tests/duplicate_plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ path = "bulwark_blank_slate.wasm"
config = {}

[[resource]]
route = "/"
plugins = ["blank_slate"]
timeout = 25

[[resource]]
route = "/*params"
routes = ["/"]
plugins = ["blank_slate"]
timeout = 25
7 changes: 1 addition & 6 deletions crates/config/tests/duplicate_preset.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ ref = "default"
plugins = ["blank_slate"]

[[resource]]
route = "/"
plugins = ["blank_slate"]
timeout = 25

[[resource]]
route = "/*params"
routes = ["/"]
plugins = ["blank_slate"]
timeout = 25
10 changes: 10 additions & 0 deletions crates/config/tests/exact_resource_route.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[[plugin]]
ref = "blank_slate"
path = "bulwark_blank_slate.wasm"
config = {}

[[resource]]
routes = ["/", "/user/:userid"]
exact = true
plugins = ["blank_slate"]
timeout = 25
10 changes: 10 additions & 0 deletions crates/config/tests/inexact_resource_route.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[[plugin]]
ref = "blank_slate"
path = "bulwark_blank_slate.wasm"
config = {}

[[resource]]
routes = ["/", "/login", "/logout/", "/api/*suffix"]
exact = false
plugins = ["blank_slate"]
timeout = 25
7 changes: 1 addition & 6 deletions crates/config/tests/invalid_config_array.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@ path = "bulwark_blank_slate.wasm"
config = { key = [{ subkey = "not a primitive" }] }

[[resource]]
route = "/"
plugins = ["blank_slate"]
timeout = 25

[[resource]]
route = "/*params"
routes = ["/"]
plugins = ["blank_slate"]
timeout = 25
Loading

0 comments on commit 0786cfa

Please sign in to comment.