Files
inbuxa-server/tests/src/system/security.rs
T
jcoffey-dev 1d5a49409f
ci / fork-checks (pull_request) Successful in 2m28s
ci / build (pull_request) Successful in 3m47s
Keep rotated log files for a set number of days
Personal-data catalog spec, default D1 (settled 2026-09-28): log files
were never deleted. inbuxa:LogSettings.keepForDays says how many days
rotated log files are kept; unset (null) keeps every file, as before,
and a new install sets 30 days.

It is a fork-owned setting, stored under T + l as audit retention is,
not a field on x:TracerLog: that object is also stored inside
x:Bootstrap with a field after it, so a new field would change
x:Bootstrap's stored format. Server-level, with the tracers'
permissions (sysTracerGet, sysTracerUpdate); changes are in the audit
log, before and after.

Log files are local, so every node deletes its own: hourly, and at once
when the setting changes on that node. Only regular files named
<prefix>.<something> in each enabled log tracer's directory, last
changed more than the limit ago, are removed; the file being written is
never that old, and nothing else in the directory is touched. Minimum
one day. The catalog classifies inbuxa:LogSettings and points the log
file's retention at it.

Tested: unit tests for the file rule (only this log's old files; the
current file, other files and directories stay) and a purge on disk;
the system suite, which reads, sets, refuses zero, restores null and
checks the audit records; fork checks.
2026-09-28 08:23:36 -07:00

