@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Add basic support for a "Must Encrypt" mail flag which prevents unsecured content transmission

Summary:
Ref T13053. See PHI291. For particularly sensitive objects (like security issues), installs may reasonably wish to prevent details from being sent in plaintext over email.

This adds a "Must Encrypt" mail behavior, which discards mail content and all identifying details, replacing it with a link to the `/mail/` application. Users can follow the link to view the message over HTTPS.

The flag discards body content, attachments, and headers which imply things about the content of the object. It retains threading headers and headers which may uniquely identify the object as long as they don't disclose anyting about the content.

The `bin/mail list-outbound` command now flags these messages with a `#` mark.

The `bin/mail show-outbound` command now shows sent/suppressed headers and the body content as delivered (if it differs from the original body content).

The `/mail/` web UI now shows a tag for messages marked with this flag.

For now, there is no way to actually set this flag on mail.

Test Plan:
- Forced this flag on, made comments and took actions to send mail.
- Reviewed mail with `bin/mail` and `/mail/` in the web UI, saw all content information omitted.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13053

Differential Revision: https://secure.phabricator.com/D18983

+197 -25
+17
src/applications/metamta/controller/PhabricatorMetaMTAMailViewController.php
··· 32 32 $color = PhabricatorMailOutboundStatus::getStatusColor($status); 33 33 $header->setStatus($icon, $color, $name); 34 34 35 + if ($mail->getMustEncrypt()) { 36 + Javelin::initBehavior('phabricator-tooltips'); 37 + $header->addTag( 38 + id(new PHUITagView()) 39 + ->setType(PHUITagView::TYPE_SHADE) 40 + ->setColor('blue') 41 + ->setName(pht('Must Encrypt')) 42 + ->setIcon('fa-shield blue') 43 + ->addSigil('has-tooltip') 44 + ->setMetadata( 45 + array( 46 + 'tip' => pht( 47 + 'Message content can only be transmitted over secure '. 48 + 'channels.'), 49 + ))); 50 + } 51 + 35 52 $crumbs = $this->buildApplicationCrumbs() 36 53 ->addTextCrumb(pht('Mail %d', $mail->getID())) 37 54 ->setBorder(true);
+2
src/applications/metamta/management/PhabricatorMailManagementListOutboundWorkflow.php
··· 37 37 $table = id(new PhutilConsoleTable()) 38 38 ->setShowHeader(false) 39 39 ->addColumn('id', array('title' => pht('ID'))) 40 + ->addColumn('encrypt', array('title' => pht('#'))) 40 41 ->addColumn('status', array('title' => pht('Status'))) 41 42 ->addColumn('subject', array('title' => pht('Subject'))); 42 43 ··· 45 46 46 47 $table->addRow(array( 47 48 'id' => $mail->getID(), 49 + 'encrypt' => ($mail->getMustEncrypt() ? '#' : ' '), 48 50 'status' => PhabricatorMailOutboundStatus::getStatusName($status), 49 51 'subject' => $mail->getSubject(), 50 52 ));
+47 -10
src/applications/metamta/management/PhabricatorMailManagementShowOutboundWorkflow.php
··· 79 79 80 80 $info = array(); 81 81 82 - $info[] = pht('PROPERTIES'); 82 + $info[] = $this->newSectionHeader(pht('PROPERTIES')); 83 83 $info[] = pht('ID: %d', $message->getID()); 84 84 $info[] = pht('Status: %s', $message->getStatus()); 85 85 $info[] = pht('Related PHID: %s', $message->getRelatedPHID()); ··· 87 87 88 88 $ignore = array( 89 89 'body' => true, 90 + 'body.sent' => true, 90 91 'html-body' => true, 91 92 'headers' => true, 92 93 'attachments' => true, 93 94 'headers.sent' => true, 95 + 'headers.unfiltered' => true, 94 96 'authors.sent' => true, 95 97 ); 96 98 97 99 $info[] = null; 98 - $info[] = pht('PARAMETERS'); 100 + $info[] = $this->newSectionHeader(pht('PARAMETERS')); 99 101 $parameters = $message->getParameters(); 100 102 foreach ($parameters as $key => $value) { 101 103 if (isset($ignore[$key])) { ··· 110 112 } 111 113 112 114 $info[] = null; 113 - $info[] = pht('HEADERS'); 115 + $info[] = $this->newSectionHeader(pht('HEADERS')); 114 116 115 117 $headers = $message->getDeliveredHeaders(); 116 - if (!$headers) { 118 + $unfiltered = $message->getUnfilteredHeaders(); 119 + if (!$unfiltered) { 117 120 $headers = $message->generateHeaders(); 121 + $unfiltered = $headers; 118 122 } 119 123 124 + $header_map = array(); 120 125 foreach ($headers as $header) { 121 126 list($name, $value) = $header; 122 - $info[] = "{$name}: {$value}"; 127 + $header_map[$name.':'.$value] = true; 128 + } 129 + 130 + foreach ($unfiltered as $header) { 131 + list($name, $value) = $header; 132 + $was_sent = isset($header_map[$name.':'.$value]); 133 + 134 + if ($was_sent) { 135 + $marker = ' '; 136 + } else { 137 + $marker = '#'; 138 + } 139 + 140 + $info[] = "{$marker} {$name}: {$value}"; 123 141 } 124 142 125 143 $attachments = idx($parameters, 'attachments'); 126 144 if ($attachments) { 127 145 $info[] = null; 128 - $info[] = pht('ATTACHMENTS'); 146 + 147 + $info[] = $this->newSectionHeader(pht('ATTACHMENTS')); 148 + 129 149 foreach ($attachments as $attachment) { 130 150 $info[] = idx($attachment, 'filename', pht('Unnamed File')); 131 151 } ··· 136 156 $actors = $message->getDeliveredActors(); 137 157 if ($actors) { 138 158 $info[] = null; 139 - $info[] = pht('RECIPIENTS'); 159 + 160 + $info[] = $this->newSectionHeader(pht('RECIPIENTS')); 161 + 140 162 foreach ($actors as $actor_phid => $actor_info) { 141 163 $actor = idx($all_actors, $actor_phid); 142 164 if ($actor) { ··· 162 184 } 163 185 164 186 $info[] = null; 165 - $info[] = pht('TEXT BODY'); 187 + $info[] = $this->newSectionHeader(pht('TEXT BODY')); 166 188 if (strlen($message->getBody())) { 167 - $info[] = $message->getBody(); 189 + $info[] = tsprintf('%B', $message->getBody()); 168 190 } else { 169 191 $info[] = pht('(This message has no text body.)'); 170 192 } 171 193 194 + $delivered_body = $message->getDeliveredBody(); 195 + if ($delivered_body !== null) { 196 + $info[] = null; 197 + $info[] = $this->newSectionHeader(pht('BODY AS DELIVERED'), true); 198 + $info[] = tsprintf('%B', $delivered_body); 199 + } 200 + 172 201 $info[] = null; 173 - $info[] = pht('HTML BODY'); 202 + $info[] = $this->newSectionHeader(pht('HTML BODY')); 174 203 if (strlen($message->getHTMLBody())) { 175 204 $info[] = $message->getHTMLBody(); 176 205 $info[] = null; ··· 183 212 if ($message_key != $last_key) { 184 213 $console->writeOut("\n%s\n\n", str_repeat('-', 80)); 185 214 } 215 + } 216 + } 217 + 218 + private function newSectionHeader($label, $emphasize = false) { 219 + if ($emphasize) { 220 + return tsprintf('**<bg:yellow> %s </bg>**', $label); 221 + } else { 222 + return tsprintf('**<bg:blue> %s </bg>**', $label); 186 223 } 187 224 } 188 225
+131 -15
src/applications/metamta/storage/PhabricatorMetaMTAMail.php
··· 21 21 public function __construct() { 22 22 23 23 $this->status = PhabricatorMailOutboundStatus::STATUS_QUEUE; 24 - $this->parameters = array('sensitive' => true); 24 + $this->parameters = array( 25 + 'sensitive' => true, 26 + 'mustEncrypt' => false, 27 + ); 25 28 26 29 parent::__construct(); 27 30 } ··· 247 250 return $this->getParam('sensitive', true); 248 251 } 249 252 253 + public function setMustEncrypt($bool) { 254 + $this->setParam('mustEncrypt', $bool); 255 + return $this; 256 + } 257 + 258 + public function getMustEncrypt() { 259 + return $this->getParam('mustEncrypt', false); 260 + } 261 + 250 262 public function setHTMLBody($html) { 251 263 $this->setParam('html-body', $html); 252 264 return $this; ··· 431 443 unset($params['is-first-message']); 432 444 433 445 $is_threaded = (bool)idx($params, 'thread-id'); 446 + $must_encrypt = $this->getMustEncrypt(); 434 447 435 448 $reply_to_name = idx($params, 'reply-to-name', ''); 436 449 unset($params['reply-to-name']); ··· 502 515 mpull($cc_actors, 'getEmailAddress')); 503 516 break; 504 517 case 'attachments': 518 + // If the mail content must be encrypted, don't add attachments. 519 + if ($must_encrypt) { 520 + break; 521 + } 522 + 505 523 $value = $this->getAttachments(); 506 524 foreach ($value as $attachment) { 507 525 $mailer->addAttachment( ··· 521 539 522 540 $subject[] = trim(idx($params, 'subject-prefix')); 523 541 524 - $vary_prefix = idx($params, 'vary-subject-prefix'); 525 - if ($vary_prefix != '') { 526 - if ($this->shouldVarySubject($preferences)) { 527 - $subject[] = $vary_prefix; 542 + // If mail content must be encrypted, we replace the subject with 543 + // a generic one. 544 + if ($must_encrypt) { 545 + $subject[] = pht('Object Updated'); 546 + } else { 547 + $vary_prefix = idx($params, 'vary-subject-prefix'); 548 + if ($vary_prefix != '') { 549 + if ($this->shouldVarySubject($preferences)) { 550 + $subject[] = $vary_prefix; 551 + } 528 552 } 553 + 554 + $subject[] = $value; 529 555 } 530 556 531 - $subject[] = $value; 532 - 533 557 $mailer->setSubject(implode(' ', array_filter($subject))); 534 558 break; 535 559 case 'thread-id': ··· 567 591 } 568 592 } 569 593 570 - $body = idx($params, 'body', ''); 594 + $raw_body = idx($params, 'body', ''); 595 + $body = $raw_body; 596 + if ($must_encrypt) { 597 + $parts = array(); 598 + $parts[] = pht( 599 + 'The content for this message can only be transmitted over a '. 600 + 'secure channel. To view the message content, follow this '. 601 + 'link:'); 602 + 603 + $parts[] = PhabricatorEnv::getProductionURI($this->getURI()); 604 + 605 + $body = implode("\n\n", $parts); 606 + } else { 607 + $body = $raw_body; 608 + } 609 + 571 610 $max = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); 572 611 if (strlen($body) > $max) { 573 612 $body = id(new PhutilUTF8StringTruncator()) ··· 578 617 } 579 618 $mailer->setBody($body); 580 619 581 - $html_emails = $this->shouldSendHTML($preferences); 582 - if ($html_emails && isset($params['html-body'])) { 620 + // If we sent a different message body than we were asked to, record 621 + // what we actually sent to make debugging and diagnostics easier. 622 + if ($body !== $raw_body) { 623 + $this->setParam('body.sent', $body); 624 + } 625 + 626 + if ($must_encrypt) { 627 + $send_html = false; 628 + } else { 629 + $send_html = $this->shouldSendHTML($preferences); 630 + } 631 + 632 + if ($send_html && isset($params['html-body'])) { 583 633 $mailer->setHTMLBody($params['html-body']); 584 634 } 585 635 586 636 // Pass the headers to the mailer, then save the state so we can show 587 - // them in the web UI. 588 - foreach ($headers as $header) { 637 + // them in the web UI. If the mail must be encrypted, we remove headers 638 + // which are not on a strict whitelist to avoid disclosing information. 639 + $filtered_headers = $this->filterHeaders($headers, $must_encrypt); 640 + foreach ($filtered_headers as $header) { 589 641 list($header_key, $header_value) = $header; 590 642 $mailer->addHeader($header_key, $header_value); 591 643 } 592 - $this->setParam('headers.sent', $headers); 644 + $this->setParam('headers.unfiltered', $headers); 645 + $this->setParam('headers.sent', $filtered_headers); 593 646 594 647 // Save the final deliverability outcomes and reasoning so we can 595 648 // explain why things happened the way they did. ··· 1002 1055 // Some clients respect this to suppress OOF and other auto-responses. 1003 1056 $headers[] = array('X-Auto-Response-Suppress', 'All'); 1004 1057 1005 - // If the message has mailtags, filter out any recipients who don't want 1006 - // to receive this type of mail. 1007 1058 $mailtags = $this->getParam('mailtags'); 1008 1059 if ($mailtags) { 1009 1060 $tag_header = array(); ··· 1028 1079 $headers[] = array('Precedence', 'bulk'); 1029 1080 } 1030 1081 1082 + if ($this->getMustEncrypt()) { 1083 + $headers[] = array('X-Phabricator-Must-Encrypt', 'Yes'); 1084 + } 1085 + 1031 1086 return $headers; 1032 1087 } 1033 1088 ··· 1035 1090 return $this->getParam('headers.sent'); 1036 1091 } 1037 1092 1093 + public function getUnfilteredHeaders() { 1094 + $unfiltered = $this->getParam('headers.unfiltered'); 1095 + 1096 + if ($unfiltered === null) { 1097 + // Older versions of Phabricator did not filter headers, and thus did 1098 + // not record unfiltered headers. If we don't have unfiltered header 1099 + // data just return the delivered headers for compatibility. 1100 + return $this->getDeliveredHeaders(); 1101 + } 1102 + 1103 + return $unfiltered; 1104 + } 1105 + 1038 1106 public function getDeliveredActors() { 1039 1107 return $this->getParam('actors.sent'); 1040 1108 } ··· 1045 1113 1046 1114 public function getDeliveredRoutingMap() { 1047 1115 return $this->getParam('routingmap.sent'); 1116 + } 1117 + 1118 + public function getDeliveredBody() { 1119 + return $this->getParam('body.sent'); 1120 + } 1121 + 1122 + private function filterHeaders(array $headers, $must_encrypt) { 1123 + if (!$must_encrypt) { 1124 + return $headers; 1125 + } 1126 + 1127 + $whitelist = array( 1128 + 'In-Reply-To', 1129 + 'Message-ID', 1130 + 'Precedence', 1131 + 'References', 1132 + 'Thread-Index', 1133 + 1134 + 'X-Mail-Transport-Agent', 1135 + 'X-Auto-Response-Suppress', 1136 + 1137 + 'X-Phabricator-Sent-This-Message', 1138 + 'X-Phabricator-Must-Encrypt', 1139 + ); 1140 + 1141 + // NOTE: The major header we want to drop is "X-Phabricator-Mail-Tags". 1142 + // This header contains a significant amount of meaningful information 1143 + // about the object. 1144 + 1145 + $whitelist_map = array(); 1146 + foreach ($whitelist as $term) { 1147 + $whitelist_map[phutil_utf8_strtolower($term)] = true; 1148 + } 1149 + 1150 + foreach ($headers as $key => $header) { 1151 + list($name, $value) = $header; 1152 + $name = phutil_utf8_strtolower($name); 1153 + 1154 + if (!isset($whitelist_map[$name])) { 1155 + unset($headers[$key]); 1156 + } 1157 + } 1158 + 1159 + return $headers; 1160 + } 1161 + 1162 + public function getURI() { 1163 + return '/mail/detail/'.$this->getID().'/'; 1048 1164 } 1049 1165 1050 1166