Skip to main content

unifi/api/
path.rs

1//! `{param}` substitution and `*path` connector-wildcard handling for
2//! capability path templates.
3
4use serde_json::{Number, Value};
5
6use crate::error::{Result, UnifiError};
7
8/// Substitutes every `{key}` placeholder in `template` with the matching
9/// string/number/boolean field from `params`, and expands a trailing
10/// `*path` wildcard segment (validated against `allowed_wildcard_prefixes`).
11///
12/// # Errors
13/// Returns [`UnifiError::PathTemplate`] for a malformed template or a
14/// missing/mistyped parameter, or [`UnifiError::ConnectorPath`] if the
15/// `*path` wildcard value fails [`validate_connector_path`].
16pub fn substitute_path(
17    template: &str,
18    params: &Value,
19    allowed_wildcard_prefixes: &[&str],
20) -> Result<String> {
21    let mut path = template.to_string();
22    while let Some(start) = path.find('{') {
23        let Some(end_offset) = path[start..].find('}') else {
24            return Err(UnifiError::PathTemplate(
25                "path template contains an unmatched opening brace".to_string(),
26            ));
27        };
28        let end = start + end_offset;
29        let key = &path[start + 1..end];
30        if key.is_empty() {
31            return Err(UnifiError::PathTemplate(
32                "path template contains an empty {} parameter".to_string(),
33            ));
34        }
35        let value = path_scalar(params, key)?;
36        path.replace_range(start..=end, &encode_path_segment(&value));
37    }
38
39    if path.contains("*path") {
40        let Some(value) = params.get("path").and_then(Value::as_str) else {
41            return Err(UnifiError::PathTemplate(
42                "missing required path parameter: path".to_string(),
43            ));
44        };
45        validate_connector_path(value, allowed_wildcard_prefixes)?;
46        path = path.replace("*path", value.trim_start_matches('/'));
47    }
48
49    Ok(path)
50}
51
52/// Percent-encodes everything outside `[A-Za-z0-9._~-]`, matching RFC 3986's
53/// unreserved set for a single path segment.
54pub fn encode_path_segment(value: &str) -> String {
55    let mut encoded = String::with_capacity(value.len());
56    for byte in value.bytes() {
57        match byte {
58            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
59                encoded.push(byte as char);
60            }
61            _ => encoded.push_str(&format!("%{byte:02X}")),
62        }
63    }
64    encoded
65}
66
67/// Rejects a `*path` wildcard value unless it is an absolute, traversal-free
68/// path under one of `allowed_prefixes`. This is the only user-influenced
69/// input that reaches the controller as a raw path segment rather than a
70/// query/body value, so it gets its own defense-in-depth check on top of
71/// whatever the controller itself enforces.
72///
73/// # Errors
74/// Returns [`UnifiError::ConnectorPath`] if `path` looks unsafe (empty,
75/// relative, contains `..`, an encoded separator, or a null byte) or falls
76/// outside `allowed_prefixes`.
77pub fn validate_connector_path(path: &str, allowed_prefixes: &[&str]) -> Result<()> {
78    if is_unsafe_connector_path(path) {
79        return Err(UnifiError::ConnectorPath(path.to_string()));
80    }
81    if allowed_prefixes
82        .iter()
83        .any(|prefix| path.starts_with(prefix))
84    {
85        Ok(())
86    } else {
87        Err(UnifiError::ConnectorPath(format!(
88            "{path} is outside the supported integration API prefix"
89        )))
90    }
91}
92
93fn path_scalar(params: &Value, key: &str) -> Result<String> {
94    match params.get(key) {
95        Some(Value::String(value)) => Ok(value.clone()),
96        Some(Value::Number(value)) => Ok(number_to_string(value)),
97        Some(Value::Bool(value)) => Ok(value.to_string()),
98        Some(_) => Err(UnifiError::PathTemplate(format!(
99            "path parameter {key} must be a string, number, or boolean"
100        ))),
101        None => Err(UnifiError::PathTemplate(format!(
102            "missing required path parameter: {key}"
103        ))),
104    }
105}
106
107fn number_to_string(value: &Number) -> String {
108    value
109        .as_i64()
110        .map(|v| v.to_string())
111        .or_else(|| value.as_u64().map(|v| v.to_string()))
112        .or_else(|| value.as_f64().map(|v| v.to_string()))
113        .unwrap_or_else(|| value.to_string())
114}
115
116fn is_unsafe_connector_path(path: &str) -> bool {
117    if path.is_empty()
118        || !path.starts_with('/')
119        || path.starts_with("//")
120        || path.contains("://")
121        || path.contains('\\')
122        || path.contains('?')
123        || path.contains('#')
124        || path.contains("..")
125    {
126        return true;
127    }
128
129    let lowercase = path.to_ascii_lowercase();
130    lowercase.contains("%2e")
131        || lowercase.contains("%2f")
132        || lowercase.contains("%5c")
133        || lowercase.contains("%00")
134}
135
136#[cfg(test)]
137mod tests {
138    use serde_json::json;
139
140    use super::*;
141
142    #[test]
143    fn substitute_path_replaces_string_number_and_bool_params() {
144        let params = json!({ "id": "abc", "count": 3, "active": true });
145
146        let path = substitute_path("/sites/{id}/{count}/{active}", &params, &[]).unwrap();
147
148        assert_eq!(path, "/sites/abc/3/true");
149    }
150
151    #[test]
152    fn substitute_path_percent_encodes_replaced_segments() {
153        let params = json!({ "id": "a b/c" });
154
155        let path = substitute_path("/sites/{id}", &params, &[]).unwrap();
156
157        assert_eq!(path, "/sites/a%20b%2Fc");
158    }
159
160    #[test]
161    fn substitute_path_errors_on_missing_param() {
162        let err = substitute_path("/sites/{id}", &json!({}), &[]).unwrap_err();
163
164        assert!(
165            matches!(err, UnifiError::PathTemplate(msg) if msg.contains("missing required path parameter: id"))
166        );
167    }
168
169    #[test]
170    fn substitute_path_errors_on_unmatched_brace() {
171        let err = substitute_path("/sites/{id", &json!({}), &[]).unwrap_err();
172
173        assert!(matches!(err, UnifiError::PathTemplate(_)));
174    }
175
176    #[test]
177    fn substitute_path_expands_an_allowed_wildcard() {
178        let params = json!({ "path": "/proxy/network/integration/v1/sites" });
179
180        let path =
181            substitute_path("/proxy/*path", &params, &["/proxy/network/integration/"]).unwrap();
182
183        assert_eq!(path, "/proxy/proxy/network/integration/v1/sites");
184    }
185
186    #[test]
187    fn substitute_path_rejects_a_wildcard_outside_the_allowed_prefix() {
188        let params = json!({ "path": "/etc/passwd" });
189
190        let err =
191            substitute_path("/proxy/*path", &params, &["/proxy/network/integration/"]).unwrap_err();
192
193        assert!(matches!(err, UnifiError::ConnectorPath(_)));
194    }
195
196    #[test]
197    fn validate_connector_path_rejects_traversal() {
198        assert!(validate_connector_path(
199            "/proxy/network/integration/../secret",
200            &["/proxy/network/integration/"]
201        )
202        .is_err());
203    }
204
205    #[test]
206    fn validate_connector_path_rejects_encoded_separators() {
207        assert!(validate_connector_path(
208            "/proxy/network/integration/%2e%2e",
209            &["/proxy/network/integration/"]
210        )
211        .is_err());
212    }
213
214    #[test]
215    fn validate_connector_path_accepts_an_allowed_path() {
216        assert!(validate_connector_path(
217            "/proxy/network/integration/v1/sites",
218            &["/proxy/network/integration/"]
219        )
220        .is_ok());
221    }
222
223    #[test]
224    fn encode_path_segment_leaves_unreserved_characters_untouched() {
225        assert_eq!(encode_path_segment("abc-123_ABC.~"), "abc-123_ABC.~");
226    }
227
228    #[test]
229    fn encode_path_segment_percent_encodes_everything_else() {
230        assert_eq!(encode_path_segment("a b"), "a%20b");
231    }
232}