226 lines
8.7 KiB
Rust
226 lines
8.7 KiB
Rust
//! Signed, offline configuration schemas. Credentials belong to the vault broker.
|
|
use crate::workspace::{HostError, Result};
|
|
use serde_json::{json, Value};
|
|
|
|
fn walk(value: &Value, depth: usize, nodes: &mut usize, schema: bool) -> Result<()> {
|
|
if depth > 24 || *nodes >= 4096 {
|
|
return Err(HostError::new("EXTENSION_CONFIG_LIMIT"));
|
|
}
|
|
*nodes += 1;
|
|
match value {
|
|
Value::Object(map) => {
|
|
for (key, value) in map {
|
|
if schema && matches!(key.as_str(), "$ref" | "$dynamicRef" | "$recursiveRef") {
|
|
// Recursive/unbounded schema execution is not allowed in the Host.
|
|
return Err(HostError::new("EXTENSION_CONFIG_REFERENCE"));
|
|
}
|
|
if !schema
|
|
&& matches!(
|
|
key.to_ascii_lowercase().as_str(),
|
|
"api_key"
|
|
| "apikey"
|
|
| "password"
|
|
| "token"
|
|
| "secret"
|
|
| "access_token"
|
|
| "refresh_token"
|
|
| "authorization"
|
|
| "client_secret"
|
|
| "private_key"
|
|
)
|
|
{
|
|
return Err(HostError::new("EXTENSION_CONFIG_SECRET"));
|
|
}
|
|
walk(value, depth + 1, nodes, schema)?;
|
|
}
|
|
}
|
|
Value::Array(items) => {
|
|
for item in items {
|
|
walk(item, depth + 1, nodes, schema)?;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Examine every applicable schema branch; an alternative branch must not
|
|
// turn a secret declaration back into persistable plaintext.
|
|
fn reject_secrets(schema: &Value, instance: &Value) -> Result<()> {
|
|
let Some(map) = schema.as_object() else {
|
|
return Ok(());
|
|
};
|
|
if map.get("writeOnly") == Some(&Value::Bool(true))
|
|
|| map.get("x-opennexus-secret") == Some(&Value::Bool(true))
|
|
{
|
|
return Err(HostError::new("EXTENSION_CONFIG_SECRET"));
|
|
}
|
|
for key in ["allOf", "anyOf", "oneOf"] {
|
|
if let Some(items) = map.get(key).and_then(Value::as_array) {
|
|
for item in items {
|
|
reject_secrets(item, instance)?;
|
|
}
|
|
}
|
|
}
|
|
for key in ["if", "then", "else", "not"] {
|
|
if let Some(child) = map.get(key) {
|
|
reject_secrets(child, instance)?;
|
|
}
|
|
}
|
|
if let Some(properties) = instance.as_object() {
|
|
for (name, value) in properties {
|
|
if let Some(child) = map.get("unevaluatedProperties") {
|
|
reject_secrets(child, value)?;
|
|
}
|
|
let declared = map.get("properties").and_then(|p| p.get(name));
|
|
if let Some(child) = declared {
|
|
reject_secrets(child, value)?;
|
|
}
|
|
let mut matched = false;
|
|
if let Some(patterns) = map.get("patternProperties").and_then(Value::as_object) {
|
|
for (pattern, child) in patterns {
|
|
let matcher = jsonschema::options()
|
|
.offline()
|
|
.with_pattern_options(jsonschema::PatternOptions::regex())
|
|
.build(&json!({"pattern":pattern}))
|
|
.map_err(|_| HostError::new("EXTENSION_CONFIG_SCHEMA"))?;
|
|
if matcher.is_valid(&Value::String(name.clone())) {
|
|
matched = true;
|
|
reject_secrets(child, value)?;
|
|
}
|
|
}
|
|
}
|
|
if declared.is_none() && !matched {
|
|
if let Some(child) = map.get("additionalProperties") {
|
|
reject_secrets(child, value)?;
|
|
}
|
|
}
|
|
}
|
|
if let Some(dependent) = map.get("dependentSchemas").and_then(Value::as_object) {
|
|
for (name, child) in dependent {
|
|
if properties.contains_key(name) {
|
|
reject_secrets(child, instance)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(items) = instance.as_array() {
|
|
let prefix = map.get("prefixItems").and_then(Value::as_array);
|
|
for (index, item) in items.iter().enumerate() {
|
|
if let Some(child) = map.get("unevaluatedItems") {
|
|
reject_secrets(child, item)?;
|
|
}
|
|
if let Some(child) = prefix
|
|
.and_then(|p| p.get(index))
|
|
.or_else(|| map.get("items"))
|
|
{
|
|
reject_secrets(child, item)?;
|
|
}
|
|
if let Some(child) = map.get("contains") {
|
|
reject_secrets(child, item)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Schema is read from the verified manifest, never from the proposed configuration.
|
|
pub fn validate(manifest: &Value, configuration: &Value) -> Result<()> {
|
|
if !configuration.is_object() {
|
|
return Err(HostError::new("EXTENSION_CONFIG_INVALID"));
|
|
}
|
|
walk(configuration, 0, &mut 0, false)?;
|
|
let schema = manifest
|
|
.get("configuration_schema")
|
|
.cloned()
|
|
.unwrap_or_else(|| json!({"type":"object","additionalProperties":false}));
|
|
walk(&schema, 0, &mut 0, true)?;
|
|
if schema
|
|
.get("$schema")
|
|
.is_some_and(|v| v.as_str() != Some("https://json-schema.org/draft/2020-12/schema"))
|
|
{
|
|
return Err(HostError::new("EXTENSION_CONFIG_SCHEMA"));
|
|
}
|
|
for value in [&schema, configuration] {
|
|
if serde_json::to_vec(value)
|
|
.map_err(|_| HostError::new("EXTENSION_CONFIG_INVALID"))?
|
|
.len()
|
|
> 64 * 1024
|
|
{
|
|
return Err(HostError::new("EXTENSION_CONFIG_LIMIT"));
|
|
}
|
|
}
|
|
let validator = jsonschema::options()
|
|
.offline()
|
|
.with_draft(jsonschema::Draft::Draft202012)
|
|
.with_pattern_options(jsonschema::PatternOptions::regex())
|
|
.should_validate_formats(true)
|
|
.should_ignore_unknown_formats(false)
|
|
.build(&schema)
|
|
.map_err(|_| HostError::new("EXTENSION_CONFIG_SCHEMA"))?;
|
|
if !validator.is_valid(configuration) {
|
|
return Err(HostError::new("EXTENSION_CONFIG_INVALID"));
|
|
}
|
|
reject_secrets(&schema, configuration)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn signed_schema_checks_nested_types_bounds_and_unknown_properties() {
|
|
let manifest = json!({"configuration_schema":{"type":"object","required":["count"],"additionalProperties":false,
|
|
"properties":{"count":{"type":"integer","minimum":1,"maximum":5},"labels":{"type":"array","maxItems":2,"items":{"type":"string","pattern":"^[a-z]+$"}}}}});
|
|
validate(&manifest, &json!({"count":2,"labels":["valid"]})).unwrap();
|
|
for config in [
|
|
json!({}),
|
|
json!({"count":0}),
|
|
json!({"count":"2"}),
|
|
json!({"count":2,"extra":true}),
|
|
json!({"count":2,"labels":["INVALID"]}),
|
|
] {
|
|
assert!(validate(&manifest, &config).is_err());
|
|
}
|
|
validate(&json!({}), &json!({})).unwrap();
|
|
assert!(validate(&json!({}), &json!({"undeclared":true})).is_err());
|
|
}
|
|
#[test]
|
|
fn secrets_references_and_excessive_work_are_refused_without_values_in_errors() {
|
|
let secret = "do-not-print-this-value";
|
|
for field in ["password", "TOKEN", "api_key", "client_secret"] {
|
|
let err = validate(
|
|
&json!({"configuration_schema":true}),
|
|
&json!({"nested":[{field:secret}]}),
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(err.code, "EXTENSION_CONFIG_SECRET");
|
|
assert!(!format!("{err:?}").contains(secret));
|
|
}
|
|
for mark in ["writeOnly", "x-opennexus-secret"] {
|
|
let schema = json!({"configuration_schema":{"properties":{"custom":{"type":"string",mark:true}}}});
|
|
assert!(validate(&schema, &json!({"custom":secret})).is_err());
|
|
validate(&schema, &json!({})).unwrap();
|
|
}
|
|
assert!(validate(&json!({"configuration_schema":{"anyOf":[{"properties":{"custom":{"writeOnly":true}}},true]}}),&json!({"custom":secret})).is_err());
|
|
for reference in ["file:///etc/passwd", "https://example.com/schema", "#"] {
|
|
assert!(validate(
|
|
&json!({"configuration_schema":{"$ref":reference}}),
|
|
&json!({})
|
|
)
|
|
.is_err());
|
|
}
|
|
assert!(validate(&json!({"configuration_schema":{"type":42}}), &json!({})).is_err());
|
|
let mut deep = json!(true);
|
|
for _ in 0..25 {
|
|
deep = json!({"nested":deep});
|
|
}
|
|
assert!(validate(&json!({"configuration_schema":true}), &deep).is_err());
|
|
assert!(validate(
|
|
&json!({"configuration_schema":true}),
|
|
&json!({"value":"a".repeat(65536)})
|
|
)
|
|
.is_err());
|
|
}
|
|
}
|