Never save "****" as a secret #206

Merged
jcoffey-dev merged 1 commits from fix/keep-masked-secrets into main 2026-10-18 22:21:30 +00:00
5 changed files with 292 additions and 0 deletions

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! A stored secret reads back as `****` (`MASKED_PASSWORD`), so a client
//! that writes back what it read sends `****` where the secret was. That is
//! never saved as the secret. Written straight at a secret, it means "keep
//! the stored one"; anywhere else (a create, a variant switch, a whole
//! object replaced) the write is refused, since there is no stored secret
//! it could stand for.
//!
//! Accounts are left to their own credential checks, which already keep a
//! credential written back as `****`.
use jmap_proto::error::set::SetError;
use jmap_tools::{JsonPointer, JsonPointerItem, Key};
use registry::{
jmap::JmapValue,
schema::prelude::{MASKED_PASSWORD, Property},
};
fn is_secret_key(key: &Key<'_, Property>) -> bool {
match key {
Key::Property(property) => *property == Property::Secret,
Key::Borrowed(name) => *name == "secret",
Key::Owned(name) => name == "secret",
}
}
fn is_masked(value: &JmapValue<'_>) -> bool {
value.as_str().is_some_and(|value| value == MASKED_PASSWORD)
}
/// Whether `value` holds `****` as a secret anywhere inside it.
pub fn holds_masked_secret(value: &JmapValue<'_>) -> bool {
match value {
JmapValue::Object(map) => map.iter().any(|(key, value)| {
(is_secret_key(key) && is_masked(value)) || holds_masked_secret(value)
}),
JmapValue::Array(items) => items.iter().any(holds_masked_secret),
_ => false,
}
}
/// Whether a patch at `pointer` writes `****` straight at a secret: the
/// client sent back what it read, so the stored secret stays.
pub fn keeps_stored_secret(pointer: &JsonPointer<Property>, value: &JmapValue<'_>) -> bool {
is_masked(value)
&& matches!(pointer.last(), Some(JsonPointerItem::Key(key)) if is_secret_key(key))
}
/// The top-level property a write at `pointer` goes to, for the error.
pub fn top_level_property(pointer: &JsonPointer<Property>) -> Option<Property> {
pointer.iter().find_map(|item| match item {
JsonPointerItem::Key(Key::Property(property)) => Some(property.clone()),
_ => None,
})
}
/// For a whole-object write (a create or a variant switch): the first
/// top-level property holding `****` as a secret, if any.
pub fn masked_secret_in_object(value: &JmapValue<'_>) -> Option<Option<Property>> {
let map = value.as_object()?;
map.iter().find_map(|(key, value)| {
((is_secret_key(key) && is_masked(value)) || holds_masked_secret(value)).then(
|| match key {
Key::Property(property) => Some(property.clone()),
_ => None,
},
)
})
}
pub fn masked_secret_error(property: Option<Property>) -> SetError<Property> {
let error = SetError::invalid_properties().with_description(concat!(
"\"****\" stands for a stored secret and can't be saved as one. ",
"Send the secret itself, or leave the field out to keep the stored one."
));
match property {
Some(property) => error.with_property(property),
None => error,
}
}
+1
View File
@@ -28,6 +28,7 @@ pub mod data_inventory;
pub mod directory_test;
pub mod webhook_test;
pub mod explanation;
pub mod masked_secrets; // inbuxa: "****" is never saved as a secret
pub mod protocol_policy;
pub mod tenant_protocol_policy;
pub mod sharing_policy;
+28
View File
@@ -6,6 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::inbuxa::masked_secrets;
use crate::registry::{
EnterpriseRegistry,
mapping::{
@@ -348,6 +349,10 @@ impl RegistrySet for Server {
continue 'outer;
};
// inbuxa: "****" is never saved as a secret (accounts keep
// their own credential checks)
let guard_masked_secrets = object_type != ObjectType::Account;
if is_create
|| value
.as_object()
@@ -356,6 +361,13 @@ impl RegistrySet for Server {
.and_then(|v| v.as_str())
.is_some_and(|t| new_object.object_variant().is_some_and(|v| v != t))
{
if guard_masked_secrets
&& let Some(property) = masked_secrets::masked_secret_in_object(&value)
{
set.failed(modification, masked_secrets::masked_secret_error(property));
continue 'outer;
}
// Patch object
match new_object.patch(
JsonPointerPatch::new(&JsonPointer::new(vec![]))
@@ -411,6 +423,22 @@ impl RegistrySet for Server {
Key::Owned(other) => JsonPointer::parse(&other),
};
// inbuxa: "****" written back at a secret keeps it;
// anywhere deeper there is nothing it could stand for
if guard_masked_secrets {
if masked_secrets::keeps_stored_secret(&ptr, &value) {
continue;
} else if masked_secrets::holds_masked_secret(&value) {
set.failed(
modification,
masked_secrets::masked_secret_error(
masked_secrets::top_level_property(&ptr),
),
);
continue 'outer;
}
}
// Patch object
match new_object.patch(
JsonPointerPatch::new(&ptr)
+177
View File
@@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! A stored secret reads back as "****", and "****" is never saved as one:
//! written straight at a secret it keeps the stored one, anywhere else the
//! write is refused.
use crate::utils::{account::Account, server::TestServerBuilder};
use common::Server;
use registry::{
schema::{
prelude::{ObjectInner, ObjectType},
structs::{HttpAuth, SecretKey, SecretKeyOptional, WebHook},
},
types::id::ObjectId,
};
use serde_json::{Value, json};
use std::str::FromStr;
use types::id::Id;
const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"];
async fn webhook_set(admin: &Account, arguments: Value) -> Value {
let mut arguments = arguments;
arguments["accountId"] = admin.id_string().into();
let response = admin
.jmap_request(USING, json!([["x:WebHook/set", arguments, "0"]]))
.await;
let call = response.0.pointer("/methodResponses/0").cloned().unwrap();
assert_eq!(call[0], "x:WebHook/set", "{call}");
call[1].clone()
}
/// The webhook's Basic password and signature key as stored.
async fn stored_secrets(server: &Server, id: &str) -> (String, String) {
let object = server
.registry()
.get(ObjectId::new(
ObjectType::WebHook,
Id::from_str(id).unwrap(),
))
.await
.unwrap()
.expect("webhook not found");
let ObjectInner::WebHook(WebHook {
http_auth: HttpAuth::Basic(basic),
signature_key,
..
}) = object.inner
else {
panic!("not a webhook with Basic auth");
};
let SecretKey::Value(password) = basic.secret else {
panic!("password not stored as a value");
};
let SecretKeyOptional::Value(signature) = signature_key else {
panic!("signature key not stored as a value");
};
(password.secret, signature.secret)
}
fn assert_refused(response: &Value, key: &str, id: &str, property: &str) {
let error = &response[key][id];
assert_eq!(error["type"], "invalidProperties", "{response}");
assert_eq!(error["properties"], json!([property]), "{response}");
}
#[tokio::test(flavor = "multi_thread")]
pub async fn masked_secrets_tests() {
let test = TestServerBuilder::new("masked_secrets_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
println!("Running masked secret tests...");
let response = webhook_set(
&admin,
json!({"create": {"hook": {
"url": "https://hooks.example.org/inbuxa",
"enable": false,
"httpAuth": {"@type": "Basic", "username": "inbuxa",
"secret": {"@type": "Value", "secret": "basic-password"}},
"signatureKey": {"@type": "Value", "secret": "signing-key"}
}}}),
)
.await;
let id = response["created"]["hook"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
let original = ("basic-password".to_string(), "signing-key".to_string());
assert_eq!(stored_secrets(&test.server, &id).await, original);
// Read back, the secrets are masked
let response = admin
.jmap_request(
USING,
json!([["x:WebHook/get", {"accountId": admin.id_string(), "ids": [&id]}, "0"]]),
)
.await;
let hook = response
.0
.pointer("/methodResponses/0/1/list/0")
.cloned()
.unwrap();
assert_eq!(hook["httpAuth"]["secret"]["secret"], "****", "{hook}");
assert_eq!(hook["signatureKey"]["secret"], "****", "{hook}");
// "****" straight at a secret keeps it, and the rest of the update applies
let response = webhook_set(
&admin,
json!({"update": {&id: {
"url": "https://hooks.example.org/changed",
"httpAuth/secret/secret": "****",
"signatureKey/secret": "****"
}}}),
)
.await;
assert!(response["updated"].get(&id).is_some(), "{response}");
assert_eq!(stored_secrets(&test.server, &id).await, original);
// Inside a whole object, there is no stored secret it could stand for
for (patch, property) in [
(
json!({"httpAuth/secret": {"@type": "Value", "secret": "****"}}),
"httpAuth",
),
(
json!({"signatureKey": {"@type": "Value", "secret": "****"}}),
"signatureKey",
),
(
json!({"httpAuth": {"@type": "Basic", "username": "inbuxa",
"secret": {"@type": "Value", "secret": "****"}}}),
"httpAuth",
),
] {
let response = webhook_set(&admin, json!({"update": {&id: patch}})).await;
assert_refused(&response, "notUpdated", &id, property);
assert_eq!(stored_secrets(&test.server, &id).await, original);
}
// Nor in a new object
let response = webhook_set(
&admin,
json!({"create": {"copy": {
"url": "https://hooks.example.org/copy",
"enable": false,
"httpAuth": {"@type": "Basic", "username": "inbuxa",
"secret": {"@type": "Value", "secret": "****"}},
}}}),
)
.await;
assert_refused(&response, "notCreated", "copy", "httpAuth");
// A real secret is still saved
let response = webhook_set(
&admin,
json!({"update": {&id: {"httpAuth/secret/secret": "new-password"}}}),
)
.await;
assert!(response["updated"].get(&id).is_some(), "{response}");
assert_eq!(
stored_secrets(&test.server, &id).await,
("new-password".to_string(), "signing-key".to_string())
);
if test.is_reset() {
test.temp_dir.delete();
}
}
+1
View File
@@ -29,6 +29,7 @@ pub mod crypto;
pub mod delivery;
pub mod directory;
pub mod masked_email;
pub mod masked_secrets; // inbuxa: "****" is never saved as a secret
pub mod monitoring;
pub mod oidc;
pub mod purge;