use serde_json::Value;
use super::error::ProfileError;
const FORBIDDEN_KEYS: &[&str] = &[
"fn",
"function",
"js",
"javascript",
"lua",
"script",
"command",
"apply",
"normalize",
];
pub(crate) fn scan(value: &Value) -> Result<(), ProfileError> {
scan_value(value, None, "")
}
fn scan_value(value: &Value, key: Option<&str>, path: &str) -> Result<(), ProfileError> {
match value {
Value::Object(map) => {
for (k, v) in map {
if FORBIDDEN_KEYS.iter().any(|f| k.eq_ignore_ascii_case(f)) {
return Err(ProfileError::ForbiddenKey(k.clone()));
}
let child = if path.is_empty() {
k.clone()
} else {
format!("{path}.{k}")
};
scan_value(v, Some(k.as_str()), &child)?;
}
Ok(())
}
Value::Array(arr) => {
for (i, v) in arr.iter().enumerate() {
let child = format!("{path}[{i}]");
scan_value(v, key, &child)?;
}
Ok(())
}
Value::String(s) => scan_string(s, key, path),
_ => Ok(()),
}
}
fn scan_string(s: &str, key: Option<&str>, path: &str) -> Result<(), ProfileError> {
if is_native_module_path(s) {
return Err(ProfileError::NativeModule {
field: path.to_string(),
});
}
let key = key.unwrap_or("");
if is_url_field(key) {
check_url(path, s)?;
}
refuse_interpolation(s, path)?;
Ok(())
}
fn is_hint_path(path: &str) -> bool {
matches!(
path,
"display_name" | "displayName" | "oauth.setup_token_hint" | "oauth.setupTokenHint"
)
}
fn is_url_field(key: &str) -> bool {
matches!(
key,
"base_url"
| "baseUrl"
| "token_url"
| "tokenUrl"
| "token_url_fallback"
| "tokenUrlFallback"
| "authorize_url"
| "authorizeUrl"
| "device_auth_url"
| "deviceAuthUrl"
| "redirect_uri"
| "redirectUri"
| "chat_path"
| "chatPath"
)
}
fn scheme_of(s: &str) -> Option<&str> {
let colon = s.find(':')?;
let scheme = &s[..colon];
if scheme.is_empty() || !scheme.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
Some(scheme)
}
fn check_url(field: &str, raw: &str) -> Result<(), ProfileError> {
let s = raw.trim();
let Some(scheme) = scheme_of(s) else {
return Ok(());
};
let scheme_l = scheme.to_ascii_lowercase();
let allowed = match scheme_l.as_str() {
"https" => true,
"http" => is_loopback_http(s),
_ => false,
};
if allowed {
Ok(())
} else {
Err(ProfileError::DisallowedUrl {
field: field.to_string(),
url: raw.to_string(),
})
}
}
pub(crate) fn is_loopback_http(url: &str) -> bool {
let Some((_, rest)) = url.split_once(':') else {
return false;
};
let Some(after_slashes) = rest.strip_prefix("//") else {
return false;
};
let authority = after_slashes
.split(['/', '?', '#'])
.next()
.unwrap_or(after_slashes);
let without_userinfo = match authority.rfind('@') {
Some(i) => &authority[i + 1..],
None => authority,
};
let hostport = without_userinfo
.split(['/', '?', '#'])
.next()
.unwrap_or(without_userinfo);
let host = if let Some(inner) = hostport.strip_prefix('[') {
match inner.split_once(']') {
Some((h, _)) => h,
None => return false,
}
} else {
hostport.split(':').next().unwrap_or(hostport)
};
host.eq_ignore_ascii_case("localhost")
|| host.eq_ignore_ascii_case("localhost.")
|| host == "127.0.0.1"
|| host == "::1"
}
fn refuse_interpolation(s: &str, path: &str) -> Result<(), ProfileError> {
let trimmed = s.trim_start();
let bang = trimmed.starts_with('!');
let dollar = has_dollar_paren(s);
let backtick = s.contains('`');
let trigger = if bang {
Some("!")
} else if dollar {
Some("$(...)")
} else if backtick && !is_hint_path(path) {
Some("backticks")
} else {
None
};
if let Some(trigger) = trigger {
return Err(ProfileError::Interpolation {
field: path.to_string(),
trigger,
});
}
Ok(())
}
fn has_dollar_paren(s: &str) -> bool {
let bytes = s.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'$' && bytes[i + 1] == b'(' {
return bytes[i + 2..].contains(&b')');
}
i += 1;
}
false
}
fn is_native_module_path(s: &str) -> bool {
let path = s.trim().split(['?', '#']).next().unwrap_or(s).trim();
let lower = path.to_ascii_lowercase();
lower.ends_with(".wasm")
|| lower.ends_with(".dylib")
|| lower.ends_with(".so")
|| lower.ends_with(".dll")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_hosts() {
assert!(is_loopback_http("http://localhost:11434"));
assert!(is_loopback_http("http://127.0.0.1"));
assert!(is_loopback_http("http://[::1]:80/cb"));
assert!(!is_loopback_http("http://192.0.2.1"));
assert!(!is_loopback_http("http://127.0.0.1.example"));
assert!(
!is_loopback_http("http://evil.com#@127.0.0.1"),
"fragment @ is not userinfo"
);
assert!(
!is_loopback_http("http://evil.com?@127.0.0.1"),
"query @ is not userinfo"
);
assert!(is_loopback_http("http://127.0.0.1#@evil.com"));
assert!(is_loopback_http("http://user:pass@127.0.0.1/cb"));
let err = check_url("base_url", "http://evil.com#@127.0.0.1").unwrap_err();
assert!(matches!(err, ProfileError::DisallowedUrl { .. }), "{err}");
}
#[test]
fn hint_path_is_document_fields_only() {
assert!(is_hint_path("display_name"));
assert!(is_hint_path("oauth.setup_token_hint"));
assert!(!is_hint_path("headers.setup_token_hint"));
assert!(!is_hint_path("setup_token_hint"));
}
}