Compare commits

..
Author SHA1 Message Date
FreddleSpl0it dc880d861c [Update] Keep ENABLE_IPV6 as set, do not disable IPv6 on hosts with a public IPv6 address 2026-11-10 17:04:50 +01:00
FreddleSpl0it a03fd8aaad Merge pull request #7500 from philippderdiedas/fix/update-sh-masquerade-bridge
[Update] Fix update.sh deleting mailcow's own MASQUERADE rule
2026-11-10 15:39:48 +01:00
FreddleSpl0it e3a90718c2 Merge pull request #7516 from wbilalz400/fix/webauthn-forced-enrollment-empty-credential
[Web] Fix forced WebAuthn TFA enrollment storing an empty credential
2026-11-10 15:21:30 +01:00
FreddleSpl0it 54a85ba439 Merge pull request #7540 from pReya/fix/table-ui-polish
[Web] Table UI polish (icon centering, tag heights, sort headers)
2026-11-10 14:07:39 +01:00
FreddleSpl0it 4ced82d554 Merge pull request #7548 from junkerderprovinz/fix/idp-ignore-ssl-error-key
[Web] Fix ignore_ssl_error key in identity provider settings
2026-11-10 13:55:15 +01:00
FreddleSpl0it f83e02b740 Merge pull request #7640 from mailcow/fix/7562
[Rspamd] Remove the URLhaus and MalwareBazaar maps retired by abuse.ch
2026-11-10 13:51:45 +01:00
FreddleSpl0it 04c9413ed9 [Rspamd] Remove the URLhaus and MalwareBazaar maps retired by abuse.ch 2026-11-10 13:50:48 +01:00
FreddleSpl0it 5e98d8e279 Merge pull request #7581 from peterschwps/fix/trim-logs-redis-password
[Dovecot] Mask Redis password in trim_logs output
2026-11-10 13:43:36 +01:00
FreddleSpl0it 4caecf77e0 Merge pull request #7610 from woodfairy/fix/brotli-cve-2026-111497
[Nginx][Clamd][Postfix] Rebuild images for brotli CVE-2026-111497 (CISA KEV)
2026-11-10 13:40:04 +01:00
FreddleSpl0it 37d304d075 Merge pull request #7544 from jorgeavlobo/fix/keycloak-admin-token-introspect
[Web] Keycloak: skip introspection of an expired admin token and reuse the new one
2026-11-10 01:45:16 +01:00
FreddleSpl0it 25086a9b3f Merge pull request #7488 from apio-sys/fix/rspamd-dqs-dbl-full-rule
[Rspamd] write full DBL rule when SPAMHAUS_DQS_KEY is set
2026-11-10 01:42:29 +01:00
FreddleSpl0it 7a3f1cbd41 Merge pull request #7530 from ThxGiving/fix/eas-auth-challenge
[Nginx] Send Basic auth challenge for unauthenticated ActiveSync requests
2026-11-10 01:39:28 +01:00
FreddleSpl0it 616717bc0d Merge pull request #7639 from mailcow/fix/7580
[Rspamd] Remove MX check overrides that rspamd ignores
2026-11-10 01:28:42 +01:00
FreddleSpl0it 6fd271ee64 [Rspamd] Remove MX check overrides that rspamd ignores 2026-11-10 01:27:52 +01:00
FreddleSpl0it 7459cc046a Merge pull request #7638 from mailcow/fix/7579
[Web] Template lists: sort size columns numerically
2026-11-10 01:08:36 +01:00
FreddleSpl0it 58dad73eee [Web] Template lists: sort size columns by bytes 2026-11-10 01:07:32 +01:00
FreddleSpl0it b21acd4787 Merge pull request #7637 from mailcow/fix/7557
[Web] Domain admins saw sender aliases of other domains
2026-11-10 01:02:39 +01:00
FreddleSpl0it 4ab954dcd1 [Web] Domain admins: show sender ACL and alias entries of their own domains only 2026-11-10 01:01:22 +01:00
FreddleSpl0it eefc17274e Merge pull request #7636 from mailcow/fix/7545
[Rspamd] WHITELISTED_FWD_HOST was skipped at the reject threshold
2026-11-10 00:57:45 +01:00
FreddleSpl0it 4878840bdd [Rspamd] Forwarding hosts: check them as prefilter and scan the mail completely 2026-11-10 00:56:53 +01:00
FreddleSpl0it 869bcb3c42 Merge pull request #7635 from mailcow/fix/7518
[Rspamd] Domain footer broke DKIM for mail without Content-Transfer-Encoding
2026-11-09 23:36:42 +01:00
FreddleSpl0it 0bcde13500 [Rspamd] Domain footer: add the Content-Transfer-Encoding header to the delivered mail 2026-11-09 23:34:57 +01:00
FreddleSpl0it 867b9dddab Merge pull request #7634 from mailcow/fix/7503
[Web] Keycloak: accept Base64-encoded mailcow_password
2026-11-09 23:30:20 +01:00
FreddleSpl0it a4326e6642 [Web] Keycloak Mailpassword Flow: accept Base64-encoded password hashes 2026-11-09 23:28:53 +01:00
FreddleSpl0it c5e89a9e03 Merge pull request #7633 from mailcow/fix/7501
[Web] Forwarding hosts: store IPv6 addresses in canonical form
2026-11-09 23:25:40 +01:00
FreddleSpl0it 45d31b3279 [Web] Forwarding hosts: store IPv6 addresses in canonical form 2026-11-09 23:24:39 +01:00
FreddleSpl0it 24780ea5fb Merge pull request #7632 from mailcow/fix/7489
[Rspamd] Quarantine stored nothing without Q_EXCLUDE_DOMAINS
2026-11-09 23:22:11 +01:00
FreddleSpl0it 38b031e10c [Rspamd] Quarantine: store mail when Q_EXCLUDE_DOMAINS is not set 2026-11-09 23:21:14 +01:00
FreddleSpl0it e2fb833ceb Merge pull request #7631 from mailcow/fix/7482
[Web] Autodiscover: no ActiveSync when autodiscoverType is imap
2026-11-09 23:18:50 +01:00
FreddleSpl0it e27cb83326 [Web] Autodiscover: no ActiveSync when autodiscoverType is imap 2026-11-09 23:17:55 +01:00
FreddleSpl0it f810cecd3a Merge pull request #7630 from mailcow/fix/7476
[Web] force_tfa did not prevent removing 2FA
2026-11-09 23:14:38 +01:00
FreddleSpl0it cfad223824 [Web] force_tfa: read the flag with JSON_VALUE 2026-11-09 23:13:26 +01:00
FreddleSpl0it 6d20463acb Merge pull request #7629 from mailcow/fix/7454
[Nginx] Edit and quarantine links: allow a trailing slash
2026-11-09 23:09:05 +01:00
FreddleSpl0it 1a42c19050 [Nginx] Accept a trailing slash in /edit and /qhandler links 2026-11-09 23:08:14 +01:00
FreddleSpl0it b9f8eb4c31 Merge pull request #7628 from mailcow/fix/7449
[Postfix-TLSPol] Update to 1.14.5
2026-11-09 23:05:35 +01:00
FreddleSpl0it ce4150f921 [Postfix-TLSPol] Update to 1.14.5 2026-11-09 23:04:33 +01:00
FreddleSpl0it 7cc8cac705 Merge pull request #7627 from mailcow/fix/7318
[Rspamd] Mails between 50 and 100 MiB were deferred forever
2026-11-09 23:01:43 +01:00
FreddleSpl0it 3fd1d017cf [Rspamd] Raise max_message above the Postfix message size limit 2026-11-09 23:00:38 +01:00
FreddleSpl0it 8597954f91 Merge pull request #7626 from mailcow/fix/7179
[Rspamd] Sub-addressing: read the tag from the original SMTP recipient
2026-11-09 22:55:05 +01:00
FreddleSpl0it 2b4ffe8808 [Rspamd] Sub-addressing: read the tag from the original SMTP recipient 2026-11-09 22:53:07 +01:00
FreddleSpl0it 1911847c3b Merge pull request #7625 from mailcow/fix/6931
[Web] Update "Last modified" when tags of a domain or mailbox change
2026-11-09 22:48:21 +01:00
FreddleSpl0it 497872080b [Web] Update "Last modified" when tags of a domain or mailbox change 2026-11-09 22:46:15 +01:00
FreddleSpl0it a0dc3447f8 Merge pull request #7624 from mailcow/fix/6853
[Web][Dovecot] Mailbox list and API: show the last SOGo login
2026-11-09 22:42:27 +01:00
FreddleSpl0it e0640949a2 [Web][Dovecot] Show the last SOGo login in the mailbox list and API 2026-11-09 22:39:42 +01:00
milkmaker bf744413bd Translations update from Weblate (#7623)
* [Web] Language file updated by 'Cleanup translation files' addon

Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Language file updated by 'Cleanup translation files' addon

Co-authored-by: milkmaker <milkmaker@mailcow.de>
2026-11-09 21:41:36 +01:00
FreddleSpl0it 262de81da1 Merge pull request #7621 from mailcow/fix/7606
[Dockerapi] Group IMAP grants: set ACLs only on existing folders
2026-11-09 19:43:38 +01:00
FreddleSpl0it 5f13bb2046 [Dockerapi] Group IMAP grants: set ACLs only on existing folders 2026-11-09 19:41:17 +01:00
woodfairy 0a0686ef19 [Nginx][Clamd][Postfix] Rebuild images for brotli CVE-2026-111497
The current nginx, clamd and postfix images ship a brotli decoder in the
affected range 1.0.3 - 1.2.0 (CVE-2026-111497, CVSS 9.8, listed in CISA KEV):

- nginx:1.30.6-2   brotli-libs 1.2.0-r1 (fixed in Alpine 3.24: 1.2.1-r0)
- clamd:1.4.6-1    brotli-libs 1.2.0-r1 (fixed in Alpine 3.24: 1.2.1-r0)
- postfix:3.10.12-1 libbrotli1 1.1.0-2+b7 (fixed in trixie-security:
  1.1.0-2+deb13u1, DSA-6582-1)

All other mailcow images already contain a fixed version.

clamd and postfix only need a rebuild, which pulls the fixed packages.
The postfix rebuild also picks up Debian's postfix 3.10.13, hence the
new tag postfix:3.10.13-1. The
nginx image never upgraded the packages of its base image, so it only got
fixes when nginx:<version>-alpine was republished. It now runs
apk upgrade like clamd and acme do.
2026-11-08 00:49:55 +01:00
FreddleSpl0it 329bca1d46 Merge pull request #7596 from mailcow/fix/7595
[Web] Mailbox protocols do not limit app passwords
2026-11-05 13:19:59 +01:00
FreddleSpl0it 6a83b9e0f0 [Web] Mailbox protocols do not limit app passwords 2026-11-05 12:46:22 +01:00
FreddleSpl0it 3ffecc505e Merge pull request #7593 from mailcow/feat/netfilter-unban-all
[Web] Add Netfilter unban all button
2026-11-04 22:29:32 +01:00
FreddleSpl0it e8f12cd061 [Web] Add Netfilter unban all button 2026-11-04 22:23:16 +01:00
FreddleSpl0it 3a4e479424 [Web] Update group info text 2026-11-04 22:16:10 +01:00
FreddleSpl0it f1e51c0a40 [Update] Mark 2026-11 as major update 2026-11-04 17:39:22 +01:00
FreddleSpl0it ddcba178af Merge pull request #7590 from mailcow/feat/sogo-5.12.13
[SOGo] Update to 5.12.13
2026-11-04 16:55:52 +01:00
FreddleSpl0it 1752dd6ddf [SOGo] Update to 5.12.13 2026-11-04 16:54:00 +01:00
FreddleSpl0it adb1c74ce3 Merge pull request #7589 from mailcow/fix/rework-bugs
Fixes for the reworks: imapsync, logins/netfilter, groups, startup, hardening
2026-11-04 16:32:46 +01:00
FreddleSpl0it 716f67844d [Web] mobileconfig: Wait for a pending account setup 2026-11-04 15:44:57 +01:00
FreddleSpl0it a2ab4301e7 [Web][Dovecot][Compose] Keep group shares on Enter, no restarts while waiting for the schema, run sync crons on master only 2026-11-04 15:10:40 +01:00
FreddleSpl0it 52f28025a5 [Web][Dovecot] imapsync: Block skipmess in custom params 2026-11-04 12:02:18 +01:00
FreddleSpl0it 3c03f8e567 Merge branch 'fix/sec' into fix/rework-bugs 2026-11-04 11:46:47 +01:00
FreddleSpl0it 72613e7e2b [Dovecot][Watchdog] Limit the wait for the schema update to 5 minutes 2026-11-04 11:29:00 +01:00
FreddleSpl0it 9293e4ce43 [Web][Dovecot] Do not count denied logins as failed logins 2026-11-04 10:39:32 +01:00
FreddleSpl0it a8d1121f0e [Dovecot][Web] imapsync: verify certificates against the source host, make the check optional per source 2026-11-03 09:31:16 +01:00
FreddleSpl0it a4b60ae26f [Netfilter][Dovecot] Count failed master logins, log the client address of successful logins 2026-11-02 14:55:42 +01:00
FreddleSpl0it e96fb24a67 [Nginx][Web] Pass the client address to sogo-auth for EAS/DAV logins 2026-11-02 14:44:09 +01:00
FreddleSpl0it 5c762d9949 [Dovecot][Netfilter] Report failed logins from mailcowauth.php, anchor default rules 2026-11-02 14:11:56 +01:00
FreddleSpl0it 06cbdc4409 [Dockerapi] Pass "--" before positional arguments of doveadm 2026-11-02 10:03:30 +01:00
FreddleSpl0it 7c73199bde [Web] Groups: limit changes of domain admins to their domains 2026-11-02 10:01:43 +01:00
FreddleSpl0it 4b7ebab8f3 [Web] Mask IdP secrets for read-only API keys, deny sync job OAuth to read-only and pending sessions 2026-11-02 09:56:24 +01:00
FreddleSpl0it 5eb3148bd5 [Web] OIDC/LDAP: check the OAuth state, keep the mailbox on token refresh, require unique IdP users 2026-11-02 09:54:54 +01:00
FreddleSpl0it 41467dc47f [Web] imapsync: change the server of a sync source only with access to all its sync jobs 2026-11-02 09:50:24 +01:00
FreddleSpl0it a1cbd51d70 [Dovecot][Web] imapsync: restrict custom parameters and source hosts, verify certificates 2026-11-02 09:49:03 +01:00
milkmaker 1cb0656924 update postscreen_access.cidr (#7584) 2026-11-01 21:43:41 +01:00
Peter Schwips 24b91f6089 [Dovecot] Mask Redis password in trim_logs output 2026-10-30 23:54:21 +01:00
FreddleSpl0it 510674ee67 [Web] Pending account setup adjustments 2026-10-28 17:11:25 +01:00
FreddleSpl0it a21604cd7b [Web][Rspamd] Quarantine link adjustments 2026-10-28 17:06:17 +01:00
FreddleSpl0it 4c7e2ec036 [Nginx] Limit request body size, configurable via NGINX_MAX_BODY_SIZE 2026-10-28 16:54:26 +01:00
FreddleSpl0it 085d10118d [Nginx][Web] Client address and autodiscover adjustments 2026-10-28 16:36:16 +01:00
FreddleSpl0it 1b6fec6a89 [Web] Hide the sync job form without the syncjobs ACL 2026-10-28 16:24:37 +01:00
FreddleSpl0it 9eb3a79f3b [Web] Quarantine list adjustments 2026-10-28 16:16:00 +01:00
FreddleSpl0it d5e3de3ae8 [Web] Add TFA Ratelimit 2026-10-28 16:04:45 +01:00
FreddleSpl0it e986803980 [Rspamd] Render domain footers without the template engine 2026-10-28 15:50:56 +01:00
FreddleSpl0it 44429fa0d0 [Web] App password and API adjustments 2026-10-28 15:29:55 +01:00
Junker der Provinzanddiko 5705a48724 [Web] Fix ignore_ssl_error key in identity provider settings
The edit action stores the setting as ignore_ssl_error, but the get
action cast and defaulted ignore_ssl_errors. The stored value was
returned as a string and LDAP setups got an extra ignore_ssl_errors
field that was always false.

Co-authored-by: diko <108140792+admins-little-helper@users.noreply.github.com>
2026-10-24 20:05:10 +02:00
Jorge Lobo 2f76230daf [Web] Keycloak: skip introspection of expired admin token 2026-10-23 00:52:03 +01:00
Moritz Stückler 498434ee3d [Web] Even out spacing of the expanded table row
DataTables removes the top padding of the first entry in the expanded
row, but mailcow's 0.5em padding left 7px under the last entry, so the
list had 7px of space above and 15px below and the last entry (often
Tags) looked taller than the others. Drop the last entry's bottom
padding as well.

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 13:10:49 +02:00
Moritz Stückler f662af4c50 [Web] Even out badge and tag sizes in tables
- Use the normal cursor on non-orderable headers. The expand column
  header kept a pointer cursor from sorting_asc although clicking it
  does nothing.
- Let icons in badges inherit the badge font size, like .btn .bi, and
  keep their glyph from adding line height. The global 12pt icon size
  made e.g. the "SSO @" badge in Last mail login taller than the IMAP
  and SMTP badges next to it. Tag badges keep their 12px icon.
- Drop the vertical margin of tag badges inside tables. It is meant for
  the tag editor in edit forms and made the Tags entry of the expanded
  row taller than the others.
- Center tag badge captions: the 12px tag icon stretched the 10.5px
  badge line and pushed the caption below center.

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 13:06:00 +02:00
Moritz Stückler 8d4895a54d [Web] Align the table expand control and row checkbox
- Center the +/- glyph in the expand control: its line-height (1.25em)
  was larger than the 1em box, pushing the glyph down. Derive the box's
  margin-top from its size so it is centered in the row (desktop and
  the larger mobile variant).
- Hide sort arrows on non-orderable headers. The default initial order
  targets column 0, the expand column, which got a sorting_asc arrow
  and arrow padding although it cannot be sorted.
- Center row checkboxes level with the expand control by using
  Bootstrap's 0.25em margin-top instead of a fixed 7px.

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 12:43:18 +02:00
Moritz Stückler 81d6b1f547 [Web] Clearer captions for domain table columns
Caption the "RL" column "Rate limit" using the existing translated
edit.ratelimit string, and shorten "Max. size of a mailbox" to
"Max. mailbox size" (en-gb only; the key is only used for this column).

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 12:43:18 +02:00
Sebastian Müller 8db3cae826 [Nginx] Send Basic auth challenge for unauthenticated ActiveSync requests
Outlook for iOS/Android connects through Microsoft's sync service, which
sends each EAS command without credentials first and only retries with
Basic auth after a 401 + WWW-Authenticate challenge. SOGo answers these
unauthenticated POSTs (Provision, FolderSync, Sync, ...) with 403, so the
Outlook apps never get past Provision and show an empty mailbox (#5846).

Answer requests without an Authorization header with a Basic challenge
in nginx before they reach SOGo. Clients that send credentials up front
are not affected.
2026-10-20 20:48:30 +02:00
Bilal Zahid d93f32072e fix(web): post WebAuthn TFA enrollment through the JSON API
The forced TFA enrollment form submitted the raw credential JSON in the
'token' field, which set_tfa()'s webauthn case never reads. It expects the
parsed \['registration'] object that only the JSON API builds, so the
form POST inserted a row with an empty keyHandle and a NULL publicKey while
still reporting success. Every WebAuthn 2FA login then failed, because
webauthn-tfa-get-args sent an empty credential id in allowCredentials.

- base.twig: POST the attestation to /api/v1/add/webauthn-tfa-registration
  like the regular WebAuthn modal does
- functions.inc.php: refuse incomplete WebAuthn registrations
- lang.en-gb.json: message for the new guard
2026-10-13 19:25:17 +05:00
Philipp Harms 7b658fa65c [Update] Fix update.sh deleting mailcow's own MASQUERADE rule
The "Checking for conflicting bridges" step extracts the bridge name with
`cut -d':' -f2`, which yields " br-mailcow" (leading space). Since the
variable got quoted in `grep -v "${MAILCOW_BRIDGE}"`, the leading space is
significant and never matches "!br-mailcow" in the `iptables -L` output, so
the rule `-s <IPV4_NETWORK>.0/24 ! -o br-mailcow -j MASQUERADE` itself gets
deleted. The mailcow network loses outbound NAT, unbound turns unhealthy and
dependent containers stay in "Created" until Docker is restarted.

Strip whitespace/quotes from the bridge name, match the interface as a whole
token, and skip the cleanup if the bridge name could not be determined.
2026-10-02 18:53:12 +02:00
Joris Le BlanschandClaude Opus 5.5 9f934af3f2 [Rspamd] write full DBL rule when SPAMHAUS_DQS_KEY is set
The generated "DBL" block only set rbl and disable_monitoring, assuming
it would merge into the stock rule. It is included with override=true,
so it replaced the stock rule instead: DBL lost all its checks (urls,
emails, dkim, helo, rdns, replyto), no_ip, selectors and returncodes,
and rspamd fell back to querying the client IP against DBL.

Write the full stock rule pointed at the DQS zone, like the ZRD block.

Fixes #7485

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:20:23 +02:00
83 changed files with 1394 additions and 448 deletions

No files matched your search

+1
View File
@@ -192,6 +192,7 @@ detect_major_update() {
"2025-02"
"2025-03"
"2025-09"
"2026-11"
)
current_version=""
+46 -21
View File
@@ -3,6 +3,11 @@
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
# Global IPv6 address that is no unique local address (fc00::/7, e.g. Docker bridges)
_has_public_ipv6() {
ip -6 -o addr show scope global 2>/dev/null | awk '{print $4}' | grep -qviE '^f[cd]'
}
# 1) Check if the host supports IPv6
get_ipv6_support() {
# ---- helper: probe external IPv6 connectivity without DNS ----
@@ -37,6 +42,9 @@ get_ipv6_support() {
if _probe_ipv6_connectivity; then
DETECTED_IPV6=true
echo -e "IPv6 detected on host – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
elif _has_public_ipv6; then
DETECTED_IPV6=true
echo -e "${YELLOW}External IPv6 connectivity test failed, but the host has a public IPv6 address – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
else
DETECTED_IPV6=false
echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
@@ -192,9 +200,31 @@ EOF
fi
}
_set_enable_ipv6() {
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i "s/^ENABLE_IPV6=.*/ENABLE_IPV6=$1/" "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=$1" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=$1
fi
}
_warn_ipv6_disabled() {
_has_public_ipv6 || return 0
ip -6 route show default 2>/dev/null | grep -qE '^default' || return 0
grep -Eq '"userland-proxy"[[:space:]]*:[[:space:]]*false' /etc/docker/daemon.json 2>/dev/null && return 0
echo -e "${LIGHT_RED}WARNING: ENABLE_IPV6=false, but this host has a public IPv6 address.${NC}"
echo -e "${YELLOW}Docker forwards IPv6 connections to mailcow with an internal source address, which Postfix trusts. This can make your server an open relay over IPv6.${NC}"
echo -e "${YELLOW}Set ENABLE_IPV6=true in mailcow.conf, or set \"userland-proxy\": false in /etc/docker/daemon.json and restart Docker.${NC}"
echo -e "${YELLOW}See https://docs.mailcow.email/post_installation/firststeps-disable_ipv6/${NC}"
sleep 5
}
# 3) Main wrapper for generate_config.sh and update.sh
configure_ipv6() {
# detect manual override if mailcow.conf is present
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2)
elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then
@@ -203,19 +233,23 @@ configure_ipv6() {
MANUAL_SETTING=""
fi
if [[ "${MANUAL_SETTING,,}" == "false" ]]; then
_set_enable_ipv6 false
echo "ENABLE_IPV6=false is set, skipping Docker IPv6 configuration."
_warn_ipv6_disabled
echo "IPv6 configuration complete: ENABLE_IPV6=false"
return
fi
get_ipv6_support
# if user manually set it, check for mismatch
if [[ "$DETECTED_IPV6" != "true" && "${MANUAL_SETTING,,}" == "true" ]] && _has_public_ipv6; then
echo -e "${YELLOW}Keeping ENABLE_IPV6=true, the host has a public IPv6 address.${NC}"
DETECTED_IPV6=true
fi
if [[ "$DETECTED_IPV6" != "true" ]]; then
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=false
fi
_set_enable_ipv6 false
echo "Skipping Docker IPv6 configuration because host does not support IPv6."
echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6."
echo "IPv6 configuration complete: ENABLE_IPV6=false"
@@ -224,16 +258,7 @@ configure_ipv6() {
fi
docker_daemon_edit
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=true
fi
_set_enable_ipv6 true
echo "IPv6 configuration complete: ENABLE_IPV6=true"
}
+6
View File
@@ -60,6 +60,7 @@ adapt_new_options() {
"ACME_DNS_CHALLENGE"
"ACME_DNS_PROVIDER"
"ACME_ACCOUNT_EMAIL"
"NGINX_MAX_BODY_SIZE"
)
sed -i --follow-symlinks '$a\' mailcow.conf
@@ -309,6 +310,11 @@ adapt_new_options() {
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
;;
NGINX_MAX_BODY_SIZE)
echo '# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited' >> mailcow.conf
echo '# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.' >> mailcow.conf
echo 'NGINX_MAX_BODY_SIZE=128m' >> mailcow.conf
;;
*)
echo "${option}=" >> mailcow.conf
;;
@@ -318,7 +318,7 @@ class DockerApi:
if 'username' in request_json and 'script_name' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' -- '" + request_json['script_name'].replace("'", "'\\''") + "'"]
sieve_return = container.exec_run(cmd)
return self.exec_run_handler('utf8_text_only', sieve_return)
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
@@ -491,7 +491,7 @@ class DockerApi:
if mailbox in mailbox_seen:
continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"])
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' -- '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2:
for acl in acls[1:]:
@@ -510,7 +510,7 @@ class DockerApi:
if mailbox in mailbox_seen:
continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"])
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' -- '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2:
for acl in acls[1:]:
@@ -534,7 +534,7 @@ class DockerApi:
id = request_json['id'].replace("'", "'\\''")
if user and mailbox and id:
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' '{self.acl_identifier(id)}'"])
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}'"])
return self.exec_run_handler('generic', acl_delete_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
@@ -568,7 +568,7 @@ class DockerApi:
rights += right + " "
if user and mailbox and id and rights:
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' '{self.acl_identifier(id)}' {rights}"])
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}' {rights}"])
return self.exec_run_handler('generic', acl_set_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all
# Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as
@@ -585,9 +585,9 @@ class DockerApi:
rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights]
if not user or id in ('user=', 'group='):
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json")
action = 'doveadm acl set -u "$ACL_USER" "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" "$folder" "$ACL_ID"'
script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do '
'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"')
action = 'doveadm acl set -u "$ACL_USER" -- "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" -- "$folder" "$ACL_ID"'
script = ('set -o pipefail; doveadm -f tab mailbox status -u "$ACL_USER" guid "*" | tail -n +2 | cut -f1 | while IFS= read -r folder; do '
'case "$folder" in Shared|Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"')
acl_return = container.exec_run(["/bin/bash", "-c", script],
environment={ 'ACL_USER': user, 'ACL_ID': id, 'ACL_RIGHTS': ' '.join(rights) })
return self.exec_run_handler('generic', acl_return)
@@ -601,7 +601,7 @@ class DockerApi:
for container in self.sync_docker_client.containers.list(filters=filters):
users = [str(user) for user in request_json.get('users', []) if str(user)]
flush_return = container.exec_run(["doveadm", "auth", "cache", "flush"] + users)
flush_return = container.exec_run(["doveadm", "auth", "cache", "flush", "--"] + users)
return self.exec_run_handler('generic', flush_return)
# Rights doveadm acl set accepts from the API
+11 -4
View File
@@ -306,13 +306,20 @@ IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DB
echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
# Wait for updated schema (DB_VERSION from docker-compose.yml), the userdb query reads tables the init_db of php-fpm creates on updates
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do
# No error while the versions table is missing (fresh install, set -e)
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null || true)
DBV_WAIT=0
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
echo "Waiting for schema update..."
sleep 5
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
DBV_WAIT=$((DBV_WAIT + 1))
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null || true)
done
echo "DB schema is ${DBV_NOW}"
if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
echo -e "\e[31mDB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting anyway, check the php-fpm log (init_db)\e[0m"
else
echo "DB schema is ${DBV_NOW}"
fi
# GUID generation
while [[ ${VERSIONS_OK} != 'OK' ]]; do
+54 -4
View File
@@ -1,10 +1,12 @@
#!/usr/bin/python3
import fcntl
import ipaddress
import json
import os
import re
import signal
import socket
import subprocess
import sys
import tempfile
@@ -18,6 +20,15 @@ max_output = 16 * 1024 * 1024
lock_file = run_dir + '/imapsync_busy.lock'
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
token_min_valid = 900
# Custom params never passed, also when stored before (same list as CustomParams::BLOCKED in the web UI)
blocked_params = {'abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd',
'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess',
'regextrans2', 'showpasswords', 'skipmess', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir'}
# imapsync only gets these variables, not the secrets of the container environment
job_env_keys = ('PATH', 'LANG', 'LC_ALL', 'TZ')
# A source must not point to mailcow itself (internal Docker network), host1 is checked before each run
internal_nets = [ipaddress.ip_network(os.environ.get('IPV4_NETWORK', '172.22.1') + '.0/24', strict=False),
ipaddress.ip_network(os.environ.get('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'), strict=False)]
# Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect
token_sync_interval = 60
@@ -25,7 +36,7 @@ token_sync_interval = 60
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow
s.host1, s.port1, s.enc1, s.verify_cert, s.auth_type, s.oauth_flow
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
WHERE i.active = 1
AND s.active = 1
@@ -106,6 +117,28 @@ def defer_job(job_id, text, exit_status):
(text, exit_status, job_id))
deferred.add(job_id)
# True when host resolves to loopback, link-local or the internal network; unknown hosts are left to imapsync
def internal_host(host):
try:
addrs = {a[4][0] for a in socket.getaddrinfo(host, None)}
except (socket.gaierror, UnicodeError):
return False
for addr in addrs:
ip = ipaddress.ip_address(addr.split('%')[0])
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if ip.is_loopback or ip.is_unspecified or ip.is_link_local or any(ip in net for net in internal_nets):
return True
return False
# SNI takes host names only
def ip_literal(host):
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
def oauth_token(j):
if j['oauth_flow'] == 'authorization_code':
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
@@ -150,6 +183,11 @@ def prepare_job(j, global_bw, files):
f.write(content)
return path
if internal_host(j['host1']):
finish_job(j['id'], 'Host1 %s is an address of mailcow itself, the sync job does not connect to it\n' % j['host1'], 0,
'EXIT_CONNECTION_FAILURE_HOST1')
return None
auth_type = j['auth_type'] or 'PLAIN'
oauth = None
if auth_type == 'XOAUTH2':
@@ -174,7 +212,11 @@ def prepare_job(j, global_bw, files):
except ValueError:
pairs = []
for p in pairs if isinstance(pairs, list) else []:
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']):
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']) \
or p['o'] in blocked_params:
continue
# The certificate check protects the OAuth token of the source, a job must not turn it off
if oauth and p['o'] == 'sslargs1':
continue
value = '' if p.get('v') is None else str(p['v'])
custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value)
@@ -205,7 +247,13 @@ def prepare_job(j, global_bw, files):
cmd.append(opt)
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
if enc1:
cmd.append(enc1)
# Mail::IMAPClient starts TLS without a host name: check the certificate against host1, not its address
cmd += [enc1, '--sslargs1', 'SSL_verifycn_scheme=imap', '--sslargs1', 'SSL_verifycn_name=' + j['host1']]
if not ip_literal(j['host1']):
cmd += ['--sslargs1', 'SSL_hostname=' + j['host1']]
# imapsync does not check certificates by default; custom sslargs1 of password jobs can still change it
if j['verify_cert'] == 1:
cmd += ['--sslargs1', 'SSL_verify_mode=1']
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
if j['dry'] == 1:
@@ -216,8 +264,10 @@ def prepare_job(j, global_bw, files):
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
def start_job(job):
env = {k: os.environ[k] for k in job_env_keys if k in os.environ}
env['HOME'] = run_dir
with open(job['outfile'], 'wb') as out:
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT)
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, env=env)
# Store the result of a finished worker and remove its temp files
def reap_job(job):
+1 -1
View File
@@ -4,7 +4,7 @@ catch_non_zero() {
${CMD} > /dev/null
EC=$?
if [ ${EC} -ne 0 ]; then
echo "Command ${CMD} failed to execute, exit code was ${EC}"
echo "Command ${CMD//${REDISPASS}/***} failed to execute, exit code was ${EC}"
fi
}
source /source_env.sh
@@ -7,9 +7,6 @@ class BanManager:
and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter
(chains, SNAT), because the backends must not run in parallel."""
# Seconds failed logins of an address are not counted after the auth backend was unavailable for it
EXCUSE_TIME = 60
def __init__(self, r, tables, options, logger, lock, clock=time.time):
self.r = r
self.tables = tables
@@ -21,16 +18,6 @@ class BanManager:
self.banned = set()
self.denied = {}
self.allowlist = []
self.excused = {}
def excuse(self, ip):
"""The auth backend could not check a login of ip: its failed logins are not counted for EXCUSE_TIME"""
with self.lock:
now = self.clock()
if self.excused.get(ip, 0) <= now:
self.logger.logWarn('Auth backend unavailable for %s, its failed logins of the next %d seconds are not counted' % (
ip, self.EXCUSE_TIME))
self.excused[ip] = now + self.EXCUSE_TIME
def attempt(self, ip):
"""Counts a failed attempt of ip and bans its network after max_attempts within retry_window"""
@@ -43,9 +30,6 @@ class BanManager:
if allowed is not None:
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
return
if self.excused.get(ip, 0) > now:
self.logger.logInfo('Not counting the failed login of %s, the auth backend was unavailable' % ip)
return
if net in self.banned:
# log lines of connections from before the ban
return
@@ -208,8 +192,6 @@ class BanManager:
for net in [net for net, counter in self.counters.items()
if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]:
del self.counters[net]
for ip in [ip for ip, until in self.excused.items() if until <= now]:
del self.excused[ip]
def apply(self, nets):
for net in self.tables.ban(nets):
@@ -109,12 +109,6 @@ class Netfilter:
def handle_line(self, line):
# a broken line must not end the watcher
try:
excused = Regexes.temporary_failure(line)
if excused is not None:
ip = Regexes.parse_ip(excused)
if self.external(ip):
self.bans.excuse(ip)
return
result = self.regexes.match(line)
if result is None:
return
+69 -16
View File
@@ -6,20 +6,43 @@ import re
class Regexes:
"""Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address"""
# Anchored to the line start: text clients control in other lines (login names, SMTP commands) must not match.
# Failed IMAP, POP3, Sieve and SMTP logins are reported by mailcowauth.php (rule 3), not by Dovecot or Postfix.
# Only the master passdb is checked by Dovecot itself (rule 8); the last address is the one Dovecot added.
DEFAULTS = {
1: r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)',
2: r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)',
3: r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+',
4: r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
5: r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
6: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)',
7: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
8: r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
9: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
1: r'^mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)$',
2: r'^Rspamd UI: Invalid password by ([0-9a-f\.:]+)$',
3: r'^MAILCOWAUTH: Login failed for user .+ with service \w* from IP ([0-9a-f\.:]+)$',
4: r'^warning: non-SMTP command from [^\[\]\s]*\[([0-9a-f\.:]+)\]:.+',
5: r'^NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
6: r'^(?:\[\d+\]: )?SOGo\w* Login from \'(?:[0-9a-f\.:]+, )*([0-9a-f\.:]+)\' for user .+ might not have worked',
7: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
8: r'^auth: passwd-file\(.*,([0-9a-f\.:]+),master(?:,<[^<>]*>)?\): (?:Password mismatch|unknown user)',
}
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while.
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)')
# Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed.
# Rules the admin changed are kept.
FORMER_DEFAULTS = {
r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)': 1,
r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)': 2,
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed': 3,
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+': 3,
r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+': 4,
r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+': 5,
r"SOGo.+ Login from '([0-9a-f\.:]+)' for user .+ might not have worked": 6,
r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked': 6,
r'-login: Disconnected \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
r'-login: Aborted login \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
r'auth: static\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
r'auth: \w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)': None,
}
# Lines of the web UI are not sanitized like syslog lines: a newline in a login name must not hide the address
CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]')
def __init__(self, logger):
self.logger = logger
@@ -42,6 +65,10 @@ class Regexes:
if not isinstance(stored, dict):
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
return
migrated = self.migrate(stored)
if migrated != stored:
self.store_migrated(r)
stored = migrated
rules = []
for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])):
@@ -54,6 +81,7 @@ class Regexes:
def match(self, line):
"""(rule id, captured address) of the first rule that matches the line, None if no rule matches"""
line = self.CONTROL_CHARS.sub('_', line)
for rule_id, regex in self.rules:
result = regex.search(line)
if result and result.lastindex and result.group(1):
@@ -61,10 +89,35 @@ class Regexes:
return None
@classmethod
def temporary_failure(cls, line):
"""Address of a login that failed because the auth backend was unavailable, None for other lines"""
result = cls.TEMPORARY_FAILURE.search(line)
return result.group(1) if result else None
def migrate(cls, stored):
"""The stored rules with former default rules replaced by the current ones (or removed)"""
migrated = {}
for rule_id, regex in stored.items():
if isinstance(regex, str) and regex in cls.FORMER_DEFAULTS:
replacement = cls.FORMER_DEFAULTS[regex]
if replacement is None:
continue
regex = cls.DEFAULTS[replacement]
migrated[rule_id] = regex
return migrated
def store_migrated(self, r):
# WATCH keeps a save of the UI between reading and writing
def replace(pipe):
current = json.loads(pipe.get('F2B_REGEX') or '{}')
if not isinstance(current, dict):
return
migrated = self.migrate(current)
if migrated == current:
return
pipe.multi()
pipe.set('F2B_REGEX', json.dumps(migrated, ensure_ascii=False))
try:
r.transaction(replace, 'F2B_REGEX')
self.logger.logInfo('Replaced former default rules in F2B_REGEX')
except Exception as ex:
self.logger.logWarn('Could not replace former default rules in F2B_REGEX: %s' % ex)
@staticmethod
def parse_ip(text):
+2 -1
View File
@@ -3,7 +3,8 @@ LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
ENV PIP_BREAK_SYSTEM_PACKAGES=1
RUN apk add --no-cache nginx \
RUN apk upgrade --no-cache && \
apk add --no-cache nginx \
python3 \
py3-pip && \
pip install --upgrade pip && \
+7
View File
@@ -1,4 +1,5 @@
import os
import re
import subprocess
from jinja2 import Environment, FileSystemLoader
@@ -44,6 +45,11 @@ def prepare_template_vars():
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
trusted_proxies = os.getenv("TRUSTED_PROXIES", "")
# nginx size syntax, invalid values fall back to the default
max_body_size = os.getenv("NGINX_MAX_BODY_SIZE", "128m").strip()
if not re.fullmatch(r'[0-9]+[kKmMgG]?', max_body_size):
print(f"Invalid NGINX_MAX_BODY_SIZE '{max_body_size}', using 128m")
max_body_size = "128m"
template_vars = {
'IPV4_NETWORK': ipv4_network,
@@ -60,6 +66,7 @@ def prepare_template_vars():
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
'NGINX_MAX_BODY_SIZE': max_body_size,
}
ssl_dir = '/etc/ssl/mail/'
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /src
ENV CGO_ENABLED=0 \
GO111MODULE=on \
NOOPT=1 \
VERSION=1.14.0
VERSION=1.14.5
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
cd /src/postfix-tlspol && \
+26 -1
View File
@@ -277,9 +277,34 @@ if [[ ! -z ${SPAMHAUS_DQS_KEY} ]]; then
}
}
"DBL" {
# override the defaults for DBL defined in modules.d/rbl.conf
# full copy of the DBL rule from modules.d/rbl.conf, pointed at DQS:
# the override include replaces the stock rule instead of merging into it
rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net";
disable_monitoring = true;
checks = ["emails", "dkim", "helo", "rdns", "replyto", "urls", "content_urls"];
no_ip = true;
ignore_defaults = true;
exclude_users = false;
emails_domainonly = true;
selector = {
from_smtp = "from('smtp'):domain";
from_mime = "from('mime'):domain";
mid = "header(Message-Id).regexp('@([^\.]+\.[^>]+)').last";
}
returncodes = {
DBL_SPAM = "127.0.1.2";
DBL_PHISH = "127.0.1.4";
DBL_MALWARE = "127.0.1.5";
DBL_BOTNET = "127.0.1.6";
DBL_ABUSE = "127.0.1.102";
DBL_ABUSE_REDIR = "127.0.1.103";
DBL_ABUSE_PHISH = "127.0.1.104";
DBL_ABUSE_MALWARE = "127.0.1.105";
DBL_ABUSE_BOTNET = "127.0.1.106";
DBL_PROHIBIT = "127.0.1.255";
DBL_BLOCKED_OPENRESOLVER = "127.255.255.254";
DBL_BLOCKED = "127.255.255.255";
}
}
"ZRD" {
ignore_defaults = true;
+3 -3
View File
@@ -1,6 +1,6 @@
# SOGo built from source to enable security patch application
# Repository: https://github.com/Alinto/sogo
# Version: SOGo-5.12.12
# Version: SOGo-5.12.13
#
# Applied security patches:
# -
@@ -12,8 +12,8 @@ FROM debian:bookworm
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ARG SOGO_VERSION=SOGo-5.12.12
ARG SOPE_VERSION=SOPE-5.12.12
ARG SOGO_VERSION=SOGo-5.12.13
ARG SOPE_VERSION=SOPE-5.12.13
# Security patches to apply (space-separated commit hashes)
ARG SOGO_SECURITY_PATCHES=""
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
+13
View File
@@ -43,6 +43,19 @@ while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u$
sleep 2
done
# Wait for the schema update (DB_VERSION) at startup, do not restart them meanwhile.
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
DBV_WAIT=0
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
echo "Waiting for schema update..."
sleep 5
DBV_WAIT=$((DBV_WAIT + 1))
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
done
if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
echo -e "\e[31m$(date) - DB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting checks anyway, check the php-fpm log (init_db)\e[0m"
fi
# Do not attempt to write to slave
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
+24 -9
View File
@@ -68,13 +68,17 @@ require_once 'functions.acl.inc.php';
$isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248';
// real_rip is the address that connected to Dovecot (for proxied connections the proxy), client_ip the client (for logs)
$client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip'];
$result = false;
// set when the password is right, but the account does not allow this login
$denied = false;
if ($isSOGoRequest) {
// This is a SOGo Auth request. First check for SSO password.
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
if ($sogo_sso_pass === $post['password']){
error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']);
set_sasl_log($post['username'], $post['real_rip'], "SOGO");
set_sasl_log($post['username'], $client_ip, "SOGO");
$result = true;
}
}
@@ -91,10 +95,10 @@ if ($result === false){
'service' => $post['service'],
'is_internal' => true,
'remote_addr' => $post['real_rip']
));
), $denied);
if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $service);
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
set_sasl_log($post['username'], $client_ip, $service);
}
}
if ($result === false){
@@ -102,26 +106,37 @@ if ($result === false){
$iam_provider = identity_provider('init');
$iam_settings = identity_provider('get');
try {
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']), $denied);
} catch (\Mailcow\Auth\IdpUnavailable $e) {
// temporary failure for Dovecot, no failed login
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip'] . ": " . $e->getMessage());
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip . ": " . $e->getMessage());
http_response_code(503); // Service Unavailable
echo json_encode($return);
session_destroy();
exit;
}
if ($result) {
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
set_sasl_log($post['username'], $client_ip, $service);
}
}
if ($result) {
http_response_code(200); // OK
$return['success'] = true;
} elseif ($denied) {
// Logged, but no failed login for netfilter: e.g. a client of a shared mailbox or without IMAP access
error_log("MAILCOWAUTH: Login denied for user " . $post['username'] . " with service " . $service . " from IP " . $client_ip);
http_response_code(401); // Unauthorized
} else {
error_log("MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
// Same line for the log and netfilter, which counts it for IMAP, POP3, Sieve and SMTP (Postfix asks Dovecot);
// SOGo logins have their own rule
$failed = "MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " .
preg_replace('/[^a-z0-9]/', '', strtolower((string)$service)) . " from IP " . $client_ip;
error_log($failed);
if (!$isSOGoRequest) {
$redis->publish("F2B_CHANNEL", $failed);
}
http_response_code(401); // Unauthorized
}
+3 -4
View File
@@ -1,8 +1,4 @@
function auth_password_verify(request, password)
if request.domain == nil then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
end
local json = require "cjson"
local ltn12 = require "ltn12"
local https = require "ssl.https"
@@ -11,7 +7,10 @@ function auth_password_verify(request, password)
local req = {
username = request.user,
password = password,
-- address that connected to Dovecot, for proxied connections the proxy
real_rip = request.real_rip,
-- address of the client, also for proxied connections
client_ip = request.rip,
service = request.service
}
local req_json = json.encode(req)
+2 -8
View File
@@ -51,13 +51,6 @@ recipient_delimiter = +
auth_master_user_separator = *
mail_shared_explicit_inbox = yes
mail_prefetch_count = 30
passdb {
driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
result_success = return-ok
result_failure = continue
result_internalfail = continue
}
# try a master passwd
passdb {
driver = passwd-file
@@ -67,8 +60,9 @@ passdb {
}
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
# a return of the following passdb is mandatory
# each check asks mailcowauth.php, which reports a failed login to netfilter once
# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry
# of a passdb lookup (master logins) and log "unknown user", which netfilter counts as failed login
# of a passdb lookup (master logins) and reject the login as unknown user
passdb {
driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
+7
View File
@@ -47,6 +47,13 @@ http {
default "";
}
# Client address for PHP: on the internal sogo-auth listener nginx itself is the client,
# the real address comes from the X-Real-IP header set by /sogo-auth-verify
map $sogo_auth_internal $mailcow_client_ip {
"1" $http_x_real_ip;
default $remote_addr;
}
{% if HTTP_REDIRECT %}
# HTTP to HTTPS redirect
server {
@@ -22,7 +22,8 @@ add_header Referrer-Policy strict-origin;
index index.php index.html;
client_max_body_size 0;
# PHP accepts no larger requests (post_max_size), SOGo webmail, CalDAV/CardDAV and ActiveSync use NGINX_MAX_BODY_SIZE
client_max_body_size 64m;
gzip on;
gzip_disable "msie6";
@@ -93,11 +94,11 @@ location / {
}
location /qhandler {
rewrite ^/qhandler/(.*)/(.*) /qhandler.php?action=$1&hash=$2;
rewrite ^/qhandler/([^/]+)/(.+?)/?$ /qhandler.php?action=$1&hash=$2;
}
location /edit {
rewrite ^/edit/(.*)/(.*) /edit.php?$1=$2;
rewrite ^/edit/([^/]+)/(.+?)/?$ /edit.php?$1=$2;
}
location ~ ^/api/v1/(.*)$ {
@@ -116,7 +117,8 @@ location ~ \.php$ {
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_X_REAL_IP $remote_addr;
fastcgi_param REMOTE_ADDR $mailcow_client_ip;
fastcgi_param HTTP_X_REAL_IP $mailcow_client_ip;
# trusted internal-auth marker; empty for external clients (see nginx.conf map)
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
fastcgi_read_timeout 3600;
@@ -181,9 +183,16 @@ location /sogo-auth-verify {
proxy_set_header Content-Length "";
proxy_pass http://127.0.0.1:65510/sogo-auth;
proxy_pass_request_body off;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
}
location ^~ /Microsoft-Server-ActiveSync {
# Outlook for iOS/Android (Microsoft's sync service) sends EAS commands without credentials
# and only authenticates after a 401 Basic challenge. SOGo answers those with 403, so challenge here.
if ($http_authorization = "") {
add_header WWW-Authenticate 'Basic realm="SOGo"' always;
return 401;
}
auth_request /sogo-auth-verify;
auth_request_set $user $upstream_http_x_user;
auth_request_set $auth $upstream_http_x_auth;
@@ -204,7 +213,7 @@ location ^~ /Microsoft-Server-ActiveSync {
proxy_busy_buffers_size 512k;
proxy_set_header Host $http_host;
client_body_buffer_size 512k;
client_max_body_size 0;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
}
location ^~ /SOGo {
@@ -255,7 +264,7 @@ location ^~ /SOGo {
proxy_send_timeout 3600;
proxy_read_timeout 3600;
client_body_buffer_size 128k;
client_max_body_size 0;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
break;
}
-4
View File
@@ -1,7 +1,3 @@
MX_IMPLICIT {
expression = "MX_GOOD & MX_MISSING";
score = -0.01;
}
VIRUS_FOUND {
expression = "CLAM_VIRUS & !MAILCOW_WHITE";
score = 2000.0;
-18
View File
@@ -23,24 +23,6 @@ symbols {
}
}
group "MX" {
"MX_INVALID" {
score = 0.5;
description = "No connectable MX";
one_shot = true;
}
"MX_MISSING" {
score = 2.0;
description = "No MX record";
one_shot = true;
}
"MX_GOOD" {
score = -0.01;
description = "MX was ok";
one_shot = true;
}
}
group "reputation" {
symbols = {
"IP_REPUTATION_HAM" {
-20
View File
@@ -5,12 +5,6 @@ RCPT_MAILCOW_DOMAIN {
symbols_set = ["RCPT_MAILCOW_DOMAIN"];
}
WHITELISTED_FWD_HOST {
type = "ip";
map = "redis://WHITELISTED_FWD_HOST";
symbols_set = ["WHITELISTED_FWD_HOST"];
}
BULK_HEADER {
type = "content";
map = "${LOCAL_CONFDIR}/custom/bulk_header.map";
@@ -151,20 +145,6 @@ BAD_LANG {
score = 5.0;
}
BAZAAR_ABUSE_CH {
type = "selector";
selector = "attachments(hex,md5)";
map = "https://bazaar.abuse.ch/export/txt/md5/recent/";
score = 10.0;
}
URLHAUS_ABUSE_CH {
type = "selector";
selector = "urls";
map = "https://urlhaus.abuse.ch/downloads/text_online/";
score = 10.0;
}
SMTP_LIMITED_ACCESS {
type = "user";
map = "redis://SMTP_LIMITED_ACCESS";
-1
View File
@@ -1,6 +1,5 @@
timeout = 8.0;
symbol_bad_mx = "MX_INVALID";
symbol_no_mx = "MX_MISSING";
symbol_good_mx = "MX_GOOD";
expire = 86400;
key_prefix = "rmx";
+2
View File
@@ -9,3 +9,5 @@ disable_monitoring = true;
# instead of silently accepting the message without further processing.
soft_reject_on_timeout = true;
local_addrs = /etc/rspamd/custom/mailcow_networks.map;
# Must not be lower than message_size_limit in postfix main.cf
max_message = 134217728;
+106 -40
View File
@@ -150,41 +150,41 @@ rspamd_config:register_symbol({
local function ipv6_to_segments(ip_str)
-- Remove zone identifier if present (e.g., %eth0)
ip_str = ip_str:gsub("%%.*$", "")
local segments = {}
-- Handle :: compression
if ip_str:find('::') then
local before, after = ip_str:match('^(.*)::(.*)$')
before = before or ''
after = after or ''
local before_parts = {}
local after_parts = {}
if before ~= '' then
for seg in before:gmatch('[^:]+') do
table.insert(before_parts, tonumber(seg, 16) or 0)
end
end
if after ~= '' then
for seg in after:gmatch('[^:]+') do
table.insert(after_parts, tonumber(seg, 16) or 0)
end
end
-- Add before segments
for _, seg in ipairs(before_parts) do
table.insert(segments, seg)
end
-- Add compressed zeros
local zeros_needed = 8 - #before_parts - #after_parts
for i = 1, zeros_needed do
table.insert(segments, 0)
end
-- Add after segments
for _, seg in ipairs(after_parts) do
table.insert(segments, seg)
@@ -195,12 +195,12 @@ rspamd_config:register_symbol({
table.insert(segments, tonumber(seg, 16) or 0)
end
end
-- Ensure we have exactly 8 segments
while #segments < 8 do
table.insert(segments, 0)
end
return segments
end
@@ -208,46 +208,46 @@ rspamd_config:register_symbol({
local function get_ipv6_variants(ip_str)
local variants = {}
local seen = {}
local function add_variant(v)
if v and not seen[v] then
table.insert(variants, v)
seen[v] = true
end
end
-- For IPv4, just return the original
if not ip_str:find(':') then
add_variant(ip_str)
return variants
end
local segments = ipv6_to_segments(ip_str)
-- 1. Fully expanded form (all zeros shown as 0000)
local expanded_parts = {}
for _, seg in ipairs(segments) do
table.insert(expanded_parts, string.format('%04x', seg))
end
add_variant(table.concat(expanded_parts, ':'))
-- 2. Standard form (no leading zeros, but all segments present)
local standard_parts = {}
for _, seg in ipairs(segments) do
table.insert(standard_parts, string.format('%x', seg))
end
add_variant(table.concat(standard_parts, ':'))
-- 3. Find all possible :: compressions
-- RFC 5952: compress the longest run of consecutive zeros
-- But we need to check all possibilities since Redis might have any form
-- Find all zero runs
local zero_runs = {}
local in_run = false
local run_start = 0
local run_length = 0
for i = 1, 8 do
if segments[i] == 0 then
if not in_run then
@@ -266,21 +266,21 @@ rspamd_config:register_symbol({
end
end
end
-- Don't forget the last run
if in_run and run_length >= 1 then
table.insert(zero_runs, {start = run_start, length = run_length})
end
-- Generate variant for each zero run compression
for _, run in ipairs(zero_runs) do
local parts = {}
-- Before compression
for i = 1, run.start - 1 do
table.insert(parts, string.format('%x', segments[i]))
end
-- The compression
if run.start == 1 then
table.insert(parts, '')
@@ -291,22 +291,22 @@ rspamd_config:register_symbol({
else
table.insert(parts, '')
end
-- After compression
for i = run.start + run.length, 8 do
table.insert(parts, string.format('%x', segments[i]))
end
local compressed = table.concat(parts, ':'):gsub('::+', '::')
add_variant(compressed)
end
return variants
end
local from_ip_string = tostring(ip)
local ip_check_table = {}
-- Add all variants of the exact IP
for _, variant in ipairs(get_ipv6_variants(from_ip_string)) do
table.insert(ip_check_table, variant)
@@ -318,18 +318,18 @@ rspamd_config:register_symbol({
maxbits = 32
minbits = 8
end
-- Add all CIDR notations with variants
for i=maxbits,minbits,-1 do
local masked_ip = ip:apply_mask(i)
local cidr_base = masked_ip:to_string()
for _, variant in ipairs(get_ipv6_variants(cidr_base)) do
local cidr = variant .. "/" .. i
table.insert(ip_check_table, cidr)
end
end
local function keep_spam_cb(err, data)
if err then
rspamd_logger.infox(rspamd_config, "keep_spam query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err)
@@ -345,7 +345,7 @@ rspamd_config:register_symbol({
end
end
end
table.insert(ip_check_table, 1, 'KEEP_SPAM')
local redis_ret_user = rspamd_redis_make_request(task,
redis_params, -- connect params
@@ -362,6 +362,61 @@ rspamd_config:register_symbol({
priority = 19
})
rspamd_config:register_symbol({
name = 'WHITELISTED_FWD_HOST',
type = 'prefilter',
score = 0.0,
callback = function(task)
local rspamd_logger = require "rspamd_logger"
local redis_params = rspamd_parse_redis_server('fwd_host')
local ip = task:get_from_ip()
if ip == nil or not ip:is_valid() then
return false
end
local from_ip_string = tostring(ip)
local ip_check_table = {'WHITELISTED_FWD_HOST', from_ip_string}
local maxbits = 128
local minbits = 7
if ip:get_version() == 4 then
maxbits = 32
minbits = 8
end
for i=maxbits,minbits,-1 do
table.insert(ip_check_table, ip:apply_mask(i):to_string() .. "/" .. i)
end
local function fwd_host_cb(err, data)
if err or type(data) ~= 'table' then
rspamd_logger.infox(rspamd_config, "fwd_host query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err)
return
end
for _,v in pairs(data) do
if type(v) == 'string' then
task:insert_result('WHITELISTED_FWD_HOST', 1.0, from_ip_string)
task:set_flag('pass_all')
return
end
end
end
local redis_ret = rspamd_redis_make_request(task,
redis_params, -- connect params
'WHITELISTED_FWD_HOST', -- hash key
false, -- is write
fwd_host_cb, --callback
'HMGET', -- command
ip_check_table -- arguments
)
if not redis_ret then
rspamd_logger.infox(rspamd_config, "cannot check fwd_host redis map")
end
end,
priority = 18
})
rspamd_config:register_symbol({
name = 'TLS_HEADER',
type = 'postfilter',
@@ -390,9 +445,8 @@ rspamd_config:register_symbol({
local rspamd_logger = require "rspamd_logger"
local redis_params = rspamd_parse_redis_server('taghandler')
local rspamd_http = require "rspamd_http"
local rcpts = task:get_recipients('smtp')
local rcpts = task:get_recipients({'smtp', 'orig'})
local lua_util = require "lua_util"
local tagged_rcpt = task:get_symbol("TAGGED_RCPT")
local function remove_moo_tag()
local moo_tag_header = task:get_header('X-Moo-Tag', false)
@@ -417,8 +471,9 @@ rspamd_config:register_symbol({
-- Check if recipient has a tag (contains '+')
local tag = nil
if tagged_rcpt ~= nil then
tag = tagged_rcpt
local base_user, tag_part = rcpt_user:match('^([^%+]+)%+(.+)$')
if base_user and tag_part then
tag = tag_part
rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag)
end
@@ -441,7 +496,7 @@ rspamd_config:register_symbol({
-- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER)
local allow_processing = false
if task.has_pre_result then
local has_pre, pre_action = task:has_pre_result()
if has_pre then
@@ -498,8 +553,7 @@ rspamd_config:register_symbol({
else
rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail")
local sbj = task:get_header('Subject') or ''
local tag_value = tag[1] and tag[1].options and tag[1].options[1] or ''
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag_value .. '] ' .. sbj)) .. '?='
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?='
task:set_milter_reply({
remove_headers = {
['Subject'] = 1,
@@ -765,13 +819,22 @@ rspamd_config:register_symbol({
end
})
-- Expand a domain footer: replace only "{= key =}" placeholders with their values.
-- Unknown placeholders render empty, matching the previous engine behaviour.
local function expand_footer(template, replacements)
return (template:gsub('{=%s*([%w_]+)%s*=}', function(key)
local value = replacements[key]
if value == nil then return '' end
return tostring(value)
end))
end
rspamd_config:register_symbol({
name = 'MOO_FOOTER',
type = 'prefilter',
callback = function(task)
local cjson = require "cjson"
local lua_mime = require "lua_mime"
local lua_util = require "lua_util"
local rspamd_logger = require "rspamd_logger"
local rspamd_http = require "rspamd_http"
local envfrom = task:get_from(1)
@@ -844,10 +907,10 @@ rspamd_config:register_symbol({
end
end
if footer.html and footer.html ~= "" then
footer.html = lua_util.jinja_template(footer.html, replacements, true)
footer.html = expand_footer(footer.html, replacements)
end
if footer.plain and footer.plain ~= "" then
footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
footer.plain = expand_footer(footer.plain, replacements)
end
-- add footer
@@ -898,6 +961,9 @@ rspamd_config:register_symbol({
if not seen_cte and rewrite.need_rewrite_ct then
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', new_cte)
task:set_milter_reply({
add_headers = {['Content-Transfer-Encoding'] = new_cte}
})
end
-- End of headers
+6 -7
View File
@@ -109,7 +109,7 @@ foreach ($rcpts as $rcpt) {
}
// Skip if domain is excluded
if (in_array($parsed_rcpt['domain'], $exclude_domains)) {
if (is_array($exclude_domains) && in_array($parsed_rcpt['domain'], $exclude_domains)) {
error_log(sprintf("QUARANTINE: Skipped domain %s", $parsed_rcpt['domain']) . PHP_EOL);
continue;
}
@@ -216,8 +216,9 @@ foreach ($rcpts as $rcpt) {
foreach ($rcpt_final_mailboxes as $rcpt_final) {
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
try {
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`)
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes)");
// qhash is the secret of the release/delete links in notifications, so it must not be derivable
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`, `qhash`)
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes, :qhash)");
$stmt->execute(array(
':qid' => $qid,
':subject' => $subject,
@@ -229,11 +230,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) {
':ip' => $ip,
':msg' => $raw_data,
':action' => $action,
':fuzzy_hashes' => $fuzzy
':fuzzy_hashes' => $fuzzy,
':qhash' => bin2hex(random_bytes(32))
));
$lastId = $pdo->lastInsertId();
$stmt_update = $pdo->prepare("UPDATE `quarantine` SET `qhash` = SHA2(CONCAT(`id`, `qid`), 256) WHERE `id` = :id");
$stmt_update->execute(array(':id' => $lastId));
$stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN (
SELECT `id`
FROM (
+18
View File
@@ -1936,6 +1936,14 @@ paths:
- TLS
- SSL
- PLAIN
verify_cert:
description: >-
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
it (self-signed, expired or not matching the host).
type: integer
enum:
- 0
- 1
auth_type:
description: >-
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
@@ -5080,6 +5088,15 @@ paths:
- TLS
- SSL
- PLAIN
verify_cert:
description: >-
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
it (self-signed, expired or not matching the host). Turning the check off
needs access to all sync jobs of the source, like changing the server.
type: integer
enum:
- 0
- 1
auth_type:
description: >-
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
@@ -7313,6 +7330,7 @@ paths:
host1: imap.example.org
port1: 993
enc1: SSL
verify_cert: 1
auth_type: XOAUTH2
oauth_flow: authorization_code
oauth_token_endpoint: https://login.example.org/token
+1 -1
View File
@@ -8,7 +8,7 @@ if(file_exists('inc/vars.local.inc.php')) {
$autodiscover_config = array_merge($default_autodiscover_config, $autodiscover_config);
header('Content-type: application/json');
if (strtolower($_GET['Protocol']) == 'activesync' && getenv('SKIP_SOGO') != "y") {
if (strtolower($_GET['Protocol']) == 'activesync' && $autodiscover_config['autodiscoverType'] == 'activesync' && getenv('SKIP_SOGO') != "y") {
echo '{"Protocol":"ActiveSync","Url":"' . $autodiscover_config['activesync']['url'] . '"}';
}
elseif (strtolower($_GET['Protocol']) == 'autodiscoverv1') {
+43 -44
View File
@@ -31,7 +31,7 @@ error_reporting(0);
$data = trim(file_get_contents("php://input"));
if (strpos($data, 'autodiscover/outlook/responseschema') !== false) {
if (strpos($data, 'autodiscover/outlook/responseschema') !== false && $autodiscover_config['autodiscoverType'] == 'activesync') {
$autodiscover_config['autodiscoverType'] = 'imap';
if ($autodiscover_config['useEASforOutlook'] == 'yes' &&
// Office for macOS does not support EAS
@@ -144,41 +144,15 @@ try {
}
$username = trim((string)$email);
try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`username` = :username
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'");
$stmt->execute(array(':username' => $username));
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
}
catch(PDOException $e) {
// Database error - return error response with complete XML
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>500</ErrorCode>
<Message>Database Error</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
// Mailbox not found or not active - return generic error to prevent user enumeration
if (empty($MailboxData)) {
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: mailbox not found or inactive"
"service" => "Error: invalid email address in request"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
@@ -202,13 +176,33 @@ if (empty($MailboxData)) {
<?php
exit(0);
}
try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`username` = :username
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'");
$stmt->execute(array(':username' => $username));
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
}
catch(PDOException $e) {
// Database error - return error response with complete XML
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>500</ErrorCode>
<Message>Database Error</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
if (!empty($MailboxData['name'])) {
$displayname = $MailboxData['name'];
}
else {
$displayname = $email;
}
$email = $username;
$displayname = $email;
try {
$json = json_encode(
array(
@@ -216,11 +210,16 @@ try {
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => $autodiscover_config['autodiscoverType']
// The answer is the same, only the log tells unknown or inactive mailboxes apart
"service" => (empty($MailboxData)) ? "Error: mailbox not found or inactive" : $autodiscover_config['autodiscoverType']
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
if (empty($MailboxData)) {
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
}
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
@@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['imap']['server'];?></Server>
<Port><?=$autodiscover_config['imap']['port'];?></Port>
<DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
@@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['smtp']['server'];?></Server>
<Port><?=$autodiscover_config['smtp']['port'];?></Port>
<DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
@@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
?>
<Protocol>
<Type>CalDAV</Type>
<Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server>
<Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
<DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol>
<Protocol>
<Type>CardDAV</Type>
<Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server>
<Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
<DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol>
<?php
}
@@ -288,7 +287,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Culture>en:en</Culture>
<User>
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
<EMailAddress><?=$email;?></EMailAddress>
<EMailAddress><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></EMailAddress>
</User>
<Action>
<Settings>
+25 -2
View File
@@ -15,6 +15,10 @@ table.dataTable>tbody>tr.child ul.dtr-details>li {
border-bottom: 1px solid rgba(0, 0, 0, 0.129);
padding: 0.5em 0;
}
/* like the first entry has no top padding, the last one needs no bottom padding */
table.dataTable>tbody>tr.child ul.dtr-details>li:last-child {
padding-bottom: 0;
}
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
background-color: var(--mc-dtr-hover-bg);
@@ -28,7 +32,8 @@ table.dataTable td.dt-control:before {
color: var(--mc-dtr-color);
height: 1em;
width: 1em;
line-height: 1.25em;
line-height: 1em;
margin-top: calc(-0.5em - 1.5px);
border-radius: 0px;
box-shadow: none;
font-size: 14px;
@@ -39,11 +44,29 @@ table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before,
table.dataTable td.dt-control:before {
background-color: var(--mc-dtr-active-bg) !important;
}
/* The default initial order targets column 0, which is usually the non-orderable expand column */
table.dataTable thead > tr > th.sorting_disabled.sorting_asc,
table.dataTable thead > tr > th.sorting_disabled.sorting_desc {
padding-right: 0.5rem;
cursor: auto;
}
table.dataTable thead > tr > th.sorting_disabled.sorting_asc:before,
table.dataTable thead > tr > th.sorting_disabled.sorting_asc:after,
table.dataTable thead > tr > th.sorting_disabled.sorting_desc:before,
table.dataTable thead > tr > th.sorting_disabled.sorting_desc:after {
display: none;
}
table.dataTable.table-striped>tbody>tr>td {
vertical-align: middle;
}
table.dataTable.table-striped>tbody>tr>td>input[type="checkbox"] {
margin-top: 7px;
/* centers the 1em checkbox in the 1.5em line, level with the expand control */
margin-top: 0.25em;
}
/* the vertical margin is meant for the tag editor in edit forms, in tables it only adds height */
table.dataTable .tag-badge {
margin-top: 0;
margin-bottom: 0;
}
td.dtr-col-lg {
min-width: 350px;
+8
View File
@@ -139,6 +139,13 @@ body {
display: inline-block;
font-size: inherit;
}
.badge .bi {
font-size: inherit;
}
/* keeps icons from making a badge taller than one with text only */
.badge .bi::before {
line-height: 0;
}
.btn-group-xs > .btn, .btn-xs {
padding: .25rem .4rem;
font-size: .875rem;
@@ -343,6 +350,7 @@ code {
}
.tag-badge .bi {
font-size: 12px;
line-height: 0;
}
.tag-badge.btn-badge:hover {
filter: brightness(0.9);
+1 -1
View File
@@ -191,7 +191,7 @@
height: 2rem;
width: 2rem;
line-height: 2rem;
margin-top: -15px;
margin-top: calc(-1rem - 1.5px);
}
li .dtr-data {
+2 -1
View File
@@ -227,7 +227,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
if (isset($_GET['syncjob']) &&
is_numeric($_GET['syncjob'])) {
$id = $_GET["syncjob"];
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id);
// Without the syncjobs ACL a job cannot be saved, so do not show the form
$result = (($_SESSION['acl']['syncjobs'] ?? '0') == '1') ? \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id) : [];
$template = 'edit/syncjob.twig';
}
elseif (isset($_GET['syncjob_source']) &&
+9
View File
@@ -74,6 +74,15 @@ function admin($_action, $_data = null) {
$usernames = $_data['username'];
}
foreach ($usernames as $username) {
// A pending password update only allows changing the own password
if (!empty($_SESSION['pending_pw_update']) && $username != $_SESSION['mailcow_cc_username']) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => 'access_denied'
);
continue;
}
$is_now = admin('details', $username);
if (!empty($is_now)) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
@@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) {
$_data_log = $_data;
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
!isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*';
if (in_array($_action, array('add', 'edit', 'delete')) &&
(!isset($_SESSION['acl']['app_passwds']) || $_SESSION['acl']['app_passwds'] != "1")) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => 'access_denied'
);
return false;
}
if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
$_SESSION['return'][] = array(
+65 -40
View File
@@ -6,6 +6,8 @@ function check_login($user, $pass, $extra = null) {
$is_internal = $extra['is_internal'];
$role = $extra['role'];
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
// set when the password is right, but the account does not allow this login
$denied = false;
// Try validate admin
if (!isset($role) || $role == "admin") {
@@ -26,7 +28,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate app password
if (!isset($role) || $role == "app") {
$result = apppass_login($user, $pass, $extra);
$result = apppass_login($user, $pass, $extra, $denied);
if ($result !== false) {
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
set_sasl_log($user, $real_rip, $extra['service'], $pass);
@@ -37,7 +39,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate user
if (!isset($role) || $role == "user") {
try {
$result = user_login($user, $pass, $extra);
$result = user_login($user, $pass, $extra, $denied);
} catch (\Mailcow\Auth\IdpUnavailable $e) {
// no failed login, netfilter must not count it
error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage());
@@ -54,19 +56,20 @@ function check_login($user, $pass, $extra = null) {
return $result;
}
}
// skip log and only return false if it's an internal request
if ($is_internal == true) return false;
// A denied login is logged, but not counted by netfilter (e.g. EAS/DAV clients of accounts without access)
$msg = ($denied ? "mailcow UI: Login denied for " : "mailcow UI: Invalid password for ") . $user . " by " . $_SERVER['REMOTE_ADDR'];
if (!isset($_SESSION['ldelay'])) {
$_SESSION['ldelay'] = "0";
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log($msg);
}
elseif (!isset($_SESSION['mailcow_cc_username'])) {
$_SESSION['ldelay'] = $_SESSION['ldelay']+0.5;
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log($msg);
}
$_SESSION['return'][] = array(
'type' => 'danger',
@@ -207,7 +210,7 @@ function domainadmin_login($user, $pass){
return false;
}
function user_login($user, $pass, $extra = null){
function user_login($user, $pass, $extra = null, &$denied = false){
global $pdo;
global $iam_provider;
global $iam_settings;
@@ -261,11 +264,14 @@ function user_login($user, $pass, $extra = null){
if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
$denied = true;
return false;
}
}
return true;
}
// the IdP accepted the password, the mailbox is inactive
$denied = true;
}
clear_session();
return false;
@@ -273,18 +279,16 @@ function user_login($user, $pass, $extra = null){
// check if user has access to service (imap, smtp, pop3, sieve) if service is set
$row['attributes'] = json_decode($row['attributes'], true);
// Shared mailboxes have no login of their own, members open them through group shares
if (($row['attributes']['shared'] ?? '0') == '1') {
return false;
}
if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
return false;
$key = strtolower($extra['service']) . "_access";
// Shared mailboxes have no login of their own, members open them through group shares.
// Checked before asking an IdP (no template side effects).
if (($row['attributes']['shared'] ?? '0') == '1'
|| ($extra['service'] != 'NONE' && isset($row['attributes'][$key]) && $row['attributes'][$key] != '1')
|| $row['active'] != '1' || $row['d_active'] != '1') {
// Denied, not failed, unless the password is wrong; IdP passwords are not checked for that
if ($row['authsource'] != 'mailcow' || verify_hash($row['password'], $pass) !== false) {
$denied = true;
}
}
// IdP accounts: refuse inactive mailboxes and domains before asking the IdP (no template side effects)
if ($row['authsource'] != 'mailcow' && ($row['active'] != '1' || $row['d_active'] != '1')) {
return false;
}
switch ($row['authsource']) {
@@ -303,6 +307,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) {
$denied = true;
return false;
}
@@ -361,6 +366,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) {
$denied = true;
return false;
}
@@ -403,9 +409,6 @@ function user_login($user, $pass, $extra = null){
return $result;
break;
case 'mailcow':
if ($row['active'] != 1 || $row['d_active'] != 1) {
return false;
}
// verify password
if (verify_hash($row['password'], $pass) !== false) {
@@ -446,13 +449,15 @@ function user_login($user, $pass, $extra = null){
}
return "user";
}
// TFA accounts use app passwords in mail clients
$denied = true;
}
break;
}
return false;
}
function apppass_login($user, $pass, $extra = null){
function apppass_login($user, $pass, $extra = null, &$denied = false){
global $pdo;
$is_internal = $extra['is_internal'];
@@ -473,15 +478,12 @@ function apppass_login($user, $pass, $extra = null){
return false;
}
// fetch app password data
$stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id` FROM `app_passwd`
// fetch app password data, inactive ones too: their right password is denied, not failed
$stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id`,
`mailbox`.`active` as `mailbox_active`, `domain`.`active` as `domain_active`, `mailbox`.`attributes` as `mailbox_attributes` FROM `app_passwd`
INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group'
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'
AND `app_passwd`.`active` = '1'
AND COALESCE(JSON_UNQUOTE(JSON_VALUE(`mailbox`.`attributes`, '$.shared')), '0') != '1'
AND `app_passwd`.`mailbox` = :user"
);
// fetch password data
@@ -491,16 +493,25 @@ function apppass_login($user, $pass, $extra = null){
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){
// verify password
if (verify_hash($row['password'], $pass) === false) {
continue;
}
// verify password
if (verify_hash($row['password'], $pass) !== false) {
$_SESSION['app_passwd_id'] = $row['app_passwd_id'];
unset($_SESSION['ldelay']);
return "user";
$key = strtolower($extra['service']) . '_access';
$mailbox_attributes = json_decode($row['mailbox_attributes'], true);
// the protocols of the mailbox only limit direct user access, app passwords have their own
if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1'
|| ($mailbox_attributes['shared'] ?? '0') == '1'
|| ($extra['service'] != 'NONE' && $row[$key] != '1')) {
// right password, but the app password does not allow this login
$denied = true;
continue;
}
$_SESSION['app_passwd_id'] = $row['app_passwd_id'];
unset($_SESSION['ldelay']);
return "user";
}
return false;
@@ -538,7 +549,8 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
// get the mailcow_password attribute from keycloak user
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
$queryParams = array('email' => $user, 'exact' => true);
// as string, http_build_query() would send exact=1 and Keycloak would search for substrings
$queryParams = array('email' => $user, 'exact' => 'true');
$queryString = http_build_query($queryParams);
$curl = curl_init();
curl_setopt($curl, CURLOPT_TIMEOUT, 7);
@@ -559,7 +571,10 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
if ($code != 200) {
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
}
// exact=true can still return several users with that email
// exact=true can still return several users with that email, only the exact address counts
if (is_array($users)) {
$users = array_values(array_filter($users, fn($u) => is_array($u) && strcasecmp((string)($u['email'] ?? ''), $user) === 0));
}
if (!is_array($users) || count($users) != 1) {
return false;
}
@@ -586,6 +601,13 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
// validate mailcow_password
$mailcow_password = $user_res['attributes']['mailcow_password'][0];
// Keycloak returns binary LDAP attributes like userPassword Base64 encoded
if (substr($mailcow_password, 0, 1) != '{') {
$decoded_password = base64_decode($mailcow_password, true);
if ($decoded_password !== false && substr($decoded_password, 0, 1) == '{') {
$mailcow_password = $decoded_password;
}
}
if (!verify_hash($mailcow_password, $pass)) {
return false;
}
@@ -710,12 +732,15 @@ function ldap_mbox_login($user, $pass, $extra = null){
->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'],
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
$user_res = $ldap_query->firstOrFail();
} catch (\LdapRecord\Query\ObjectNotFoundException $e) {
return false;
$entries = $ldap_query->get();
} catch (Exception $e) {
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
}
// the login name must identify exactly one entry, several matches are ambiguous
if (!is_array($entries) || count($entries) != 1) {
return false;
}
$user_res = reset($entries);
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
return false;
+22
View File
@@ -145,6 +145,28 @@ function fail2ban($_action, $_data = null, $_extra = null) {
);
return true;
}
// Queue all automatic bans for unbanning, the denylist stays
elseif ($_data['action'] == "unban-all") {
try {
foreach ($redis->hKeys('F2B_ACTIVE_BANS') as $network) {
$redis->hSet('F2B_QUEUE_UNBAN', $network, 1);
}
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => array('redis_error', $e)
);
return false;
}
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_data_log),
'msg' => 'f2b_modified'
);
return true;
}
// Start actions in dependency of network
if (!empty($_data['network'])) {
+7
View File
@@ -35,6 +35,13 @@ function fwdhost($_action, $_data = null) {
);
return false;
}
$hosts = array_unique(array_map(function ($host) {
$net = explode('/', $host, 2);
if (filter_var($net[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$net[0] = inet_ntop(inet_pton($net[0]));
}
return implode('/', $net);
}, $hosts));
foreach ($hosts as $host) {
try {
$redis->hSet('WHITELISTED_FWD_HOST', $host, $source);
+77 -7
View File
@@ -1394,6 +1394,18 @@ function set_tfa($_data) {
case "webauthn":
$key_id = (!isset($_data["key_id"])) ? 'unidentified' : $_data["key_id"];
// Refuse incomplete WebAuthn registrations. Without this guard a request
// that does not contain the parsed credential data (e.g. a plain form
// POST) would silently insert an empty keyHandle while reporting success.
if (empty($_data['registration']) || empty($_data['registration']->credentialId)) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_data_log),
'msg' => 'webauthn_registration_failed'
);
return false;
}
$stmt = $pdo->prepare("INSERT INTO `tfa` (`username`, `key_id`, `authmech`, `keyHandle`, `publicKey`, `certificate`, `counter`, `active`)
VALUES (?, ?, 'webauthn', ?, ?, ?, ?, '1')");
$stmt->execute(array(
@@ -1416,11 +1428,11 @@ function set_tfa($_data) {
// Block TFA removal if force_tfa policy is active
$is_forced_tfa = false;
if ($_SESSION['mailcow_cc_role'] === 'user') {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
} else {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
}
@@ -1687,11 +1699,11 @@ function unset_tfa_key($_data) {
// Block key removal if force_tfa policy is active
$is_forced_tfa = false;
if ($_SESSION['mailcow_cc_role'] === 'user') {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
} else {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
}
@@ -1857,6 +1869,40 @@ function get_tfa($username = null, $id = null) {
}
}
function verify_tfa_login($username, $_data) {
global $redis;
global $TFA_MAX_FAILS;
global $TFA_FAIL_WINDOW;
if (empty($username)) {
return false;
}
// Count failed second factors per account, so codes cannot be guessed from many addresses
$fails_key = 'TFA_FAILS_' . $username;
if (intval($redis->get($fails_key)) >= $TFA_MAX_FAILS) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $username, '*'),
'msg' => 'tfa_login_locked'
);
$verified = false;
}
else {
$verified = verify_tfa_token($username, $_data);
}
if ($verified !== true) {
// The window starts with the first failure and is not extended by further attempts
if ($redis->incr($fails_key) == 1) {
$redis->expire($fails_key, $TFA_FAIL_WINDOW);
}
// Same message format as failed passwords, so the default fail2ban rule counts it
error_log("mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
return false;
}
$redis->del($fails_key);
return true;
}
function verify_tfa_token($username, $_data) {
global $pdo;
global $yubi;
global $u2f;
@@ -2386,7 +2432,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
case "use_ssl":
case "use_tls":
case "login_provisioning":
case "ignore_ssl_errors":
case "ignore_ssl_error":
$settings[$row["key"]] = boolval($row["value"]);
break;
default:
@@ -2410,7 +2456,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
if ($settings["authsource"] == "ldap"){
$settings['use_ssl'] = !isset($settings['use_ssl']) ? false : $settings['use_ssl'];
$settings['use_tls'] = !isset($settings['use_tls']) ? false : $settings['use_tls'];
$settings['ignore_ssl_errors'] = !isset($settings['ignore_ssl_errors']) ? false : $settings['ignore_ssl_errors'];
$settings['ignore_ssl_error'] = !isset($settings['ignore_ssl_error']) ? false : $settings['ignore_ssl_error'];
}
return $settings;
break;
@@ -3013,6 +3059,18 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
return false;
}
// A refresh keeps the logged-in mailbox: a changed IdP email or another IdP user ends the session
if (strtolower($info['email']) !== strtolower($_SESSION['mailcow_cc_username'] ?? '') ||
!\Mailcow\Auth\AuthModule::fromGlobals()->idp->verifyLink($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? ''))) {
clear_session();
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $info['email'], 'The IdP user does not match the logged-in mailbox'),
'msg' => 'refresh_login_failed'
);
return false;
}
// The mailbox may have been deleted or deactivated since the login
$stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
@@ -3060,7 +3118,17 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
case "get-keycloak-admin-token":
// get access_token for service account of mailcow client
if ($iam_settings['authsource'] !== 'keycloak') return false;
if (isset($iam_settings['access_token'])) {
// only introspect a cached access_token that is not (about to be) expired,
// introspecting an expired token makes Keycloak log INTROSPECT_TOKEN_ERROR
$token_not_expired = false;
if (!empty($iam_settings['access_token'])) {
$token_parts = explode('.', $iam_settings['access_token']);
if (count($token_parts) === 3) {
$token_payload = json_decode(base64_decode(strtr($token_parts[1], '-_', '+/')), true);
$token_not_expired = is_array($token_payload) && isset($token_payload['exp']) && intval($token_payload['exp']) > time() + 30;
}
}
if ($token_not_expired) {
// check if access_token is valid
$url = "{$iam_settings['server_url']}/realms/{$iam_settings['realm']}/protocol/openid-connect/token/introspect";
$req = http_build_query(array(
@@ -3111,6 +3179,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
':key' => 'access_token',
':value' => $res['access_token']
));
// keep the cached token in sync, so subsequent calls in the same run reuse it
$iam_settings['access_token'] = $res['access_token'];
return $res['access_token'];
break;
}
+44 -1
View File
@@ -2524,6 +2524,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => $domain,
':tag_name' => $tag,
));
$stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain,
));
}
$_SESSION['return'][] = array(
@@ -2692,6 +2696,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => $domain,
':tag_name' => $tag,
));
$stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain,
));
}
$_SESSION['return'][] = array(
@@ -3170,6 +3178,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':username' => $username,
':tag_name' => $tag,
));
$stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username");
$stmt->execute(array(
':username' => $username,
));
} catch (Exception $e) {
}
}
@@ -3988,6 +4000,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt->execute(array(':goto' => '(^|,)'.preg_quote($_data, '/').'($|,)'));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while ($row = array_shift($rows)) {
if (!hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['address'])) {
continue;
}
// Keep old array for backward compatibility
$data['fixed_sender_aliases'][] = $row['address'];
// Split into allowed/blocked for proper display
@@ -4923,6 +4938,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
else if ($SaslLogs['service'] == 'SSO') {
$last_sso_login = strtotime($SaslLogs['datetime']);
}
else if ($SaslLogs['service'] == 'SOGO') {
$last_sogo_login = strtotime($SaslLogs['datetime']);
}
}
if (!isset($last_imap_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) {
$last_imap_login = 0;
@@ -4936,10 +4954,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
if (!isset($last_sso_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) {
$last_sso_login = 0;
}
if (!isset($last_sogo_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) {
$last_sogo_login = 0;
}
$mailboxdata['last_imap_login'] = $last_imap_login;
$mailboxdata['last_smtp_login'] = $last_smtp_login;
$mailboxdata['last_pop3_login'] = $last_pop3_login;
$mailboxdata['last_sso_login'] = $last_sso_login;
$mailboxdata['last_sogo_login'] = $last_sogo_login;
if (!isset($_extra) || $_extra != 'reduced') {
$rl = ratelimit('get', 'mailbox', $_data);
@@ -4977,7 +4999,16 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt->execute(array(':username' => $_data));
$sender_acl_rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($sender_acl_rows as $sender_acl_row) {
$mailboxdata['sender_acl'][] = $sender_acl_row['send_as'];
$send_as = $sender_acl_row['send_as'];
if ($_SESSION['mailcow_cc_role'] == 'domainadmin' && $send_as != '*') {
$in_scope = (substr($send_as, 0, 1) == '@')
? hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr($send_as, 1))
: hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $send_as);
if (!$in_scope) {
continue;
}
}
$mailboxdata['sender_acl'][] = $send_as;
}
}
$stmt = $pdo->prepare("SELECT `tag_name`
@@ -5916,6 +5947,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => $domain,
':tag_name' => $tag,
));
if ($stmt->rowCount() > 0) {
$stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain,
));
}
}
}
@@ -5968,6 +6005,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':username' => $username,
':tag_name' => $tag,
));
if ($stmt->rowCount() > 0) {
$stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username");
$stmt->execute(array(
':username' => $username,
));
}
}
}
+3 -2
View File
@@ -767,8 +767,9 @@ function quarantine($_action, $_data = null) {
else {
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
foreach ($domains as $domain) {
$stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE `rcpt` REGEXP :domain');
$stmt->execute(array(':domain' => '@' . $domain . '$'));
// Compare the domain part exactly, a regex would treat dots in the domain as wildcards
$stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE SUBSTRING_INDEX(`rcpt`, "@", -1) = :domain');
$stmt->execute(array(':domain' => $domain));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while($row = array_shift($rows)) {
$q_meta[] = $row;
+23 -12
View File
@@ -1098,6 +1098,7 @@ function init_db_schema()
"host1" => "VARCHAR(255) NOT NULL",
"port1" => "SMALLINT UNSIGNED NOT NULL",
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
"verify_cert" => "TINYINT(1) NOT NULL DEFAULT '1'",
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
@@ -1679,9 +1680,15 @@ function init_db_schema()
// Converted JSON may not fit into the old VARCHAR(512)
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`)
VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)");
// A legacy source is found by its connection and its only target domain (none for jobs without domain)
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` s WHERE `created_by` = '' AND `host1` = :host AND `port1` = :port
AND `enc1` = :enc AND `auth_type` = :auth
AND (SELECT GROUP_CONCAT(`domain`) FROM `imapsync_source_domain` d WHERE d.`source_id` = s.`id`) <=> NULLIF(:domain, '')
ORDER BY `id` LIMIT 1");
$source_name_sel = $pdo->prepare("SELECT COUNT(*) FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
// The old runner never checked certificates, legacy sources keep that
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `verify_cert`, `auth_type`, `active`)
VALUES (:name, 'Migrated sync jobs', '', 'domain', :host, :port, :enc, 0, :auth, 1)");
$source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
$job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy,
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
@@ -1741,16 +1748,20 @@ function init_db_schema()
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
$at = strrchr((string)$row['user2'], '@');
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
// One source per (host, port, enc, auth, domain), name always unique and <= 100 chars
$name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8);
$source_sel->execute(array(':name' => $name));
// One source per (host, port, enc, auth, domain), named "host (domain)" plus " 2", " 3"... when taken (<= 100 chars)
$conn = array(':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth);
$source_sel->execute($conn + array(':domain' => $domain));
if (($source_id = $source_sel->fetchColumn()) === false) {
$source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth));
$source_sel->execute(array(':name' => $name));
if (($source_id = $source_sel->fetchColumn()) === false) continue;
}
if ($domain !== '') {
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
$base = mb_substr($host . ($domain !== '' ? ' (' . $domain . ')' : ''), 0, 94);
for ($i = 1, $name = $base; $i < 1000; $name = $base . ' ' . ++$i) {
$source_name_sel->execute(array(':name' => $name));
if ($source_name_sel->fetchColumn() == 0) break;
}
$source_ins->execute($conn + array(':name' => $name));
if (($source_id = $pdo->lastInsertId()) == 0) continue;
if ($domain !== '') {
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
}
}
$job_upd->execute(array(
':source_id' => $source_id,
@@ -44,6 +44,8 @@ class SyncPlanner
/** @var array<string, DirectoryUser> $candidates email => user to create, template still open */
$candidates = [];
$duplicates = [];
/** @var array<string, true> $ambiguous unlinked mailboxes whose email several IdP users have, left as they are */
$ambiguous = [];
foreach ($listing->users as $user) {
if (isset($owners[$user->id])) {
$username = $owners[$user->id];
@@ -55,10 +57,19 @@ class SyncPlanner
$plan->skipped++;
} elseif (isset($state->accounts[$user->email])) {
$account = $state->accounts[$user->email];
if ($account->externalId !== null || isset($plan->links[$user->email])) {
if ($account->externalId !== null) {
self::conflict($plan, $user->email . ' is linked to another IdP user');
continue;
}
if (isset($plan->links[$user->email]) || isset($ambiguous[$user->email])) {
// Which IdP user owns the mailbox is unclear: link none of them
if (!isset($ambiguous[$user->email])) {
self::conflict($plan, 'Several IdP users have the email ' . $user->email);
}
unset($plan->links[$user->email], $matched[$user->email]);
$ambiguous[$user->email] = true;
continue;
}
$plan->links[$user->email] = $user->id;
$matched[$user->email] = $user;
} elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled
@@ -85,7 +96,7 @@ class SyncPlanner
if ($groups !== null) {
$this->planGroups($settings, $groups, $state, $usernames, $matched, $plan);
}
$this->planStates($settings, $state, $matched, $plan);
$this->planStates($settings, $state, $matched, $ambiguous, $plan);
if ($settings->periodicSync) {
$this->planUpdates($settings, $state, $matched, $plan);
}
@@ -211,10 +222,13 @@ class SyncPlanner
}
/** @param array<string, DirectoryUser> $matched */
private function planStates(Settings $settings, State $state, array $matched, SyncPlan $plan): void
private function planStates(Settings $settings, State $state, array $matched, array $ambiguous, SyncPlan $plan): void
{
foreach ($state->accounts as $username => $account) {
$username = (string)$username;
if (isset($ambiguous[$username])) {
continue;
}
$user = $matched[$username] ?? null;
if ($user === null && $account->missingSince === null) {
$plan->missing[] = $username;
@@ -19,6 +19,14 @@ class CustomParams
{
private const MAX_PAIRS = 100;
private const MAX_VALUE_LENGTH = 1000;
/**
* Never allowed, even when configured: code or command execution, local files, the environment
* of the container (tests*), passwords in the output and stopping other runs (abort).
* The runner (dovecot/imapsync_runner.py) skips the same options.
*/
public const BLOCKED = ['abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd',
'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess',
'regextrans2', 'showpasswords', 'skipmess', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir'];
private static bool $warnedOldFormat = false;
@@ -57,7 +65,7 @@ class CustomParams
/**
* The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old
* 'whitelist' key and malformed entries; the defaults when nothing valid is left.
* 'whitelist' key, malformed entries and BLOCKED options; the defaults when nothing valid is left.
*/
public static function allowlist(array $configured, array $defaults): array
{
@@ -73,7 +81,7 @@ class CustomParams
self::$warnedOldFormat = true;
error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)");
}
return empty($allowlist) ? $defaults : $allowlist;
return array_diff_key(empty($allowlist) ? $defaults : $allowlist, array_flip(self::BLOCKED));
}
/** The value to store for one option; throws when the allowlist rule is violated */
@@ -29,6 +29,7 @@ class Source
public string $host = '';
public int $port = 0;
public ?string $enc = 'TLS';
public int $verifyCert = 1;
public ?string $authType = 'PLAIN';
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
public ?string $tokenEndpoint = null;
@@ -65,6 +66,7 @@ class Source
$source->scope = (string)($row['scope'] ?? 'user');
$source->host = (string)($row['host1'] ?? '');
$source->port = (int)($row['port1'] ?? 0);
$source->verifyCert = (int)($row['verify_cert'] ?? 1);
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
@@ -112,6 +114,7 @@ class Source
'host1' => $this->host,
'port1' => $this->port,
'enc1' => $this->enc,
'verify_cert' => $this->verifyCert,
'auth_type' => $this->authType,
'oauth_flow' => $this->oauthFlow,
'oauth_token_endpoint' => $this->tokenEndpoint,
@@ -48,6 +48,12 @@ class SourceRepository extends Repository
return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn();
}
/** @return string[] mailboxes whose sync jobs use the source */
public function jobMailboxes(int $id): array
{
return $this->query("SELECT DISTINCT `user2` FROM `imapsync` WHERE `source_id` = :id", ['id' => $id])->fetchAll(PDO::FETCH_COLUMN);
}
public function isSuperAdmin(string $username): bool
{
return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'",
@@ -200,6 +206,7 @@ class SourceRepository extends Repository
'host1' => $source->host,
'port1' => $source->port,
'enc1' => $source->enc,
'verify_cert' => $source->verifyCert,
'auth_type' => $source->authType,
'oauth_flow' => $source->oauthFlow,
'oauth_token_endpoint' => $source->tokenEndpoint,
@@ -121,7 +121,11 @@ class SourceService
if ($writeTargets) {
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
}
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
$connectionChanged = self::connection($current) !== self::connection($source);
if ($connectionChanged || ($current->verifyCert && !$source->verifyCert)) {
$this->requireAllJobsAccessible($source);
}
$this->repo->update($source, $writeTargets, $connectionChanged);
$this->fetchToken($source);
return $source;
}
@@ -200,6 +204,24 @@ class SourceService
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
}
/**
* The connection of a source only changes when the caller may access every mailbox whose sync jobs use it,
* their stored passwords would go to the new server.
*
* @throws UserError imapsync_source_used_by_others
*/
private function requireAllJobsAccessible(Source $source): void
{
if ($this->context->hasAdminRights()) {
return;
}
foreach ($this->repo->jobMailboxes((int)$source->id) as $mailbox) {
if (!$this->context->canAccessMailbox($mailbox)) {
throw new UserError('imapsync_source_used_by_others', [htmlspecialchars($source->name)]);
}
}
}
/** @throws AccessDenied when the caller cannot see or edit the source */
private function editable(int $id): Source
{
@@ -274,6 +296,7 @@ class SourceService
$source->host = $host;
$source->port = $port;
$source->enc = $enc;
$source->verifyCert = isset($input['verify_cert']) ? (int)!empty($input['verify_cert']) : $source->verifyCert;
$source->authType = $authType;
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
}
@@ -42,6 +42,8 @@ class Group
public array $sendAs = [];
/** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */
public array $imapGrants = [];
/** @var string[] owners of removed IMAP grants whose ACL Dovecot still has, not part of the grants to write */
public array $removingImapOwners = [];
/** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */
public array $sogoGrants = [];
/** @var array[] warnings of the last write (msg values), not stored */
@@ -501,6 +501,8 @@ class GroupRepository extends Repository
$group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
$this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant`
WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC));
$group->removingImapOwners = $this->query("SELECT `owner` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id AND `state` = 'removing'",
$id)->fetchAll(PDO::FETCH_COLUMN);
$group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
$this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error`
FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC));
@@ -79,6 +79,12 @@ class GroupService
return false;
}
}
// A removed grant still opens the mailbox until Dovecot dropped its ACL
foreach ($group->removingImapOwners as $owner) {
if (!$this->context->canAccessMailbox((string)$owner)) {
return false;
}
}
return true;
}
@@ -113,7 +119,7 @@ class GroupService
$current = $this->editable($id);
$group = clone $current;
$this->apply($group, $input, $current);
$snapshot = $this->effects->snapshot(array_merge($current->usernames(), $group->usernames()));
$snapshot = $this->effects->snapshot($this->inScope(array_merge($current->usernames(), $group->usernames())));
$sogo = $this->repo->update($group);
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
@@ -132,7 +138,7 @@ class GroupService
{
$this->requireEditor();
$group = $this->editable($id);
$snapshot = $this->effects->snapshot($group->usernames());
$snapshot = $this->effects->snapshot($this->inScope($group->usernames()));
$this->sharing->removeImap($group);
$sogo = $this->repo->delete($id);
$group->warnings = $this->effects->apply($sogo, $snapshot);
@@ -150,13 +156,30 @@ class GroupService
if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) {
return [];
}
$groups = $this->lookup->membershipsOf($username);
// Domain admins see the mailbox's groups in their domains, not those of other domains
if (!$own && !$this->context->hasAdminRights()) {
$groups = array_values(array_filter($groups, fn(array $group): bool => $this->context->canAccessDomain((string)$group['domain'])));
}
return [
'groups' => $this->lookup->membershipsOf($username),
'groups' => $groups,
'template' => $this->templates->effective([$username])[$username] ?? null,
'send_as' => $this->lookup->sendAsOf($username),
];
}
/**
* Members whose templates a change of the caller may apply: a domain admin never changes mailboxes outside
* his domains, their new effective template applies with the next admin action or IdP login.
*/
private function inScope(array $usernames): array
{
if ($this->context->hasAdminRights()) {
return $usernames;
}
return array_values(array_filter($usernames, fn(string $username): bool => $this->context->canAccessMailbox($username)));
}
/** @throws AccessDenied when the caller cannot see or edit the group */
private function editable(int $id): Group
{
+4 -2
View File
@@ -19,8 +19,10 @@ if ($iam_provider){
header('Location: ' . $redirect_uri);
die();
}
} elseif ($_GET['code'] && $_GET['state'] === $_SESSION['oauth2state']) {
// Check given state against previously stored one to mitigate CSRF attack
} elseif (!empty($_GET['code']) && !empty($_SESSION['oauth2state']) && is_string($_GET['state'] ?? null)
&& hash_equals((string)$_SESSION['oauth2state'], $_GET['state'])) {
// Check given state against previously stored one to mitigate CSRF attack, a state is used once
unset($_SESSION['oauth2state']);
// Received access token in $_GET['code']
// extract info and verify user
identity_provider('verify-sso');
+8 -5
View File
@@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID";
// Label for OTP devices
$OTP_LABEL = "mailcow UI";
// Failed second factor logins (TOTP, Yubico OTP, WebAuthn) of an account before its second factor is locked
$TFA_MAX_FAILS = 10;
// Time (in s) from the first failed second factor login until the counter and the lock are reset
$TFA_FAIL_WINDOW = 900;
// How long to wait (in s) for cURL Docker requests
$DOCKER_TIMEOUT = 60;
@@ -291,13 +297,13 @@ $RSPAMD_MAPS = array(
// true = option requires a non-empty value
// false = pure flag, must NOT have a value
// string = PCRE (no delimiters) the whole non-empty value must match
// Anything not listed here is rejected when a job is saved (the runner passes stored options as they are).
// Anything not listed here is rejected when a job is saved; the runner passes stored options as they are, except the blocked ones below.
// Override in vars.local.inc.php per option, e.g. $IMAPSYNC_OPTIONS['someoption'] = true; or unset($IMAPSYNC_OPTIONS['debug']);
// The old format $IMAPSYNC_OPTIONS['whitelist'] (list of names) is no longer supported and ignored (logged).
// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2'
// NEVER add options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file)
// These, the tests* options (print the environment), 'showpasswords' and 'abort' (stops other runs) are also blocked in code
$IMAPSYNC_OPTIONS_DEFAULT = array(
'abort' => false,
'authmd51' => false,
'authuser1' => true,
'debug' => false,
@@ -373,9 +379,6 @@ $IMAPSYNC_OPTIONS_DEFAULT = array(
'syncduplicates' => false,
'syncinternaldates' => false,
'synclabels' => false,
'tests' => false,
'testslive' => false,
'testslive6' => false,
'truncmess' => true,
'usecache' => false,
'useheader' => true,
+5 -1
View File
@@ -184,7 +184,11 @@ var mailcowImapsync = (function($) {
dataSrc: function(json) {
if (!Array.isArray(json)) return [];
$.each(json, function(i, item) {
item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')'));
item.endpoint = esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')');
if (item.verify_cert == 0 && (item.enc || item.enc1) !== 'PLAIN') {
item.endpoint += ' <i class="bi bi-shield-exclamation text-warning" title="' + esc(L('source_verify_cert_off')) + '"></i>';
}
item.endpoint = wrapCell(item.endpoint);
item.name_display = esc(item.name);
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
item.name_display = wrapCell(item.name_display);
+14 -7
View File
@@ -614,7 +614,7 @@ jQuery(function($){
defaultContent: ''
},
{
title: 'RL',
title: lang_edit.ratelimit,
data: 'rl',
searchable: false,
orderable: false,
@@ -715,10 +715,6 @@ jQuery(function($){
$.each(json, function (i, item) {
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="domain_template" name="multi_select" value="' + encodeURIComponent(item.id) + '" />';
item.attributes.def_quota_for_mbox = humanFileSize(item.attributes.def_quota_for_mbox);
item.attributes.max_quota_for_mbox = humanFileSize(item.attributes.max_quota_for_mbox);
item.attributes.max_quota_for_domain = humanFileSize(item.attributes.max_quota_for_domain);
item.template = escapeHtml(item.template);
if (item.attributes.rl_frame === "s"){
item.attributes.rl_frame = lang_rl.second;
@@ -801,16 +797,25 @@ jQuery(function($){
title: lang.mailbox_defquota,
data: 'attributes.def_quota_for_mbox',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.max_quota,
data: 'attributes.max_quota_for_mbox',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.domain_quota_total,
data: 'attributes.max_quota_for_domain',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.gal,
@@ -932,6 +937,7 @@ jQuery(function($){
item.last_mail_login = (item.attributes.imap_access == 1 ? '<div class="text-start badge bg-info mb-2" style="min-width: 70px;">IMAP @ ' + unix_time_format(Number(item.last_imap_login)) + '</div><br>' : '') +
(item.attributes.pop3_access == 1 ? '<div class="text-start badge bg-info mb-2" style="min-width: 70px;">POP3 @ ' + unix_time_format(Number(item.last_pop3_login)) + '</div><br>' : '') +
(item.attributes.smtp_access == 1 ? '<div class="text-start badge bg-info mb-2" style="min-width: 70px;">SMTP @ ' + unix_time_format(Number(item.last_smtp_login)) + '</div><br>' : '') +
'<div class="text-start badge bg-info mb-2" style="min-width: 70px;">SOGO @ ' + unix_time_format(Number(item.last_sogo_login)) + '</div><br>' +
'<div class="text-start badge bg-info" style="min-width: 70px;">SSO @ ' + unix_time_format(Number(item.last_sso_login)) + '</div>';
/*
if (!item.rl) {
@@ -1244,8 +1250,6 @@ jQuery(function($){
item.attributes.rl_value = (!item.attributes.rl_value) ? "∞" : escapeHtml(item.attributes.rl_value);
item.attributes.ratelimit = item.attributes.rl_value + " " + item.attributes.rl_frame;
item.attributes.quota = humanFileSize(item.attributes.quota);
item.attributes.tls_enforce_in = '<i class="text-' + (item.attributes.tls_enforce_in == 1 ? 'success bi bi-lock-fill' : 'danger bi bi-unlock-fill') + '"><span class="sorting-value">' + (item.attributes.tls_enforce_in == 1 ? '1' : '0') + '</span></i>';
item.attributes.tls_enforce_out = '<i class="text-' + (item.attributes.tls_enforce_out == 1 ? 'success bi bi-lock-fill' : 'danger bi bi-unlock-fill') + '"><span class="sorting-value">' + (item.attributes.tls_enforce_out == 1 ? '1' : '0') + '</span></i>';
item.attributes.pop3_access = '<i class="text-' + (item.attributes.pop3_access == 1 ? 'success' : 'danger') + ' bi bi-' + (item.attributes.pop3_access == 1 ? 'check-lg' : 'x-lg') + '"><span class="sorting-value">' + (item.attributes.pop3_access == 1 ? '1' : '0') + '</span></i>';
@@ -1332,6 +1336,9 @@ jQuery(function($){
title: lang.domain_quota,
data: 'attributes.quota',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.tls_enforce_in,
+34 -4
View File
@@ -105,9 +105,28 @@ if (isset($_GET['query'])) {
'msg' => 'Cannot find attributes in post data'
));
// A login with pending 2FA setup or password update may only use the calls of these steps
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
if (!empty($_SESSION['pending_tfa_setup'])) {
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-registration', 'add/webauthn-tfa-registration');
}
else {
// the WebAuthn login of a TFA account comes before the password update
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-get-args', 'edit/self', 'edit/admin');
}
if (!in_array($action . '/' . $category, $pending_allowed)) {
http_response_code(403);
echo json_encode(array(
'type' => 'error',
'msg' => 'API access denied until the pending account setup is finished'
));
exit();
}
}
switch ($action) {
case "add":
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username'])) {
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username']) || !isset($_SESSION["mailcow_cc_username"])) {
http_response_code(403);
echo json_encode(array(
'type' => 'error',
@@ -1068,10 +1087,14 @@ if (isset($_GET['query'])) {
$data['mailboxes'][$mailbox][] = $mailbox;
$user_alias_details = user_get_alias_details($mailbox);
foreach ($user_alias_details['direct_aliases'] as $k => $v) {
$data['mailboxes'][$mailbox][] = $k;
if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) {
$data['mailboxes'][$mailbox][] = $k;
}
}
foreach ($user_alias_details['shared_aliases'] as $k => $v) {
$data['mailboxes'][$mailbox][] = $k;
if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) {
$data['mailboxes'][$mailbox][] = $k;
}
}
}
}
@@ -1549,7 +1572,14 @@ if (isset($_GET['query'])) {
if ($object == "sync-status") {
process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus());
} elseif($_SESSION['mailcow_cc_role'] === 'admin') {
process_get_return($iam_settings);
$iam_settings_out = $iam_settings;
// Read-only API keys never get the secrets of the identity provider
if (($_SESSION['mailcow_cc_api_access'] ?? '') == 'ro') {
foreach (array('client_secret', 'bindpass', 'access_token') as $iam_secret) {
if (isset($iam_settings_out[$iam_secret])) $iam_settings_out[$iam_secret] = '*';
}
}
process_get_return($iam_settings_out);
} else {
process_get_return(null);
}
+7 -1
View File
@@ -475,6 +475,7 @@
"ui_theme_default": "Standard",
"ui_theme_info": "Themes sind Ordner in <code>data/web/css/themes/&lt;name&gt;/</code> mit folgenden Dateien:<ul class=\"mb-0\"><li><code>theme.css</code> (erforderlich)</li><li><code>dark.css</code> (optional, Darkmode)</li><li><code>bootstrap.css</code> (optional, ersetzt das Bootstrap-Basistheme)</li><li><code>theme.json</code> (optional, <code>name</code> und <code>description</code>)</li></ul>",
"ui_theme_none": "Keine Themes gefunden.",
"unban_all": "Alle entsperren",
"unban_pending": "ausstehend",
"unchanged_if_empty": "Unverändert, wenn leer",
"upload": "Hochladen",
@@ -641,6 +642,7 @@
"temp_error": "Temporärer Fehler",
"text_empty": "Text darf nicht leer sein",
"tfa_token_invalid": "TFA-Token ungültig",
"tfa_login_locked": "Zu viele fehlgeschlagene Zwei-Faktor-Anmeldungen, bitte später erneut versuchen",
"tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.",
"tls_policy_map_dest_invalid": "Ziel ist ungültig",
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
@@ -666,6 +668,7 @@
"template_name_invalid": "Name der Vorlage ungültig",
"required_data_missing": "Die benötigten Daten: %s fehlen",
"imapsync_source_in_use": "Sync-Quelle %s wird noch von einem Sync-Job verwendet und kann nicht gelöscht werden",
"imapsync_source_used_by_others": "Der Server der Sync-Quelle %s kann nicht geändert werden, Sync-Jobs anderer Postfächer verwenden sie",
"imapsync_source_token_refresh_failed": "OAuth-Token-Erneuerung für %s fehlgeschlagen",
"imapsync_source_scope_empty": "Für die gewählte Sichtbarkeit mindestens eine Domain wählen",
"imapsync_source_unavailable": "Die gewählte Sync-Quelle ist nicht verfügbar",
@@ -965,7 +968,7 @@
"idp_ref": "IdP-Gruppe",
"imap_grants": "Postfachzugriff",
"imap_grants_hint": "Mitglieder öffnen diese Mailboxen in ihrem Mailprogramm (Ordner Shared/<mailbox>). Lesen: lesen und als gelesen markieren. Schreiben: zusätzlich ablegen, verschieben und löschen. Vollzugriff: zusätzlich Ordner löschen. Team-Postfächer ohne eigenen Login als geteiltes Postfach markieren.",
"info": "Gruppen bündeln Postfächer. Eine Gruppe kann eine eigene Adresse haben: ein Alias, der automatisch an alle Mitglieder zustellt (in Exchange: Verteilerliste). Mitglieder können als andere Adressen senden, auf freigegebene Postfächer, Kalender und Adressbücher zugreifen und ein Postfach-Template erhalten. Vom Identity Provider synchronisierte Mitglieder sind mit IdP markiert.",
"info": "Gruppen bündeln Postfächer. Eine Gruppe kann eine eigene Adresse haben: ein Alias, der automatisch an alle Mitglieder zustellt. Mitglieder können als andere Adressen senden, auf freigegebene Postfächer, Kalender und Adressbücher zugreifen und ein Postfach-Template erhalten. Vom Identity Provider synchronisierte Mitglieder sind mit IdP markiert.",
"internal": "Nur Mails von Absendern der Domain annehmen",
"list_error": "Gruppen konnten nicht geladen werden",
"managed_alias": "Wird von Gruppe %s verwaltet",
@@ -1537,6 +1540,9 @@
"source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.",
"source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.",
"source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
"source_verify_cert": "Zertifikat des Servers prüfen",
"source_verify_cert_hint": "Gilt für SSL und STARTTLS. Für Server mit selbstsigniertem, abgelaufenem oder nicht zum Host passendem Zertifikat abschalten.",
"source_verify_cert_off": "Das Zertifikat des Servers wird nicht geprüft",
"copy": "Kopieren",
"copied": "In die Zwischenablage kopiert",
"source_view": "Anzeigen",
+9 -2
View File
@@ -485,6 +485,7 @@
"ui_theme_default": "Default",
"ui_theme_info": "Themes are folders in <code>data/web/css/themes/&lt;name&gt;/</code> with the following files:<ul class=\"mb-0\"><li><code>theme.css</code> (required)</li><li><code>dark.css</code> (optional, dark mode)</li><li><code>bootstrap.css</code> (optional, replaces the Bootstrap base theme)</li><li><code>theme.json</code> (optional, <code>name</code> and <code>description</code>)</li></ul>",
"ui_theme_none": "No themes found.",
"unban_all": "Unban all",
"unban_pending": "unban pending",
"unchanged_if_empty": "If unchanged leave blank",
"upload": "Upload",
@@ -517,6 +518,7 @@
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
"iam_sync_mode_invalid": "Invalid sync mode",
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
"tfa_login_locked": "Too many failed two-factor logins, please try again later",
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
"alias_domain_invalid": "Alias domain %s is invalid",
"alias_empty": "Alias address must not be empty",
@@ -666,6 +668,7 @@
"version_invalid": "Version %s is invalid",
"yotp_verification_failed": "Yubico OTP verification failed: %s",
"imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted",
"imapsync_source_used_by_others": "The server of sync source %s cannot be changed, sync jobs of other mailboxes use it",
"imapsync_source_token_refresh_failed": "OAuth token refresh for %s failed",
"imapsync_source_scope_empty": "Select at least one domain for the chosen visibility",
"imapsync_source_unavailable": "The selected sync source is unavailable",
@@ -965,7 +968,7 @@
"idp_ref": "IdP group",
"imap_grants": "Mailbox access",
"imap_grants_hint": "Members open these mailboxes in their mail client (folder Shared/<mailbox>). Read: read and mark as read. Write: also file, move and delete mails. Full access: also delete folders. For team mailboxes without a login of their own, mark the mailbox as shared mailbox.",
"info": "Groups bundle mailboxes. A group can have its own address: an alias that delivers to all members automatically (in Exchange: distribution list). Members can send as other addresses, access shared mailboxes, calendars and address books and get a mailbox template. Members synced from the identity provider are marked IdP.",
"info": "Groups bundle mailboxes. A group can have its own address: an alias that delivers to all members automatically. Members can send as other addresses, access shared mailboxes, calendars and address books and get a mailbox template. Members synced from the identity provider are marked IdP.",
"internal": "Only accept mail from senders of the domain",
"list_error": "Groups could not be loaded",
"managed_alias": "Managed by group %s",
@@ -1124,7 +1127,7 @@
"mailbox_defaults_info": "Define default settings for new mailboxes.",
"mailbox_defquota": "Default mailbox size",
"mailbox_templates": "Mailbox Templates",
"mailbox_quota": "Max. size of a mailbox",
"mailbox_quota": "Max. mailbox size",
"mailboxes": "Mailboxes",
"max_aliases": "Max. aliases",
"max_mailboxes": "Max. possible mailboxes",
@@ -1426,6 +1429,7 @@
"u2f_deprecated": "It seems that your Key was registered using the deprecated U2F method. We will deactivate Two-Factor-Authenticaiton for you and delete your Key.",
"u2f_deprecated_important": "Please register your Key in the admin panel with the new WebAuthn method.",
"webauthn": "WebAuthn authentication",
"webauthn_registration_failed": "WebAuthn registration failed. The credential could not be verified, please try again.",
"waiting_usb_auth": "<i>Waiting for USB device...</i><br><br>Please tap the button on your USB device now.",
"waiting_usb_register": "<i>Waiting for USB device...</i><br><br>Please enter your password above and confirm your registration by tapping the button on your USB device.",
"yubi_otp": "Yubico OTP authentication"
@@ -1544,6 +1548,9 @@
"source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.",
"source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.",
"source_readonly_hint": "This sync source is shared with you and can only be viewed.",
"source_verify_cert": "Verify the server certificate",
"source_verify_cert_hint": "For SSL and STARTTLS. Turn off for servers with a self-signed or expired certificate or a certificate that does not match the host.",
"source_verify_cert_off": "The server certificate is not verified",
"copy": "Copy",
"copied": "Copied to clipboard",
"source_view": "Show",
-5
View File
@@ -1074,7 +1074,6 @@
"verified_webauthn_login": "Inicio de sesión WebAuthn verificado",
"verified_yotp_login": "Inicio de sesión Yubico OTP verificado",
"admin_added": "Se agregó el administrador %s",
"admin_api_modified": "Se guardaron los cambios en la API",
"admin_removed": "Se eliminó el administrador %s",
"app_links": "Se guardaron los cambios en los enlaces de aplicaciones",
"app_passwd_added": "Se agregó una nueva contraseña de aplicación",
@@ -1333,10 +1332,6 @@
"sogo_profile_reset_help": "Esto destruirá el perfil de SOGo del usuario y <b>eliminará de forma irrecuperable todos sus contactos y datos de calendario</b>.",
"spam_score_reset": "Restablecer al valor predeterminado del servidor",
"spamfilter_table_domain_policy": "n/d (política del dominio)",
"syncjob_check_log": "Revisar registro",
"syncjob_EX_OK": "Correcto",
"syncjob_EXIT_OVERQUOTA": "El buzón de destino superó su cuota",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Usuario o contraseña incorrectos",
"tfa_info": "La autenticación de dos factores ayuda a proteger su cuenta. Si la activa, necesitará contraseñas de aplicación para iniciar sesión en aplicaciones o servicios que no admiten la autenticación de dos factores (p. ej., clientes de correo)."
},
"warning": {
-1
View File
@@ -95,7 +95,6 @@
"bcc_dest_format": "Il destinatario in copia nascosta deve essere un singolo indirizzo email.<br>Se si vuole spedire una copia del messaggio a più destinatari, bisogna creare un alias ed utilizzarlo per questa opzione.",
"app_passwd_protocols": "Protocolli consentiti per la password dell'app",
"tags": "Tag",
"dry": "Simula sincronizzazione",
"internal": "Interno",
"internal_info": "Gli alias interni sono accessibili solo dal proprio dominio o dai domini alias.",
"sender_allowed": "Consenti l'invio con questo alias"
+5 -1
View File
@@ -12,6 +12,10 @@ if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'use
header('Location: index.php?mobileconfig' . $append_get);
die();
}
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
header('Location: /');
die();
}
error_reporting(0);
@@ -39,7 +43,7 @@ if (isset($_GET['only_email'])) {
$onlyEmailAccount = false;
$description = 'IMAP, CalDAV, CardDAV';
}
if (isset($_GET['app_password'])) {
if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') {
$app_password = true;
$description .= ' with application password';
+3 -1
View File
@@ -35,7 +35,9 @@ if (empty($_SESSION['mailcow_cc_username']) || empty($_SESSION['mailcow_cc_role'
}
$context = \Mailcow\Core\Context::fromSession($_SESSION);
$imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context);
if (!$context->hasAcl('syncjobs')) {
// Storing a token is a write: no read-only API keys, no session with pending 2FA setup or password update
if (!$context->hasAcl('syncjobs') || $context->isReadOnlyApi()
|| !empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
syncjob_oauth_finish(false, array('error' => 'access_denied'));
}
@@ -106,6 +106,9 @@
<button class="btn btn-secondary" type="button" data-action="edit_selected" data-item="{{ f2b_data.banlist_id }}" data-id="f2b_banlist" data-api-url='edit/fail2ban/banlist' data-api-attr='{}'><i class="bi bi-arrow-clockwise"></i></button>
</div>
</form>
{% if f2b_data.active_bans %}
<p><button class="btn btn-sm btn-secondary" data-action="edit_selected" data-item="self" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"unban-all"}' href="#"><i class="bi bi-unlock"></i> {{ lang.admin.unban_all }}</button></p>
{% endif %}
{% for active_ban in f2b_data.active_bans %}
<p>
<span class="badge fs-7 bg-info d-block d-sm-inline-block">
+17 -11
View File
@@ -416,18 +416,24 @@ function recursiveBase64StrToArrayBuffer(obj) {
return navigator.credentials.create(json);
}).then(function(cred) {
return {
id: cred.id,
rawId: arrayBufferToBase64(cred.rawId),
response: {
attestationObject: arrayBufferToBase64(cred.response.attestationObject),
clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON)
},
type: cred.type
clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON),
attestationObject: arrayBufferToBase64(cred.response.attestationObject),
key_id: keyId,
tfa_method: 'webauthn',
confirm_password: ''
};
}).then(function(credData) {
$('#setup_webauthn_register_data').val(JSON.stringify(credData));
$('#setup_webauthn_reg_form input[name=set_tfa]').val('1');
$('#setup_webauthn_reg_form').submit();
}).then(JSON.stringify).then(function(credentialData) {
// send the attestation to the JSON API, like the regular WebAuthn modal does,
// so that the credential is parsed and stored correctly
return window.fetch('/api/v1/add/webauthn-tfa-registration', {method: 'POST', body: credentialData, cache: 'no-cache'});
}).then(function(response) {
return response.json();
}).then(function(json) {
if (json.success) {
window.location = window.location.href.split('#')[0];
} else {
throw new Error(json.msg);
}
}).catch(function(err) {
$('#setup_webauthn_return_code').show().text(err.message || 'Registration failed');
});
@@ -12,6 +12,7 @@
{% endif %}
<form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post">
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="verify_cert">
<div class="row mb-2">
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
<div class="col-sm-10">
@@ -99,6 +100,14 @@
</select>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert"{% if result.verify_cert == '1' %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.syncjobs.source_verify_cert }}</label>
</div>
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
<div class="col-sm-10">
@@ -43,7 +43,7 @@
</td>
<td class="text-end">
{% if not ro %}
<button class="btn btn-sm btn-secondary group-grant-del" title="{{ lang.mailbox.remove }}"><i class="bi bi-x-lg"></i></button>
<button type="button" class="btn btn-sm btn-secondary group-grant-del" title="{{ lang.mailbox.remove }}"><i class="bi bi-x-lg"></i></button>
{% endif %}
</td>
</tr>
@@ -17,7 +17,7 @@
<template id="{{ id }}_template">
{% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': {}} %}
</template>
<button class="btn btn-sm btn-secondary group-grant-add" data-table="{{ id }}"><i class="bi bi-plus-lg"></i> {{ lang.groups.grant_add }}</button>
<button type="button" class="btn btn-sm btn-secondary group-grant-add" data-table="{{ id }}"><i class="bi bi-plus-lg"></i> {{ lang.groups.grant_add }}</button>
{% endif %}
<small class="text-muted d-block">{{ attribute(lang.groups, field ~ '_hint') }}</small>
</div>
+5 -3
View File
@@ -97,9 +97,11 @@
<div class="col-sm-10">
<select data-live-search="true" data-width="100%" style="width:100%" id="editSelectSenderACL" name="sender_acl" size="10" multiple>
{% for domain in sender_acl_handles.sender_acl_domains.ro %}
<option data-subtext="Admin" value="{{ domain }}" disabled selected>
{{ lang.edit.dont_check_sender_acl|format(domain) }}
</option>
{% if domain == '*' or mailcow_cc_role == 'admin' %}
<option data-subtext="Admin" value="{{ domain }}" disabled selected>
{{ lang.edit.dont_check_sender_acl|format(domain) }}
</option>
{% endif %}
{% endfor %}
{% for domain in sender_acl_handles.sender_acl_domains.rw %}
<option value="{{ domain }}" selected>
@@ -9,6 +9,7 @@
<div class="modal-body">
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="verify_cert">
<div class="row mb-3">
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
<div class="col-sm-10 small">
@@ -99,6 +100,14 @@
</select>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert" checked> {{ lang.syncjobs.source_verify_cert }}</label>
</div>
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end" for="addsrc_auth_type">{{ lang.syncjobs.source_auth_type }}</label>
<div class="col-sm-10">
@@ -77,6 +77,7 @@
{% endif %}
</div>
</div>
{% if acl.app_passwds == 1 %}
<div class="row mt-2">
<div class="col-12 col-md-3 d-flex">
<span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span>
@@ -94,6 +95,7 @@
{% endif %}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-12 d-flex flex-column text-center align-items-center mt-4">
<a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a>
+16 -13
View File
@@ -1,5 +1,5 @@
# Version of the database schema
x-db-version: &db_version DB_VERSION=28102026_1300
x-db-version: &db_version DB_VERSION=02112026_1600
services:
@@ -68,7 +68,7 @@ services:
- redis
clamd-mailcow:
image: ghcr.io/mailcow/clamd:1.4.6-1
image: ghcr.io/mailcow/clamd:1.4.6-2
restart: always
depends_on:
unbound-mailcow:
@@ -194,20 +194,20 @@ services:
ofelia.enabled: "true"
ofelia.job-exec.phpfpm_identity_sync.schedule: "0 * * * * *"
ofelia.job-exec.phpfpm_identity_sync.no-overlap: "true"
ofelia.job-exec.phpfpm_identity_sync.command: "/bin/bash -c \"php /crons/identity-sync.php || exit 0\""
ofelia.job-exec.phpfpm_identity_sync.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/identity-sync.php || exit 0\""
ofelia.job-exec.phpfpm_group_grants.schedule: "0 * * * * *"
ofelia.job-exec.phpfpm_group_grants.no-overlap: "true"
ofelia.job-exec.phpfpm_group_grants.command: "/bin/bash -c \"php /crons/group-grants.php || exit 0\""
ofelia.job-exec.phpfpm_group_grants.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/group-grants.php || exit 0\""
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.schedule: "0 * * * * *"
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.no-overlap: "true"
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"php /crons/imapsync-oauth-refresh.php || exit 0\""
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/imapsync-oauth-refresh.php || exit 0\""
networks:
mailcow-network:
aliases:
- phpfpm
sogo-mailcow:
image: ghcr.io/mailcow/sogo:5.12.12-2
image: ghcr.io/mailcow/sogo:5.12.13-1
environment:
- *db_version
- DBNAME=${DBNAME}
@@ -259,7 +259,7 @@ services:
- sogo
dovecot-mailcow:
image: ghcr.io/mailcow/dovecot:2.3.21.1-3
image: ghcr.io/mailcow/dovecot:2.3.21.1-4
depends_on:
- mysql-mailcow
- netfilter-mailcow
@@ -295,6 +295,7 @@ services:
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
- MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200}
- ACL_ANYONE=${ACL_ANYONE:-disallow}
@@ -347,7 +348,7 @@ services:
- dovecot
postfix-mailcow:
image: ghcr.io/mailcow/postfix:3.10.12-1
image: ghcr.io/mailcow/postfix:3.10.13-1
depends_on:
mysql-mailcow:
condition: service_started
@@ -390,7 +391,7 @@ services:
- postfix
postfix-tlspol-mailcow:
image: ghcr.io/mailcow/postfix-tlspol:1.14.0
image: ghcr.io/mailcow/postfix-tlspol:1.14.5
depends_on:
unbound-mailcow:
condition: service_healthy
@@ -427,7 +428,7 @@ services:
- php-fpm-mailcow
- sogo-mailcow
- rspamd-mailcow
image: ghcr.io/mailcow/nginx:1.30.6-1
image: ghcr.io/mailcow/nginx:1.30.6-3
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
@@ -447,6 +448,7 @@ services:
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
- NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE:-128m}
volumes:
- ./data/web:/web:ro,z
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
@@ -514,7 +516,7 @@ services:
- acme
netfilter-mailcow:
image: ghcr.io/mailcow/netfilter:1.66
image: ghcr.io/mailcow/netfilter:1.67
stop_grace_period: 30s
restart: always
privileged: true
@@ -534,7 +536,7 @@ services:
- /lib/modules:/lib/modules:ro
watchdog-mailcow:
image: ghcr.io/mailcow/watchdog:2.12
image: ghcr.io/mailcow/watchdog:2.13
dns:
- ${IPV4_NETWORK:-172.22.1}.254
tmpfs:
@@ -552,6 +554,7 @@ services:
- acme-mailcow
- redis-mailcow
environment:
- *db_version
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- LOG_LINES=${LOG_LINES:-9999}
- TZ=${TZ}
@@ -609,7 +612,7 @@ services:
- watchdog
dockerapi-mailcow:
image: ghcr.io/mailcow/dockerapi:2.14
image: ghcr.io/mailcow/dockerapi:2.16
security_opt:
- label=disable
restart: always
+4
View File
@@ -215,6 +215,10 @@ HTTPS_BIND=
# Redirect HTTP connections to HTTPS - y/n
HTTP_REDIRECT=y
# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited
# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.
NGINX_MAX_BODY_SIZE=128m
# ------------------------------
# Other bindings
# ------------------------------
-19
View File
@@ -188,25 +188,6 @@ class BanManagerTest(unittest.TestCase):
self.bans.purge()
self.assertEqual(self.bans.counters, {})
def test_excused_address_is_not_counted(self):
ip = ipaddress.ip_address('203.0.113.1')
self.bans.excuse(ip)
self.bans.excuse(ip)
self.attempts('203.0.113.1', 5)
self.assertEqual(self.active(), {})
self.assertEqual(len([message for message in self.logger.messages('warn') if 'Auth backend unavailable' in message]), 1)
# other addresses are counted
self.attempts('203.0.113.2', 3)
self.assertIn('203.0.113.2/32', self.active())
def test_excuse_ends(self):
self.bans.excuse(ipaddress.ip_address('203.0.113.1'))
self.clock.now += BanManager.EXCUSE_TIME + 1
self.bans.purge()
self.assertEqual(self.bans.excused, {})
self.attempts('203.0.113.1', 3)
self.assertIn('203.0.113.1/32', self.active())
if __name__ == '__main__':
unittest.main()
+23 -24
View File
@@ -25,8 +25,8 @@ class NetfilterTest(unittest.TestCase):
netfilter.regexes.refresh(self.r)
return netfilter
def sasl(self, address):
return 'warning: unknown[%s]: SASL LOGIN authentication failed: UGFzc3dvcmQ6' % address
def failed(self, address):
return 'MAILCOWAUTH: Login failed for user a@example.org with service imap from IP %s' % address
def test_setup_restores_bans_and_resets_the_firewall(self):
self.r.hset('F2B_ACTIVE_BANS', '1.2.3.4/32', '9999999999')
@@ -49,8 +49,8 @@ class NetfilterTest(unittest.TestCase):
def test_shutdown_keeps_the_active_bans(self):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1')
netfilter.shutdown()
self.assertEqual(self.tables.rules, set())
@@ -59,52 +59,51 @@ class NetfilterTest(unittest.TestCase):
def test_handle_line_bans(self):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.sasl('1.2.3.4'), self.logger.messages('warn'))
self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.failed('1.2.3.4'), self.logger.messages('warn'))
def test_handle_line_with_trailing_colon(self):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4:'))
netfilter.handle_line(self.sasl('1.2.3.4:'))
netfilter.handle_line(self.failed('1.2.3.4:'))
netfilter.handle_line(self.failed('1.2.3.4:'))
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_handle_line_ignores_private_and_invalid_addresses(self):
netfilter = self.create()
for _ in range(3):
netfilter.handle_line(self.sasl('192.168.1.1'))
netfilter.handle_line(self.sasl('::1'))
netfilter.handle_line(self.sasl('1.2.3'))
netfilter.handle_line(self.failed('192.168.1.1'))
netfilter.handle_line(self.failed('::1'))
netfilter.handle_line(self.failed('1.2.3'))
self.assertEqual(self.tables.rules, set())
self.assertTrue(any('invalid address 1.2.3' in message for message in self.logger.messages('warn')))
def test_handle_line_with_replaced_characters(self):
netfilter = self.create()
line = b'warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: \xff\xfe'.decode('utf-8', 'replace')
line = b'MAILCOWAUTH: Login failed for user \xff\xfe with service imap from IP 1.2.3.4'.decode('utf-8', 'replace')
netfilter.handle_line(line)
netfilter.handle_line(line)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_unavailable_auth_backend_is_no_failed_login(self):
# Dovecot logs the temporary failure, Postfix then the failed SASL login like for a wrong password
def test_postfix_and_dovecot_lines_are_no_failed_logins(self):
# mailcowauth.php reports failed logins, not when the auth backend was unavailable
netfilter = self.create()
netfilter.handle_line('auth-worker(1): Error: conn unix:auth-worker (pid=2,uid=401): auth-worker<4>: '
'lua(a@example.org,1.2.3.4): passdb-lua: Upstream unavailable')
for _ in range(3):
netfilter.handle_line('warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org')
netfilter.handle_line('imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
'method=PLAIN, rip=1.2.3.4, lip=172.22.1.250')
self.assertEqual(self.tables.rules, set())
self.assertNotIn('1.2.3.4/32', self.r.hgetall('F2B_ACTIVE_BANS'))
def test_handle_line_survives_errors(self):
netfilter = self.create()
with mock.patch.object(netfilter.bans, 'attempt', side_effect=RuntimeError('boom')):
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.assertTrue(any('boom' in message for message in self.logger.messages('warn')))
def test_watch_reconnects_after_errors(self):
netfilter = self.create()
self.r.messages.extend([self.sasl('1.2.3.4'), ConnectionError('gone'), self.sasl('1.2.3.4')])
self.r.messages.extend([self.failed('1.2.3.4'), ConnectionError('gone'), self.failed('1.2.3.4')])
netfilter.loop('watch', netfilter.watch, 0)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
self.assertEqual(len(self.r.pubsubs), 2)
@@ -126,8 +125,8 @@ class NetfilterTest(unittest.TestCase):
def test_recreated_chain_gets_isolation_and_bans_again(self):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.tables.rules = set()
self.tables.calls = []
self.tables.recreate = True
@@ -151,8 +150,8 @@ class NetfilterTest(unittest.TestCase):
self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1)
netfilter.update_lists()
self.assertEqual(self.tables.rules, {'198.51.100.1/32'})
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.assertNotIn('1.2.3.4/32', self.tables.rules)
def test_resolve_keeps_addresses_on_dns_errors(self):
+103 -17
View File
@@ -10,14 +10,35 @@ class RegexesTest(unittest.TestCase):
LINES = {
'1': ('mailcow UI: Invalid password for admin by 203.0.113.5', '203.0.113.5'),
'2': ('Rspamd UI: Invalid password by 2001:db8::5', '2001:db8::5'),
'3': ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: UGFzc3dvcmQ6', '203.0.113.6'),
'4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET / HTTP/1.1', '203.0.113.7'),
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]: 503 5.5.0 <a@example.org>: Protocol error; proto=ESMTP', '203.0.113.8'),
'6': ('auth: lua(user@example.org,203.0.113.9,<abcdef>): Password mismatch (SHA1 of given password: 0123ab)', '203.0.113.9'),
'7': ('auth: lua(nobody@example.org,203.0.113.10,<abcdef>): unknown user (SHA1 of given password: 0123ab)', '203.0.113.10'),
'8': ("SOGo[123]: Login from '203.0.113.11' for user nobody might not have worked", '203.0.113.11'),
'9': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
'3': ('MAILCOWAUTH: Login failed for user user@example.org with service smtp from IP 203.0.113.6', '203.0.113.6'),
'4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET _ HTTP_1.1', '203.0.113.7'),
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]:52516: 550 5.5.1 Protocol error; from=<a@example.org> to=<b@example.org>',
'203.0.113.8'),
'6': ("[6714]: SOGoRootPage Login from '203.0.113.11' for user 'nobody@example.org' might not have worked - "
"password policy: 65535 grace: -1 expire: -1 bound: 0", '203.0.113.11'),
'7': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
'8': ('auth: passwd-file(master@mailcow.local,203.0.113.13,master,<qIIX9EZfYISsFgEB>): '
'Password mismatch (SHA1 of given password: 05d839)', '203.0.113.13'),
}
# Defaults of 2025-02 to 2026-10 and of 2022-11 to 2025-02
FORMER = {
'1': r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)',
'2': r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)',
'3': r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+',
'4': r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
'5': r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
'6': r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)',
'7': r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
'8': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
'9': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
}
OLDER = dict(FORMER, **{
'6': r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),',
'7': r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
'8': r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
'9': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
'10': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
})
def setUp(self):
self.r = FakeRedis()
@@ -30,10 +51,82 @@ class RegexesTest(unittest.TestCase):
for rule_id, (line, address) in self.LINES.items():
self.assertEqual(self.regexes.match(line), (rule_id, address), line)
def test_lost_connection_to_auth_server_is_no_attempt(self):
def test_postfix_and_dovecot_lines_are_no_attempts(self):
# mailcowauth.php reports the failed login once, also when Postfix asked Dovecot
self.regexes.refresh(self.r)
line = 'warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: Connection lost to authentication server'
self.assertIsNone(self.regexes.match(line))
for line in ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org',
'auth-worker(37358): conn unix:auth-worker (pid=577,uid=401): auth-worker<25>: '
'lua(a@example.org,203.0.113.6,<j4rbQeReAqwqAPggBBcAAAAFAAEAdgES>): Password mismatch (SHA1 of given password: 6426a0)',
'imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
'method=PLAIN, rip=203.0.113.6, lip=172.22.1.250'):
self.assertIsNone(self.regexes.match(line), line)
def test_failed_master_logins(self):
# Dovecot checks the master passdb itself, mailcowauth.php is not asked
self.regexes.refresh(self.r)
self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,2001:db8::13,master,<dfbigUdfZJysFgEB>): '
'unknown user (SHA1 of given password: 6d0653)'), ('8', '2001:db8::13'))
# no session of Postfix
self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,203.0.113.13,master): '
'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '203.0.113.13'))
# without the client address (doveadm) or for the user of a master login
self.assertIsNone(self.regexes.match('auth: passwd-file(x@mailcow.local,master,<dfbigUdfZJysFgEB>): Password mismatch'))
self.assertIsNone(self.regexes.match('auth: lua(a@example.org,203.0.113.13,master,<dfbigUdfZJysFgEB>): unknown user'))
def test_text_of_clients_does_not_match(self):
# Login names, SMTP commands and addresses of other lines must not ban a foreign address
self.regexes.refresh(self.r)
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: '
'mailcow UI: Invalid password for x by 203.0.113.1'), ('4', '198.51.100.7'))
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: x [203.0.113.1]: y'),
('4', '198.51.100.7'))
self.assertIsNone(self.regexes.match('NOQUEUE: reject: RCPT from unknown[198.51.100.7]: 554 5.7.1 Relay access denied; '
'from=<NOQUEUE: reject: RCPT from [203.0.113.1] Protocol error x>'))
self.assertIsNone(self.regexes.match('improper command pipelining after EHLO from unknown[198.51.100.7]: '
'MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1'))
self.assertEqual(self.regexes.match('MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1 '
'with service smtp from IP 198.51.100.7'),
('3', '198.51.100.7'))
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '198.51.100.7' for user 'x' might not have worked "
"SOGoRootPage Login from '203.0.113.1' for user y might not have worked"), ('6', '198.51.100.7'))
# nginx appends the client to the X-Forwarded-For header a client sent
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '203.0.113.1, 198.51.100.7' for user 'x' might not have worked"),
('6', '198.51.100.7'))
# the address Dovecot adds follows the login name
self.assertEqual(self.regexes.match('auth: passwd-file(x,203.0.113.1,master,<a>): Password mismatch,198.51.100.7,master,<b>): '
'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '198.51.100.7'))
# lines of the web UI are not sanitized like syslog lines
self.assertEqual(self.regexes.match('mailcow UI: Invalid password for admin\n by 203.0.113.5'), ('1', '203.0.113.5'))
def test_former_defaults_are_replaced(self):
self.r.set('F2B_REGEX', json.dumps(self.FORMER))
self.regexes.refresh(self.r)
defaults = {str(rule_id): regex for rule_id, regex in Regexes.DEFAULTS.items()}
# the rule for failed Dovecot logins counts failed master logins now
expected = dict(defaults, **{'6': defaults['8'], '8': defaults['6'], '9': defaults['7']})
del expected['7']
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), expected)
self.assertEqual(self.regexes.match(self.LINES['3'][0]), ('3', '203.0.113.6'))
self.assertEqual(self.regexes.match(self.LINES['6'][0]), ('8', '203.0.113.11'))
self.assertEqual(self.regexes.match(self.LINES['8'][0]), ('6', '203.0.113.13'))
def test_older_defaults_are_replaced(self):
self.r.set('F2B_REGEX', json.dumps(self.OLDER))
self.regexes.refresh(self.r)
stored = json.loads(self.r.get('F2B_REGEX'))
self.assertEqual(sorted(stored, key=int), ['1', '2', '3', '4', '5', '9', '10'])
self.assertEqual(stored['9'], Regexes.DEFAULTS[6])
self.assertEqual(stored['10'], self.OLDER['10'])
def test_changed_rules_are_kept(self):
custom = {'1': r'mailcow UI: Invalid password for (?:x|y) by ([0-9a-f\.:]+)', '2': self.FORMER['3'], '3': r'custom ([0-9\.]+)'}
self.r.set('F2B_REGEX', json.dumps(custom))
self.regexes.refresh(self.r)
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), dict(custom, **{'2': Regexes.DEFAULTS[3]}))
# current rules are not written again
raw = self.r.get('F2B_REGEX')
self.regexes.refresh(self.r)
self.assertEqual(self.r.get('F2B_REGEX'), raw)
def test_only_the_first_matching_rule_counts(self):
self.r.set('F2B_REGEX', json.dumps({'2': r'by ([0-9a-f\.:]+)', '10': r'from ([0-9a-f\.:]+)', '1': r'login from ([0-9a-f\.:]+)'}))
@@ -58,13 +151,6 @@ class RegexesTest(unittest.TestCase):
self.regexes.refresh(self.r)
self.assertIsNone(self.regexes.match('failed by nobody'))
def test_temporary_failure(self):
worker = 'auth-worker(3933): Error: conn unix:auth-worker (pid=3932,uid=401): auth-worker<4>: '
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): passdb-lua: Upstream unavailable'), '203.0.113.1')
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,2001:db8::1): passdb-lua: Upstream unreachable'), '2001:db8::1')
self.assertIsNone(Regexes.temporary_failure('auth: lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): unknown user (SHA1 of given password: 0123ab)'))
self.assertIsNone(Regexes.temporary_failure('warning: unknown[203.0.113.1]: SASL LOGIN authentication failed: (reason unavailable)'))
def test_parse_ip(self):
self.assertEqual(Regexes.parse_ip('203.0.113.1'), ipaddress.ip_address('203.0.113.1'))
self.assertEqual(Regexes.parse_ip('203.0.113.1:'), ipaddress.ip_address('203.0.113.1'))
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Auth;
use PDO;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../Support/legacy_auth_functions.php';
require_once __DIR__ . '/../../../data/web/inc/functions.auth.inc.php';
// Legacy apppass_login() with its own query, on an in-memory SQLite database
class AppPasswordLoginTest extends TestCase
{
private const PROTOCOLS = ['imap_access', 'smtp_access', 'pop3_access', 'sieve_access', 'eas_access', 'dav_access'];
private PDO $pdo;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->sqliteCreateFunction('regexp', fn ($pattern, $value) => preg_match('/' . $pattern . '/', (string)$value) === 1, 2);
$this->pdo->exec("CREATE TABLE `domain` (`domain` TEXT PRIMARY KEY, `active` INTEGER NOT NULL DEFAULT 1)");
$this->pdo->exec("CREATE TABLE `mailbox` (`username` TEXT PRIMARY KEY, `domain` TEXT, `kind` TEXT NOT NULL DEFAULT '',
`active` INTEGER NOT NULL DEFAULT 1, `attributes` TEXT)");
$this->pdo->exec("CREATE TABLE `app_passwd` (`id` INTEGER PRIMARY KEY, `name` TEXT, `mailbox` TEXT, `password` TEXT,
`active` INTEGER NOT NULL DEFAULT 1, " . implode(', ', array_map(fn ($p) => "`$p` INTEGER NOT NULL DEFAULT 0", self::PROTOCOLS)) . ")");
$GLOBALS['pdo'] = $this->pdo;
$_SESSION = [];
}
protected function tearDown(): void
{
unset($GLOBALS['pdo']);
$_SESSION = [];
}
public static function logins(): array
{
$off = array_fill_keys(self::PROTOCOLS, '0');
return [
// [mailbox, domain active, app password, service, password, result, denied]
'imap' => [[], 1, ['imap_access' => 1], 'imap', 'app-secret', 'user', false],
'imap, mailbox imap off' => [['attributes' => $off], 1, ['imap_access' => 1, 'smtp_access' => 1], 'imap', 'app-secret', 'user', false],
'smtp, mailbox smtp off' => [['attributes' => $off], 1, ['imap_access' => 1, 'smtp_access' => 1], 'smtp', 'app-secret', 'user', false],
'eas, mailbox eas off' => [['attributes' => $off], 1, ['eas_access' => 1], 'EAS', 'app-secret', 'user', false],
'dav, mailbox dav off' => [['attributes' => $off], 1, ['dav_access' => 1], 'DAV', 'app-secret', 'user', false],
// denied: right password, login not allowed
'protocol not in app password' => [[], 1, ['imap_access' => 1, 'smtp_access' => 1], 'pop3', 'app-secret', false, true],
'app password inactive' => [[], 1, ['imap_access' => 1, 'active' => 0], 'imap', 'app-secret', false, true],
'mailbox inactive' => [['active' => 0], 1, ['imap_access' => 1], 'imap', 'app-secret', false, true],
'mailbox login disabled' => [['active' => 2], 1, ['imap_access' => 1], 'imap', 'app-secret', false, true],
'domain inactive' => [[], 0, ['imap_access' => 1], 'imap', 'app-secret', false, true],
'shared mailbox' => [['attributes' => ['shared' => '1']], 1, ['imap_access' => 1], 'imap', 'app-secret', false, true],
// failed: counted by netfilter
'wrong password' => [[], 1, ['imap_access' => 1], 'imap', 'wrong', false, false],
'resource mailbox' => [['kind' => 'location'], 1, ['imap_access' => 1], 'imap', 'app-secret', false, false],
];
}
#[DataProvider('logins')]
public function testLogin(array $mailbox, int $domainActive, array $appPasswd, string $service, string $password, string|false $result, bool $denied): void
{
$this->addMailbox($mailbox, $domainActive);
$id = $this->addAppPasswd($appPasswd);
$isDenied = false;
$this->assertSame($result, apppass_login('user@example.org', $password, ['service' => $service, 'is_internal' => true], $isDenied));
$this->assertSame($denied, $isDenied);
if ($result !== false) {
$this->assertEquals($id, $_SESSION['app_passwd_id']);
}
}
public function testUiLoginWithoutServiceIsRefused(): void
{
$this->addMailbox([], 1);
$this->addAppPasswd(array_fill_keys(self::PROTOCOLS, 1));
$this->assertFalse(apppass_login('user@example.org', 'app-secret', ['is_internal' => false]));
}
public function testOtherAppPasswordOfTheMailboxIsTried(): void
{
$this->addMailbox(['attributes' => array_fill_keys(self::PROTOCOLS, '0')], 1);
$this->addAppPasswd(['eas_access' => 1], 'other-secret');
$id = $this->addAppPasswd(['imap_access' => 1]);
$this->assertSame('user', apppass_login('user@example.org', 'app-secret', ['service' => 'imap', 'is_internal' => true]));
$this->assertEquals($id, $_SESSION['app_passwd_id']);
}
private function addMailbox(array $mailbox, int $domainActive): void
{
$attributes = array_merge(array_fill_keys(self::PROTOCOLS, '1'), ['shared' => '0'], $mailbox['attributes'] ?? []);
$this->pdo->prepare("INSERT INTO `domain` (`domain`, `active`) VALUES ('example.org', ?)")->execute([$domainActive]);
$this->pdo->prepare("INSERT INTO `mailbox` (`username`, `domain`, `kind`, `active`, `attributes`) VALUES ('user@example.org', 'example.org', ?, ?, ?)")
->execute([$mailbox['kind'] ?? '', $mailbox['active'] ?? 1, json_encode($attributes)]);
}
private function addAppPasswd(array $appPasswd, string $password = 'app-secret'): int
{
$row = array_merge(array_fill_keys(self::PROTOCOLS, 0), ['active' => 1], $appPasswd);
$hash = '{BLF-CRYPT}' . password_hash($password, PASSWORD_BCRYPT, ['cost' => 4]);
$this->pdo->prepare("INSERT INTO `app_passwd` (`name`, `mailbox`, `password`, " . implode(', ', array_map(fn ($c) => "`$c`", array_keys($row))) . ")
VALUES ('test', 'user@example.org', ?, " . implode(', ', array_fill(0, count($row), '?')) . ")")
->execute(array_merge([$hash], array_values($row)));
return (int)$this->pdo->lastInsertId();
}
}
@@ -26,6 +26,17 @@ class SyncPlannerTest extends TestCase
$this->assertSame([], $plan->conflicts);
}
public function testUnlinkedMailboxWithSeveralIdpUsersIsNeitherLinkedNorOffboarded(): void
{
$users = [self::user('u1', 'a@example.org'), self::user('u2', 'a@example.org', false), self::user('u3', 'a@example.org')];
$plan = $this->plan($users, [new Account('a@example.org', 'A', 1)]);
$this->assertSame([], $plan->links);
$this->assertSame(['Several IdP users have the email a@example.org'], $plan->conflicts);
$this->assertSame([], $plan->missing);
$this->assertSame([], $plan->deactivate);
}
public function testLinkedUserWithNewEmailIsAConflictAndNotCreated(): void
{
$plan = $this->plan([self::user('u1', 'new@example.org')], [new Account('a@example.org', 'A', 1, 'u1')]);
@@ -132,6 +132,37 @@ class CustomParamsTest extends TestCase
$this->assertSame(['default' => true], CustomParams::allowlist(['bad name' => true], ['default' => true]));
}
public function testBlockedOptionsAreNeverAllowed(): void
{
$allowlist = CustomParams::allowlist(['tests' => false, 'pipemess' => true, 'folder' => true], ['abort' => false]);
$this->assertSame(['folder' => true], $allowlist);
$this->assertSame([], CustomParams::allowlist([], ['abort' => false, 'showpasswords' => false]));
$this->expectException(UserError::class);
CustomParams::toJson([['o' => '--tests', 'v' => '']], $allowlist);
}
public function testCodeExecutingOptionsAreBlocked(): void
{
// imapsync evaluates these values as Perl code (eval) or runs them as commands
foreach (['delete2foldersbutnot', 'delete2foldersonly', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2',
'pipemess', 'regexflag', 'regexmess', 'regextrans2', 'skipmess'] as $option) {
$this->assertContains($option, CustomParams::BLOCKED);
}
}
public function testRunnerBlocksTheSameOptions(): void
{
$runner = file_get_contents(__DIR__ . '/../../../../data/Dockerfiles/dovecot/imapsync_runner.py');
$this->assertSame(1, preg_match('/^blocked_params = \{(.*?)\}/ms', $runner, $m));
preg_match_all("/'([a-z0-9]+)'/", $m[1], $options);
$runnerBlocked = $options[1];
$blocked = CustomParams::BLOCKED;
sort($runnerBlocked);
sort($blocked);
$this->assertSame($blocked, $runnerBlocked);
}
public function testOldWhitelistFormatIsIgnored(): void
{
$errorLog = ini_set('error_log', '/dev/null');
@@ -219,6 +219,23 @@ class SourceServiceTest extends TestCase
$this->service(FakeContext::admin())->create(['name' => 'a', 'host1' => '192.0.2.10', 'port1' => 143, 'enc1' => 'PLAIN']);
}
public function testCertificateIsCheckedUnlessTurnedOff(): void
{
$stored = [];
$this->repo->expects($this->exactly(3))->method('insert')->willReturnCallback(function (Source $s) use (&$stored): int {
$stored[] = $s->verifyCert;
return 1;
});
$service = $this->service(FakeContext::admin());
$basics = ['name' => 'a', 'host' => 'imap.example.org', 'port' => 143, 'enc' => 'TLS'];
$service->create($basics);
$service->create($basics + ['verify_cert' => '0']);
$service->create($basics + ['verify_cert' => '1']);
$this->assertSame([1, 0, 1], $stored);
$this->assertSame(0, Source::fromRow(['verify_cert' => '0'])->toArray(true, '')['verify_cert']);
}
public function testNameMustBeUniquePerCreator(): void
{
$this->repo->method('nameExists')->with('<b>', 'admin', null)->willReturn(true);
@@ -292,6 +309,36 @@ class SourceServiceTest extends TestCase
$this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b']));
}
public function testServerOnlyChangesWhenAllJobsAreAccessible(): void
{
$this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org']));
$this->repo->expects($this->exactly(2))->method('jobMailboxes')
->willReturnOnConsecutiveCalls(['u@example.org', 'other@example.org'], ['u@example.org']);
$this->repo->expects($this->exactly(2))->method('update');
$service = $this->service(FakeContext::user('u@example.org'));
$this->expectUserError(['imapsync_source_used_by_others', 'src'], fn() => $service->update(7, ['host' => 'other.example.org']));
$service->update(7, ['host' => 'new.example.org']);
// Name or description changes keep the server and need no check
$service->update(7, ['description' => 'x']);
}
public function testCertificateCheckOnlyTurnsOffWhenAllJobsAreAccessible(): void
{
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(
self::source(['created_by' => 'u@example.org']),
self::source(['created_by' => 'u@example.org', 'verify_cert' => 0])
);
$this->repo->expects($this->once())->method('jobMailboxes')->willReturn(['u@example.org', 'other@example.org']);
// Turning it on needs no check and keeps the tokens
$this->repo->expects($this->once())->method('update')
->with($this->callback(fn(Source $s): bool => $s->verifyCert === 1), $this->anything(), false);
$service = $this->service(FakeContext::user('u@example.org'));
$this->expectUserError(['imapsync_source_used_by_others', 'src'], fn() => $service->update(7, ['verify_cert' => '0']));
$service->update(7, ['verify_cert' => '1']);
}
public function testUsersMayNotSwitchToXOAuth2(): void
{
$this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org']));
@@ -301,6 +301,31 @@ class GroupServiceTest extends TestCase
$this->assertTrue($this->service(self::admin())->canEdit($group));
}
public function testRemovedGrantsCountUntilDovecotDroppedThem(): void
{
$group = self::stored();
$group->removingImapOwners = ['c@other.org'];
$this->assertFalse($this->service(self::domainAdmin())->canEdit($group));
$this->assertTrue($this->service(self::admin())->canEdit($group));
}
public function testDomainAdminChangesOnlyApplyTemplatesInHisDomains(): void
{
$this->repo->method('findVisible')->willReturn(self::stored([], ['a@example.org' => 'manual', 'c@other.org' => 'manual']));
$this->repo->method('update')->willReturn([]);
$this->repo->method('delete')->willReturn([]);
$effects = $this->createMock(ChangeEffects::class);
$effects->method('apply')->willReturn([]);
// before and after lists of update() may repeat a member, only the set counts
$effects->expects($this->exactly(2))->method('snapshot')
->with($this->callback(fn(array $usernames): bool => array_values(array_unique($usernames)) === ['a@example.org']))->willReturn([]);
$service = new GroupService($this->repo, $this->lookup, $this->templates, $effects, $this->sharing, self::domainAdmin());
$service->update(7, ['active' => 0]);
$service->delete(7);
}
public function testDeleteRemovesTheImapAclsFirst(): void
{
$group = self::stored();
@@ -346,13 +371,16 @@ class GroupServiceTest extends TestCase
public function testMembershipsAreVisibleToTheMailboxAndItsManagers(): void
{
$this->lookup->method('membershipsOf')->willReturn([['id' => 7, 'name' => 'Sales']]);
$sales = ['id' => 7, 'domain' => 'example.org', 'name' => 'Sales'];
$foreign = ['id' => 8, 'domain' => 'other.org', 'name' => 'Other'];
$this->lookup->method('membershipsOf')->willReturn([$sales, $foreign]);
$this->lookup->method('sendAsOf')->willReturn(['sales@example.org']);
$this->templates->method('effective')->willReturn(['a@example.org' => 'Staff']);
$expected = ['groups' => [['id' => 7, 'name' => 'Sales']], 'template' => 'Staff', 'send_as' => ['sales@example.org']];
$expected = ['groups' => [$sales, $foreign], 'template' => 'Staff', 'send_as' => ['sales@example.org']];
$this->assertSame($expected, $this->service(FakeContext::user('a@example.org', []))->membershipsOf('A@example.org'));
$this->assertSame($expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org'));
// Groups of other domains stay hidden from a domain admin
$this->assertSame(['groups' => [$sales]] + $expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(FakeContext::user('b@example.org', []))->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(self::domainAdmin())->membershipsOf('c@other.org'));
}
@@ -0,0 +1,11 @@
<?php
// Stand-in for verify_hash() of data/web/inc/functions.inc.php, used by AppPasswordLoginTest only:
// app passwords are stored as {BLF-CRYPT} (bcrypt), the default scheme of hash_password().
if (!function_exists('verify_hash')) {
function verify_hash($hash, $password)
{
return str_starts_with($hash, '{BLF-CRYPT}') && password_verify($password, substr($hash, strlen('{BLF-CRYPT}')));
}
}
+6 -4
View File
@@ -374,10 +374,12 @@ if ! $COMPOSE_COMMAND config -q; then
fi
echo -e "\e[32mChecking for conflicting bridges...\e[0m"
MAILCOW_BRIDGE=$($COMPOSE_COMMAND config | grep -i com.docker.network.bridge.name | cut -d':' -f2)
while read NAT_ID; do
iptables -t nat -D POSTROUTING "$NAT_ID"
done < <(iptables -L -vn -t nat --line-numbers | grep "$IPV4_NETWORK" | grep -E 'MASQUERADE.*all' | grep -v "${MAILCOW_BRIDGE}" | cut -d' ' -f1)
MAILCOW_BRIDGE=$($COMPOSE_COMMAND config | grep -i com.docker.network.bridge.name | cut -d':' -f2 | tr -d "[:space:]\"'")
if [[ -n "${MAILCOW_BRIDGE}" ]]; then
while read NAT_ID; do
iptables -t nat -D POSTROUTING "$NAT_ID"
done < <(iptables -L -vn -t nat --line-numbers | grep "$IPV4_NETWORK" | grep -E 'MASQUERADE.*all' | grep -vE "[[:space:]]!?${MAILCOW_BRIDGE}[[:space:]]" | cut -d' ' -f1)
fi
DIFF_DIRECTORY=update_diffs
DIFF_FILE=${DIFF_DIRECTORY}/diff_before_update_$(date +"%Y-%m-%d-%H-%M-%S")