467 lines
14 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
system::authentication::validate_password_with_ip,
utils::{
http::HttpRequest,
imap::{ImapConnection, Type},
registry::UnwrapRegistryId,
server::TestServer,
},
};
use common::ipc::RegistryChange;
use imap_proto::ResponseType;
use jmap_client::{
client::{Client, Credentials},
mailbox::{self},
};
use registry::{
schema::{
enums::BlockReason,
prelude::{ObjectType, Property},
structs::{Action, BlockedIp, Http, Jmap},
},
types::ipmask::IpAddrOrMask,
};
use serde_json::json;
use std::{net::Ipv4Addr, sync::Arc, time::Duration};
use store::{registry::write::RegistryWrite, write::now};
use types::id::Id;
pub async fn test(test: &mut TestServer) {
println!("Running Security tests...");
let admin = test.account("[email protected]");
// Set security settings
admin
.registry_update_setting(
Http {
use_x_forwarded: true,
..Default::default()
},
&[Property::UseXForwarded],
)
.await;
admin
.registry_update_setting(
Jmap {
max_concurrent_uploads: Some(4),
max_concurrent_requests: Some(8),
max_upload_size: 5000000,
..Default::default()
},
&[
Property::MaxConcurrentUploads,
Property::MaxConcurrentRequests,
Property::MaxUploadSize,
],
)
.await;
admin.reload_settings().await;
// Create a test user
let user = test
.create_user_account(
"[email protected]",
"[email protected]",
"this is a very strong password",
&[],
"[email protected]",
)
.await;
let user_id = user.id();
// Incorrect passwords should be rejected with a 401 error
assert!(matches!(
Client::new()
.credentials(Credentials::basic("[email protected]", "abcde"))
.accept_invalid_certs(true) .follow_redirects(["127.0.0.1"])
.connect("https://127.0.0.1:8899")
.await,
Err(jmap_client::Error::Problem(err)) if err.status() == Some(401)));
// Wait until the beginning of the 5 seconds bucket
const LIMIT: u64 = 5;
let now = now();
let range_start = now / LIMIT;
let range_end = (range_start * LIMIT) + LIMIT;
tokio::time::sleep(Duration::from_secs(range_end - now)).await;
// Make sure that the IP address is not blocked before the test
assert_eq!(
admin
.registry_query_ids(
ObjectType::BlockedIp,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new()
)
.await,
Vec::<Id>::new()
);
for _ in 0..98 {
validate_password_with_ip("[email protected]", "wrong password", "127.0.0.1", false)
.await;
}
let mut imap = ImapConnection::connect(b"_x ").await;
imap.send("AUTHENTICATE PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz")
.await;
imap.assert_read(Type::Tagged, ResponseType::No).await;
// There are already 100 failed login attempts for this IP address
// so the next one should be rejected, even if done over IMAP
imap.send("AUTHENTICATE PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz")
.await;
imap.assert_disconnect().await;
// Make sure the IP address is blocked
let blocked_id = test
.server
.registry()
.primary_key(
ObjectType::BlockedIp.into(),
Property::Address,
IpAddrOrMask::from_ip(Ipv4Addr::LOCALHOST.into()).to_index_key(),
)
.await
.unwrap()
.expect("Blocked IP should have been created after too many failed login attempts");
let blocked_ip = test
.server
.registry()
.object::<BlockedIp>(blocked_id.id())
.await
.unwrap()
.unwrap();
assert_eq!(blocked_ip.reason, BlockReason::AuthFailure);
ImapConnection::connect(b"_y ")
.await
.assert_disconnect()
.await;
// Lift ban
test.server
.registry()
.write(RegistryWrite::delete(blocked_id))
.await
.unwrap()
.unwrap_id(trc::location!());
test.server
.reload_registry(RegistryChange::Delete(blocked_id))
.await
.unwrap();
// Valid authentication requests should not be rate limited
for _ in 0..110 {
validate_password_with_ip(
"[email protected]",
"this is a very strong password",
"127.0.0.1",
true,
)
.await;
}
// Set fail2ban expiration
admin
.registry_update_object(
ObjectType::Security,
Id::singleton(),
// inbuxa: five seconds, not one: the checks below make a hundred
// more requests before the ban is tested, and under load a
// one-second ban expired first
json!({
Property::AuthBanPeriod: registry::types::duration::Duration::from_millis(5000)
}),
)
.await;
admin.reload_settings().await;
// Block IP 10.0.0.2
for _ in 0..105 {
validate_password_with_ip("[email protected]", "wrong password", "10.0.0.2", false).await;
}
validate_password_with_ip(
"[email protected]",
"this is a very strong password",
"10.0.0.2",
false,
)
.await;
// Check that the IP is blocked
let blocked_ids = admin
.registry_query_ids(
ObjectType::BlockedIp,
[(Property::Address, "10.0.0.2")],
Vec::<&str>::new(),
)
.await;
assert_eq!(blocked_ids.len(), 1);
let blocked_ip = admin.registry_get::<BlockedIp>(blocked_ids[0]).await;
assert_eq!(blocked_ip.reason, BlockReason::AuthFailure);
assert!(blocked_ip.expires_at.is_some());
// After the ban period the ban should be lifted
tokio::time::sleep(Duration::from_secs(6)).await;
validate_password_with_ip(
"[email protected]",
"this is a very strong password",
"10.0.0.2",
true,
)
.await;
// inbuxa: personal-data catalog, D2: the daily clean-up removes the
// expired ban's record, without waiting for settings to reload
test.server.purge_expired_blocked_ips().await.unwrap();
assert_eq!(
admin
.registry_query_ids(
ObjectType::BlockedIp,
[(Property::Address, "10.0.0.2")],
Vec::<&str>::new(),
)
.await,
Vec::<Id>::new(),
"the expired ban's record is gone"
);
// inbuxa: personal-data catalog, D1: how long rotated log files are kept.
// Unset keeps every file; a value is days; zero is refused; null goes back
let using = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"];
let account = admin.id_string().to_string();
let log_get = || {
admin.jmap_request(
using,
json!([["inbuxa:LogSettings/get", {"accountId": account, "ids": null}, "0"]]),
)
};
let log_set = |keep: serde_json::Value| {
admin.jmap_request(
using,
json!([["inbuxa:LogSettings/set", {"accountId": account,
"update": {"singleton": {"keepForDays": keep}}}, "0"]]),
)
};
let got = log_get().await;
assert_eq!(
got.0.pointer("/methodResponses/0/1/list/0/keepForDays"),
Some(&serde_json::Value::Null),
"a server that never set it keeps every file: {}",
got.0
);
let set = log_set(json!(14)).await;
assert!(set.0.pointer("/methodResponses/0/1/updated/singleton").is_some(), "{}", set.0);
let got = log_get().await;
assert_eq!(got.0.pointer("/methodResponses/0/1/list/0/keepForDays"), Some(&json!(14)));
let refused = log_set(json!(0)).await;
assert_eq!(
refused.0.pointer("/methodResponses/0/1/notUpdated/singleton/type"),
Some(&json!("invalidProperties")),
"{}",
refused.0
);
log_set(serde_json::Value::Null).await;
let got = log_get().await;
assert_eq!(
got.0.pointer("/methodResponses/0/1/list/0/keepForDays"),
Some(&serde_json::Value::Null)
);
// Each change is in the audit log, before and after
let query = admin
.jmap_request(
using,
json!([["inbuxa:AuditEvent/query", {"accountId": account,
"filter": {"targetKind": "inbuxa:LogSettings"}}, "0"]]),
)
.await;
assert!(
query.0.pointer("/methodResponses/0/1/ids").and_then(|ids| ids.as_array()).is_some_and(|ids| ids.len() >= 2),
"log settings changes aren't recorded: {}",
query.0
);
// Make sure the IP remains unblocked after reload
admin.registry_create_object(Action::ReloadBlockedIps).await;
validate_password_with_ip(
"[email protected]",
"this is a very strong password",
"10.0.0.2",
true,
)
.await;
// Login with the correct credentials
let client = Client::new()
.credentials(Credentials::basic(
"[email protected]",
"this is a very strong password",
))
.accept_invalid_certs(true)
.follow_redirects(["127.0.0.1"])
.connect("https://127.0.0.1:8899")
.await
.unwrap();
assert_eq!(client.session().username(), "[email protected]");
assert_eq!(
client
.session()
.account(&user_id.to_string())
.unwrap()
.name(),
"[email protected]"
);
assert!(
client
.session()
.account(&user_id.to_string())
.unwrap()
.is_personal()
);
// Uploads up to 5000000 bytes should be allowed
assert_eq!(
client
.upload(None, vec![b'A'; 5000000], None)
.await
.unwrap()
.size(),
5000000
);
assert!(
client
.upload(None, vec![b'A'; 5000001], None)
.await
.is_err()
);
// Concurrent requests check
let client = Arc::new(client);
let raw_http =
HttpRequest::with_credentials(8899, "[email protected]", "this is a very strong password");
for _ in 0..8 {
let client_ = client.clone();
tokio::spawn(async move {
let _ = client_
.mailbox_query(
mailbox::query::Filter::name("__sleep").into(),
[mailbox::query::Comparator::name()].into(),
)
.await;
});
}
tokio::time::sleep(Duration::from_millis(500)).await;
let body = serde_json::to_vec(&json!({
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Mailbox/query", {
"accountId": user_id.to_string(),
"filter": { "name": "__sleep" }
}, "c1"]
]
}))
.unwrap();
let resp = raw_http
.send_full(
hyper::Method::POST,
"/jmap/",
Some(body),
Some("application/json"),
)
.await;
assert_eq!(
resp.status.as_u16(),
400,
"concurrent-requests body: {}",
resp.body
);
let policy = resp
.rate_limit_policy()
.unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers));
assert!(
policy.contains("\"concurrent-requests\"") && policy.contains("q=8"),
"RateLimit-Policy = {policy}"
);
assert!(
policy.contains(r#"qu="concurrent-requests""#),
"RateLimit-Policy = {policy}"
);
let state = resp
.rate_limit()
.unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers));
assert!(
state.contains("\"concurrent-requests\"") && state.contains("r=0"),
"RateLimit = {state}"
);
// Wait for sleep to be done
tokio::time::sleep(Duration::from_millis(1000)).await;
// Concurrent upload test
for _ in 0..4 {
let client_ = client.clone();
tokio::spawn(async move {
client_.upload(None, b"sleep".to_vec(), None).await.unwrap();
});
}
tokio::time::sleep(Duration::from_millis(500)).await;
let resp = raw_http
.send_full(
hyper::Method::POST,
&format!("/jmap/upload/{user_id}"),
Some(b"sleep".to_vec()),
Some("application/octet-stream"),
)
.await;
assert_eq!(
resp.status.as_u16(),
400,
"concurrent-uploads body: {}",
resp.body
);
let policy = resp
.rate_limit_policy()
.unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers));
assert!(
policy.contains("\"concurrent-uploads\"") && policy.contains("q=4"),
"RateLimit-Policy = {policy}"
);
let state = resp
.rate_limit()
.unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers));
assert!(
state.contains("\"concurrent-uploads\"") && state.contains("r=0"),
"RateLimit = {state}"
);
// Wait for sleep to be done before continuing
tokio::time::sleep(Duration::from_millis(1000)).await;
// Disable X-Forwarded-For processing
admin
.registry_update_setting(
Http {
use_x_forwarded: false,
..Default::default()
},
&[Property::UseXForwarded],
)
.await;
admin.reload_settings().await;
// Destroy account
admin.destroy_account(user).await;
test.cleanup().await;
}