Audit mail sent from an address that isn't the sender's own #144
No files matched your search
@@ -445,6 +445,58 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MA-D0a: a message sent from an address that isn't the sender's own:
|
||||||
|
/// a group's, today. The message itself only says `From:` the group, so
|
||||||
|
/// the audit log is where the person who sent it is named. A delegate's
|
||||||
|
/// send is AL-9's record, not this one.
|
||||||
|
pub async fn audit_send_as(
|
||||||
|
&self,
|
||||||
|
token: &AccessToken,
|
||||||
|
submission_account_id: u32,
|
||||||
|
submission_id: u32,
|
||||||
|
address: &str,
|
||||||
|
) {
|
||||||
|
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if as_account_id == token.account_id() || token.delegation(as_account_id).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let actor = self.audit_actor(token).await;
|
||||||
|
let tenant_id = self
|
||||||
|
.account(as_account_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|account| account.id_tenant);
|
||||||
|
let details = if submission_account_id == as_account_id {
|
||||||
|
format!("Sent as {address}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Sent as {address}, from {}",
|
||||||
|
self.audit_account_name(submission_account_id).await
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.audit_note(Record {
|
||||||
|
at: ms(),
|
||||||
|
actor,
|
||||||
|
via: token.origin().cloned(),
|
||||||
|
remote_ip: None,
|
||||||
|
action: Action::Create,
|
||||||
|
target: Target {
|
||||||
|
kind: "EmailSubmission".into(),
|
||||||
|
id: Some(Id::from(submission_id).to_string()),
|
||||||
|
name: Some(address.to_string()),
|
||||||
|
account_id: Some(as_account_id),
|
||||||
|
tenant_id,
|
||||||
|
},
|
||||||
|
changes: vec![],
|
||||||
|
details: Some(details),
|
||||||
|
reason: None,
|
||||||
|
outcome: Outcome::success(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// AU-7: removes entries past the retention period.
|
/// AU-7: removes entries past the retention period.
|
||||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||||
let settings = log::settings(self.store()).await?;
|
let settings = log::settings(self.store()).await?;
|
||||||
|
|||||||
@@ -783,7 +783,7 @@ impl RequestHandler for Server {
|
|||||||
// inbuxa: AL-8: a delegate may send as a locked account
|
// inbuxa: AL-8: a delegate may send as a locked account
|
||||||
access_token.assert_can_send(req.account_id)?;
|
access_token.assert_can_send(req.account_id)?;
|
||||||
|
|
||||||
self.email_submission_set(*req, &session.instance, next_call)
|
self.email_submission_set(*req, access_token, &session.instance, next_call)
|
||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
Server,
|
Server,
|
||||||
|
auth::AccessToken,
|
||||||
config::smtp::queue::QueueName,
|
config::smtp::queue::QueueName,
|
||||||
network::{ServerInstance, stream::NullIo},
|
network::{ServerInstance, stream::NullIo},
|
||||||
storage::index::ObjectIndexBuilder,
|
storage::index::ObjectIndexBuilder,
|
||||||
@@ -49,6 +50,7 @@ pub trait EmailSubmissionSet: Sync + Send {
|
|||||||
fn email_submission_set<'x>(
|
fn email_submission_set<'x>(
|
||||||
&self,
|
&self,
|
||||||
request: SetRequest<'x, email_submission::EmailSubmission>,
|
request: SetRequest<'x, email_submission::EmailSubmission>,
|
||||||
|
access_token: &AccessToken,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||||
) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send;
|
) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send;
|
||||||
@@ -68,6 +70,7 @@ impl EmailSubmissionSet for Server {
|
|||||||
async fn email_submission_set<'x>(
|
async fn email_submission_set<'x>(
|
||||||
&self,
|
&self,
|
||||||
mut request: SetRequest<'x, email_submission::EmailSubmission>,
|
mut request: SetRequest<'x, email_submission::EmailSubmission>,
|
||||||
|
access_token: &AccessToken,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||||
) -> trc::Result<SetResponse<email_submission::EmailSubmission>> {
|
) -> trc::Result<SetResponse<email_submission::EmailSubmission>> {
|
||||||
@@ -110,6 +113,15 @@ impl EmailSubmissionSet for Server {
|
|||||||
.assign_document_ids(account_id, Collection::EmailSubmission, 1)
|
.assign_document_ids(account_id, Collection::EmailSubmission, 1)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?;
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: MA-D0a: who sent it, when it went out as someone else
|
||||||
|
self.audit_send_as(
|
||||||
|
access_token,
|
||||||
|
account_id,
|
||||||
|
document_id,
|
||||||
|
&submission.envelope.mail_from.email,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
batch
|
batch
|
||||||
.with_account_id(account_id)
|
.with_account_id(account_id)
|
||||||
.with_collection(Collection::EmailSubmission)
|
.with_collection(Collection::EmailSubmission)
|
||||||
|
|||||||
@@ -540,10 +540,110 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
"AU-7: continued"
|
"AU-7: continued"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// MA-D0a: a group member sending as the group is named in the log; the
|
||||||
|
// same person sending as themselves isn't recorded
|
||||||
|
let group = admin
|
||||||
|
.create_group_account("[email protected]", "Help desk", &[])
|
||||||
|
.await;
|
||||||
|
let agent = admin
|
||||||
|
.create_user_account(
|
||||||
|
"[email protected]",
|
||||||
|
"agent-secret-for-send-as",
|
||||||
|
"Agent",
|
||||||
|
&[],
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Account,
|
||||||
|
agent.id(),
|
||||||
|
json!({"memberGroupIds": {group.id_string(): true}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
agent.send_as("[email protected]").await;
|
||||||
|
agent.send_as("[email protected]").await;
|
||||||
|
let sends = admin
|
||||||
|
.audit(json!({
|
||||||
|
"targetKind": "EmailSubmission",
|
||||||
|
"actorId": agent.id_string(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(sends.len(), 1, "MA-D0a: {sends:?}");
|
||||||
|
assert_eq!(sends[0]["target"]["name"], "[email protected]");
|
||||||
|
assert_eq!(
|
||||||
|
sends[0]["target"]["accountId"],
|
||||||
|
group.id_string(),
|
||||||
|
"MA-D0a: {}",
|
||||||
|
sends[0]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sends[0]["details"],
|
||||||
|
"Sent as [email protected], from [email protected]"
|
||||||
|
);
|
||||||
|
|
||||||
// Clean up what later suites could trip over
|
// Clean up what later suites could trip over
|
||||||
admin.registry_destroy_all(ObjectType::BlockedIp).await;
|
admin.registry_destroy_all(ObjectType::BlockedIp).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// Sends one message to itself from its own account, as `from`.
|
||||||
|
async fn send_as(&self, from: &str) {
|
||||||
|
const USING: &[&str] = &[
|
||||||
|
"urn:ietf:params:jmap:core",
|
||||||
|
"urn:ietf:params:jmap:mail",
|
||||||
|
"urn:ietf:params:jmap:submission",
|
||||||
|
];
|
||||||
|
let account_id = self.id_string();
|
||||||
|
let response = self
|
||||||
|
.jmap_request(
|
||||||
|
USING,
|
||||||
|
json!([
|
||||||
|
["Identity/get", {"accountId": account_id}, "i"],
|
||||||
|
["Mailbox/get", {"accountId": account_id, "properties": ["role"]}, "m"]
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let identity = response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/0/1/list")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|list| list.iter().find(|identity| identity["email"] == from))
|
||||||
|
.unwrap_or_else(|| panic!("no identity for {from}: {}", response.0))["id"]
|
||||||
|
.clone();
|
||||||
|
let drafts = response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/1/1/list")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|list| list.iter().find(|mailbox| mailbox["role"] == "drafts"))
|
||||||
|
.unwrap_or_else(|| panic!("no drafts: {}", response.0))["id"]
|
||||||
|
.clone();
|
||||||
|
let response = self
|
||||||
|
.jmap_request(
|
||||||
|
USING,
|
||||||
|
json!([
|
||||||
|
["Email/set", {"accountId": account_id, "create": {"m": {
|
||||||
|
"mailboxIds": {drafts.as_str().unwrap(): true},
|
||||||
|
"from": [{"email": from}],
|
||||||
|
"to": [{"email": self.name()}],
|
||||||
|
"subject": format!("Sent as {from}"),
|
||||||
|
"bodyValues": {"t": {"value": "MA-D0a"}},
|
||||||
|
"textBody": [{"partId": "t", "type": "text/plain"}]
|
||||||
|
}}}, "e"],
|
||||||
|
["EmailSubmission/set", {"accountId": account_id, "create": {"s": {
|
||||||
|
"identityId": identity, "emailId": "#m"
|
||||||
|
}}}, "s"]
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response.0.pointer("/methodResponses/1/1/created/s").is_some(),
|
||||||
|
"send as {from}: {}",
|
||||||
|
response.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`.
|
/// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`.
|
||||||
#[ignore]
|
#[ignore]
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
|||||||
Reference in new issue
Block a user