mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-05 07:58:20 +00:00
Merge branch 'fix/sec' into fix/rework-bugs
This commit is contained in:
22 files changed
+212
-104
No files matched your search
@@ -60,6 +60,7 @@ adapt_new_options() {
|
|||||||
"ACME_DNS_CHALLENGE"
|
"ACME_DNS_CHALLENGE"
|
||||||
"ACME_DNS_PROVIDER"
|
"ACME_DNS_PROVIDER"
|
||||||
"ACME_ACCOUNT_EMAIL"
|
"ACME_ACCOUNT_EMAIL"
|
||||||
|
"NGINX_MAX_BODY_SIZE"
|
||||||
)
|
)
|
||||||
|
|
||||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
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 '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
|
||||||
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> 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
|
echo "${option}=" >> mailcow.conf
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@ def prepare_template_vars():
|
|||||||
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
|
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
|
||||||
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
|
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
|
||||||
trusted_proxies = os.getenv("TRUSTED_PROXIES", "")
|
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 = {
|
template_vars = {
|
||||||
'IPV4_NETWORK': ipv4_network,
|
'IPV4_NETWORK': ipv4_network,
|
||||||
@@ -60,6 +66,7 @@ def prepare_template_vars():
|
|||||||
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
|
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
|
||||||
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
|
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
|
||||||
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
|
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
|
||||||
|
'NGINX_MAX_BODY_SIZE': max_body_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
ssl_dir = '/etc/ssl/mail/'
|
ssl_dir = '/etc/ssl/mail/'
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ http {
|
|||||||
default "";
|
default "";
|
||||||
}
|
}
|
||||||
|
|
||||||
# client of an internal auth request, X-Real-IP is set by /sogo-auth-verify
|
# Client address for PHP: on the internal sogo-auth listener nginx itself is the client,
|
||||||
map $server_port $sogo_auth_client_ip {
|
# the real address comes from the X-Real-IP header set by /sogo-auth-verify
|
||||||
65510 $http_x_real_ip;
|
map $sogo_auth_internal $mailcow_client_ip {
|
||||||
default "";
|
"1" $http_x_real_ip;
|
||||||
|
default $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if HTTP_REDIRECT %}
|
{% if HTTP_REDIRECT %}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ add_header Referrer-Policy strict-origin;
|
|||||||
|
|
||||||
index index.php index.html;
|
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 on;
|
||||||
gzip_disable "msie6";
|
gzip_disable "msie6";
|
||||||
@@ -116,10 +117,10 @@ location ~ \.php$ {
|
|||||||
include /etc/nginx/fastcgi_params;
|
include /etc/nginx/fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
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)
|
# trusted internal-auth marker; empty for external clients (see nginx.conf map)
|
||||||
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
|
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
|
||||||
fastcgi_param SOGO_AUTH_CLIENT_IP $sogo_auth_client_ip;
|
|
||||||
fastcgi_read_timeout 3600;
|
fastcgi_read_timeout 3600;
|
||||||
fastcgi_send_timeout 3600;
|
fastcgi_send_timeout 3600;
|
||||||
}
|
}
|
||||||
@@ -182,6 +183,7 @@ location /sogo-auth-verify {
|
|||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
proxy_pass http://127.0.0.1:65510/sogo-auth;
|
proxy_pass http://127.0.0.1:65510/sogo-auth;
|
||||||
proxy_pass_request_body off;
|
proxy_pass_request_body off;
|
||||||
|
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /Microsoft-Server-ActiveSync {
|
location ^~ /Microsoft-Server-ActiveSync {
|
||||||
@@ -205,7 +207,7 @@ location ^~ /Microsoft-Server-ActiveSync {
|
|||||||
proxy_busy_buffers_size 512k;
|
proxy_busy_buffers_size 512k;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
client_body_buffer_size 512k;
|
client_body_buffer_size 512k;
|
||||||
client_max_body_size 0;
|
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /SOGo {
|
location ^~ /SOGo {
|
||||||
@@ -256,7 +258,7 @@ location ^~ /SOGo {
|
|||||||
proxy_send_timeout 3600;
|
proxy_send_timeout 3600;
|
||||||
proxy_read_timeout 3600;
|
proxy_read_timeout 3600;
|
||||||
client_body_buffer_size 128k;
|
client_body_buffer_size 128k;
|
||||||
client_max_body_size 0;
|
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,41 +150,41 @@ rspamd_config:register_symbol({
|
|||||||
local function ipv6_to_segments(ip_str)
|
local function ipv6_to_segments(ip_str)
|
||||||
-- Remove zone identifier if present (e.g., %eth0)
|
-- Remove zone identifier if present (e.g., %eth0)
|
||||||
ip_str = ip_str:gsub("%%.*$", "")
|
ip_str = ip_str:gsub("%%.*$", "")
|
||||||
|
|
||||||
local segments = {}
|
local segments = {}
|
||||||
|
|
||||||
-- Handle :: compression
|
-- Handle :: compression
|
||||||
if ip_str:find('::') then
|
if ip_str:find('::') then
|
||||||
local before, after = ip_str:match('^(.*)::(.*)$')
|
local before, after = ip_str:match('^(.*)::(.*)$')
|
||||||
before = before or ''
|
before = before or ''
|
||||||
after = after or ''
|
after = after or ''
|
||||||
|
|
||||||
local before_parts = {}
|
local before_parts = {}
|
||||||
local after_parts = {}
|
local after_parts = {}
|
||||||
|
|
||||||
if before ~= '' then
|
if before ~= '' then
|
||||||
for seg in before:gmatch('[^:]+') do
|
for seg in before:gmatch('[^:]+') do
|
||||||
table.insert(before_parts, tonumber(seg, 16) or 0)
|
table.insert(before_parts, tonumber(seg, 16) or 0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if after ~= '' then
|
if after ~= '' then
|
||||||
for seg in after:gmatch('[^:]+') do
|
for seg in after:gmatch('[^:]+') do
|
||||||
table.insert(after_parts, tonumber(seg, 16) or 0)
|
table.insert(after_parts, tonumber(seg, 16) or 0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Add before segments
|
-- Add before segments
|
||||||
for _, seg in ipairs(before_parts) do
|
for _, seg in ipairs(before_parts) do
|
||||||
table.insert(segments, seg)
|
table.insert(segments, seg)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Add compressed zeros
|
-- Add compressed zeros
|
||||||
local zeros_needed = 8 - #before_parts - #after_parts
|
local zeros_needed = 8 - #before_parts - #after_parts
|
||||||
for i = 1, zeros_needed do
|
for i = 1, zeros_needed do
|
||||||
table.insert(segments, 0)
|
table.insert(segments, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Add after segments
|
-- Add after segments
|
||||||
for _, seg in ipairs(after_parts) do
|
for _, seg in ipairs(after_parts) do
|
||||||
table.insert(segments, seg)
|
table.insert(segments, seg)
|
||||||
@@ -195,12 +195,12 @@ rspamd_config:register_symbol({
|
|||||||
table.insert(segments, tonumber(seg, 16) or 0)
|
table.insert(segments, tonumber(seg, 16) or 0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Ensure we have exactly 8 segments
|
-- Ensure we have exactly 8 segments
|
||||||
while #segments < 8 do
|
while #segments < 8 do
|
||||||
table.insert(segments, 0)
|
table.insert(segments, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
return segments
|
return segments
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -208,46 +208,46 @@ rspamd_config:register_symbol({
|
|||||||
local function get_ipv6_variants(ip_str)
|
local function get_ipv6_variants(ip_str)
|
||||||
local variants = {}
|
local variants = {}
|
||||||
local seen = {}
|
local seen = {}
|
||||||
|
|
||||||
local function add_variant(v)
|
local function add_variant(v)
|
||||||
if v and not seen[v] then
|
if v and not seen[v] then
|
||||||
table.insert(variants, v)
|
table.insert(variants, v)
|
||||||
seen[v] = true
|
seen[v] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- For IPv4, just return the original
|
-- For IPv4, just return the original
|
||||||
if not ip_str:find(':') then
|
if not ip_str:find(':') then
|
||||||
add_variant(ip_str)
|
add_variant(ip_str)
|
||||||
return variants
|
return variants
|
||||||
end
|
end
|
||||||
|
|
||||||
local segments = ipv6_to_segments(ip_str)
|
local segments = ipv6_to_segments(ip_str)
|
||||||
|
|
||||||
-- 1. Fully expanded form (all zeros shown as 0000)
|
-- 1. Fully expanded form (all zeros shown as 0000)
|
||||||
local expanded_parts = {}
|
local expanded_parts = {}
|
||||||
for _, seg in ipairs(segments) do
|
for _, seg in ipairs(segments) do
|
||||||
table.insert(expanded_parts, string.format('%04x', seg))
|
table.insert(expanded_parts, string.format('%04x', seg))
|
||||||
end
|
end
|
||||||
add_variant(table.concat(expanded_parts, ':'))
|
add_variant(table.concat(expanded_parts, ':'))
|
||||||
|
|
||||||
-- 2. Standard form (no leading zeros, but all segments present)
|
-- 2. Standard form (no leading zeros, but all segments present)
|
||||||
local standard_parts = {}
|
local standard_parts = {}
|
||||||
for _, seg in ipairs(segments) do
|
for _, seg in ipairs(segments) do
|
||||||
table.insert(standard_parts, string.format('%x', seg))
|
table.insert(standard_parts, string.format('%x', seg))
|
||||||
end
|
end
|
||||||
add_variant(table.concat(standard_parts, ':'))
|
add_variant(table.concat(standard_parts, ':'))
|
||||||
|
|
||||||
-- 3. Find all possible :: compressions
|
-- 3. Find all possible :: compressions
|
||||||
-- RFC 5952: compress the longest run of consecutive zeros
|
-- RFC 5952: compress the longest run of consecutive zeros
|
||||||
-- But we need to check all possibilities since Redis might have any form
|
-- But we need to check all possibilities since Redis might have any form
|
||||||
|
|
||||||
-- Find all zero runs
|
-- Find all zero runs
|
||||||
local zero_runs = {}
|
local zero_runs = {}
|
||||||
local in_run = false
|
local in_run = false
|
||||||
local run_start = 0
|
local run_start = 0
|
||||||
local run_length = 0
|
local run_length = 0
|
||||||
|
|
||||||
for i = 1, 8 do
|
for i = 1, 8 do
|
||||||
if segments[i] == 0 then
|
if segments[i] == 0 then
|
||||||
if not in_run then
|
if not in_run then
|
||||||
@@ -266,21 +266,21 @@ rspamd_config:register_symbol({
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Don't forget the last run
|
-- Don't forget the last run
|
||||||
if in_run and run_length >= 1 then
|
if in_run and run_length >= 1 then
|
||||||
table.insert(zero_runs, {start = run_start, length = run_length})
|
table.insert(zero_runs, {start = run_start, length = run_length})
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Generate variant for each zero run compression
|
-- Generate variant for each zero run compression
|
||||||
for _, run in ipairs(zero_runs) do
|
for _, run in ipairs(zero_runs) do
|
||||||
local parts = {}
|
local parts = {}
|
||||||
|
|
||||||
-- Before compression
|
-- Before compression
|
||||||
for i = 1, run.start - 1 do
|
for i = 1, run.start - 1 do
|
||||||
table.insert(parts, string.format('%x', segments[i]))
|
table.insert(parts, string.format('%x', segments[i]))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The compression
|
-- The compression
|
||||||
if run.start == 1 then
|
if run.start == 1 then
|
||||||
table.insert(parts, '')
|
table.insert(parts, '')
|
||||||
@@ -291,22 +291,22 @@ rspamd_config:register_symbol({
|
|||||||
else
|
else
|
||||||
table.insert(parts, '')
|
table.insert(parts, '')
|
||||||
end
|
end
|
||||||
|
|
||||||
-- After compression
|
-- After compression
|
||||||
for i = run.start + run.length, 8 do
|
for i = run.start + run.length, 8 do
|
||||||
table.insert(parts, string.format('%x', segments[i]))
|
table.insert(parts, string.format('%x', segments[i]))
|
||||||
end
|
end
|
||||||
|
|
||||||
local compressed = table.concat(parts, ':'):gsub('::+', '::')
|
local compressed = table.concat(parts, ':'):gsub('::+', '::')
|
||||||
add_variant(compressed)
|
add_variant(compressed)
|
||||||
end
|
end
|
||||||
|
|
||||||
return variants
|
return variants
|
||||||
end
|
end
|
||||||
|
|
||||||
local from_ip_string = tostring(ip)
|
local from_ip_string = tostring(ip)
|
||||||
local ip_check_table = {}
|
local ip_check_table = {}
|
||||||
|
|
||||||
-- Add all variants of the exact IP
|
-- Add all variants of the exact IP
|
||||||
for _, variant in ipairs(get_ipv6_variants(from_ip_string)) do
|
for _, variant in ipairs(get_ipv6_variants(from_ip_string)) do
|
||||||
table.insert(ip_check_table, variant)
|
table.insert(ip_check_table, variant)
|
||||||
@@ -318,18 +318,18 @@ rspamd_config:register_symbol({
|
|||||||
maxbits = 32
|
maxbits = 32
|
||||||
minbits = 8
|
minbits = 8
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Add all CIDR notations with variants
|
-- Add all CIDR notations with variants
|
||||||
for i=maxbits,minbits,-1 do
|
for i=maxbits,minbits,-1 do
|
||||||
local masked_ip = ip:apply_mask(i)
|
local masked_ip = ip:apply_mask(i)
|
||||||
local cidr_base = masked_ip:to_string()
|
local cidr_base = masked_ip:to_string()
|
||||||
|
|
||||||
for _, variant in ipairs(get_ipv6_variants(cidr_base)) do
|
for _, variant in ipairs(get_ipv6_variants(cidr_base)) do
|
||||||
local cidr = variant .. "/" .. i
|
local cidr = variant .. "/" .. i
|
||||||
table.insert(ip_check_table, cidr)
|
table.insert(ip_check_table, cidr)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function keep_spam_cb(err, data)
|
local function keep_spam_cb(err, data)
|
||||||
if err then
|
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)
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
table.insert(ip_check_table, 1, 'KEEP_SPAM')
|
table.insert(ip_check_table, 1, 'KEEP_SPAM')
|
||||||
local redis_ret_user = rspamd_redis_make_request(task,
|
local redis_ret_user = rspamd_redis_make_request(task,
|
||||||
redis_params, -- connect params
|
redis_params, -- connect params
|
||||||
@@ -441,7 +441,7 @@ rspamd_config:register_symbol({
|
|||||||
|
|
||||||
-- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER)
|
-- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER)
|
||||||
local allow_processing = false
|
local allow_processing = false
|
||||||
|
|
||||||
if task.has_pre_result then
|
if task.has_pre_result then
|
||||||
local has_pre, pre_action = task:has_pre_result()
|
local has_pre, pre_action = task:has_pre_result()
|
||||||
if has_pre then
|
if has_pre then
|
||||||
@@ -765,13 +765,22 @@ rspamd_config:register_symbol({
|
|||||||
end
|
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({
|
rspamd_config:register_symbol({
|
||||||
name = 'MOO_FOOTER',
|
name = 'MOO_FOOTER',
|
||||||
type = 'prefilter',
|
type = 'prefilter',
|
||||||
callback = function(task)
|
callback = function(task)
|
||||||
local cjson = require "cjson"
|
local cjson = require "cjson"
|
||||||
local lua_mime = require "lua_mime"
|
local lua_mime = require "lua_mime"
|
||||||
local lua_util = require "lua_util"
|
|
||||||
local rspamd_logger = require "rspamd_logger"
|
local rspamd_logger = require "rspamd_logger"
|
||||||
local rspamd_http = require "rspamd_http"
|
local rspamd_http = require "rspamd_http"
|
||||||
local envfrom = task:get_from(1)
|
local envfrom = task:get_from(1)
|
||||||
@@ -844,10 +853,10 @@ rspamd_config:register_symbol({
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if footer.html and footer.html ~= "" then
|
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
|
end
|
||||||
if footer.plain and footer.plain ~= "" then
|
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
|
end
|
||||||
|
|
||||||
-- add footer
|
-- add footer
|
||||||
|
|||||||
@@ -216,8 +216,9 @@ foreach ($rcpts as $rcpt) {
|
|||||||
foreach ($rcpt_final_mailboxes as $rcpt_final) {
|
foreach ($rcpt_final_mailboxes as $rcpt_final) {
|
||||||
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
|
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`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
|
||||||
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes)");
|
$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(
|
$stmt->execute(array(
|
||||||
':qid' => $qid,
|
':qid' => $qid,
|
||||||
':subject' => $subject,
|
':subject' => $subject,
|
||||||
@@ -229,11 +230,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) {
|
|||||||
':ip' => $ip,
|
':ip' => $ip,
|
||||||
':msg' => $raw_data,
|
':msg' => $raw_data,
|
||||||
':action' => $action,
|
':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 (
|
$stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN (
|
||||||
SELECT `id`
|
SELECT `id`
|
||||||
FROM (
|
FROM (
|
||||||
|
|||||||
+42
-43
@@ -144,41 +144,15 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$username = trim((string)$email);
|
$username = trim((string)$email);
|
||||||
try {
|
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
|
||||||
$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)) {
|
|
||||||
try {
|
try {
|
||||||
$json = json_encode(
|
$json = json_encode(
|
||||||
array(
|
array(
|
||||||
"time" => time(),
|
"time" => time(),
|
||||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||||
"user" => $email,
|
"user" => "none",
|
||||||
"ip" => $_SERVER['REMOTE_ADDR'],
|
"ip" => $_SERVER['REMOTE_ADDR'],
|
||||||
"service" => "Error: mailbox not found or inactive"
|
"service" => "Error: invalid email address in request"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||||
@@ -202,13 +176,33 @@ if (empty($MailboxData)) {
|
|||||||
<?php
|
<?php
|
||||||
exit(0);
|
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'])) {
|
$email = $username;
|
||||||
$displayname = $MailboxData['name'];
|
$displayname = $email;
|
||||||
}
|
|
||||||
else {
|
|
||||||
$displayname = $email;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
$json = json_encode(
|
$json = json_encode(
|
||||||
array(
|
array(
|
||||||
@@ -216,11 +210,16 @@ try {
|
|||||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||||
"user" => $email,
|
"user" => $email,
|
||||||
"ip" => $_SERVER['REMOTE_ADDR'],
|
"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->lPush('AUTODISCOVER_LOG', $json);
|
||||||
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
$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) {
|
catch (RedisException $e) {
|
||||||
$_SESSION['return'][] = array(
|
$_SESSION['return'][] = array(
|
||||||
@@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
<Server><?=$autodiscover_config['imap']['server'];?></Server>
|
<Server><?=$autodiscover_config['imap']['server'];?></Server>
|
||||||
<Port><?=$autodiscover_config['imap']['port'];?></Port>
|
<Port><?=$autodiscover_config['imap']['port'];?></Port>
|
||||||
<DomainRequired>off</DomainRequired>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
<SPA>off</SPA>
|
<SPA>off</SPA>
|
||||||
<SSL>on</SSL>
|
<SSL>on</SSL>
|
||||||
<AuthRequired>on</AuthRequired>
|
<AuthRequired>on</AuthRequired>
|
||||||
@@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
<Server><?=$autodiscover_config['smtp']['server'];?></Server>
|
<Server><?=$autodiscover_config['smtp']['server'];?></Server>
|
||||||
<Port><?=$autodiscover_config['smtp']['port'];?></Port>
|
<Port><?=$autodiscover_config['smtp']['port'];?></Port>
|
||||||
<DomainRequired>off</DomainRequired>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
<SPA>off</SPA>
|
<SPA>off</SPA>
|
||||||
<SSL>on</SSL>
|
<SSL>on</SSL>
|
||||||
<AuthRequired>on</AuthRequired>
|
<AuthRequired>on</AuthRequired>
|
||||||
@@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
?>
|
?>
|
||||||
<Protocol>
|
<Protocol>
|
||||||
<Type>CalDAV</Type>
|
<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>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
</Protocol>
|
</Protocol>
|
||||||
<Protocol>
|
<Protocol>
|
||||||
<Type>CardDAV</Type>
|
<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>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
</Protocol>
|
</Protocol>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
@@ -288,7 +287,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
<Culture>en:en</Culture>
|
<Culture>en:en</Culture>
|
||||||
<User>
|
<User>
|
||||||
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
|
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
|
||||||
<EMailAddress><?=$email;?></EMailAddress>
|
<EMailAddress><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></EMailAddress>
|
||||||
</User>
|
</User>
|
||||||
<Action>
|
<Action>
|
||||||
<Settings>
|
<Settings>
|
||||||
|
|||||||
+2
-1
@@ -227,7 +227,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
if (isset($_GET['syncjob']) &&
|
if (isset($_GET['syncjob']) &&
|
||||||
is_numeric($_GET['syncjob'])) {
|
is_numeric($_GET['syncjob'])) {
|
||||||
$id = $_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';
|
$template = 'edit/syncjob.twig';
|
||||||
}
|
}
|
||||||
elseif (isset($_GET['syncjob_source']) &&
|
elseif (isset($_GET['syncjob_source']) &&
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ function admin($_action, $_data = null) {
|
|||||||
$usernames = $_data['username'];
|
$usernames = $_data['username'];
|
||||||
}
|
}
|
||||||
foreach ($usernames as $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);
|
$is_now = admin('details', $username);
|
||||||
if (!empty($is_now)) {
|
if (!empty($is_now)) {
|
||||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) {
|
|||||||
$_data_log = $_data;
|
$_data_log = $_data;
|
||||||
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
|
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
|
||||||
!isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*';
|
!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 (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
|
||||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
|
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
|
||||||
$_SESSION['return'][] = array(
|
$_SESSION['return'][] = array(
|
||||||
|
|||||||
@@ -498,10 +498,12 @@ function apppass_login($user, $pass, $extra = null, &$denied = false){
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$key = strtolower($extra['service']) . '_access';
|
||||||
$mailbox_attributes = json_decode($row['mailbox_attributes'], true);
|
$mailbox_attributes = json_decode($row['mailbox_attributes'], true);
|
||||||
|
// app passwords never grant a protocol the mailbox itself is denied
|
||||||
if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1'
|
if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1'
|
||||||
|| ($mailbox_attributes['shared'] ?? '0') == '1'
|
|| ($mailbox_attributes['shared'] ?? '0') == '1'
|
||||||
|| ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1')) {
|
|| ($extra['service'] != 'NONE' && ($row[$key] != '1' || (isset($mailbox_attributes[$key]) && $mailbox_attributes[$key] != '1')))) {
|
||||||
// right password, but the app password does not allow this login
|
// right password, but the app password does not allow this login
|
||||||
$denied = true;
|
$denied = true;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1857,6 +1857,40 @@ function get_tfa($username = null, $id = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function verify_tfa_login($username, $_data) {
|
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 $pdo;
|
||||||
global $yubi;
|
global $yubi;
|
||||||
global $u2f;
|
global $u2f;
|
||||||
|
|||||||
@@ -767,8 +767,9 @@ function quarantine($_action, $_data = null) {
|
|||||||
else {
|
else {
|
||||||
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
|
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
|
||||||
foreach ($domains as $domain) {
|
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');
|
// Compare the domain part exactly, a regex would treat dots in the domain as wildcards
|
||||||
$stmt->execute(array(':domain' => '@' . $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 SUBSTRING_INDEX(`rcpt`, "@", -1) = :domain');
|
||||||
|
$stmt->execute(array(':domain' => $domain));
|
||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
while($row = array_shift($rows)) {
|
while($row = array_shift($rows)) {
|
||||||
$q_meta[] = $row;
|
$q_meta[] = $row;
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID";
|
|||||||
// Label for OTP devices
|
// Label for OTP devices
|
||||||
$OTP_LABEL = "mailcow UI";
|
$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
|
// How long to wait (in s) for cURL Docker requests
|
||||||
$DOCKER_TIMEOUT = 60;
|
$DOCKER_TIMEOUT = 60;
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -105,9 +105,28 @@ if (isset($_GET['query'])) {
|
|||||||
'msg' => 'Cannot find attributes in post data'
|
'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) {
|
switch ($action) {
|
||||||
case "add":
|
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);
|
http_response_code(403);
|
||||||
echo json_encode(array(
|
echo json_encode(array(
|
||||||
'type' => 'error',
|
'type' => 'error',
|
||||||
|
|||||||
@@ -641,6 +641,7 @@
|
|||||||
"temp_error": "Temporärer Fehler",
|
"temp_error": "Temporärer Fehler",
|
||||||
"text_empty": "Text darf nicht leer sein",
|
"text_empty": "Text darf nicht leer sein",
|
||||||
"tfa_token_invalid": "TFA-Token ungültig",
|
"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.",
|
"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_dest_invalid": "Ziel ist ungültig",
|
||||||
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
|
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
|
||||||
|
|||||||
@@ -517,6 +517,7 @@
|
|||||||
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
|
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
|
||||||
"iam_sync_mode_invalid": "Invalid sync mode",
|
"iam_sync_mode_invalid": "Invalid sync mode",
|
||||||
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
|
"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.",
|
"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_domain_invalid": "Alias domain %s is invalid",
|
||||||
"alias_empty": "Alias address must not be empty",
|
"alias_empty": "Alias address must not be empty",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ if (isset($_GET['only_email'])) {
|
|||||||
$onlyEmailAccount = false;
|
$onlyEmailAccount = false;
|
||||||
$description = 'IMAP, CalDAV, CardDAV';
|
$description = 'IMAP, CalDAV, CardDAV';
|
||||||
}
|
}
|
||||||
if (isset($_GET['app_password'])) {
|
if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') {
|
||||||
$app_password = true;
|
$app_password = true;
|
||||||
$description .= ' with application password';
|
$description .= ' with application password';
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ $session_var_pass = 'sogo-sso-pass';
|
|||||||
// external clients can never supply it (bare fastcgi param, not an HTTP header)
|
// external clients can never supply it (bare fastcgi param, not an HTTP header)
|
||||||
$is_internal_auth = (($_SERVER['SOGO_AUTH_INTERNAL'] ?? '') === '1');
|
$is_internal_auth = (($_SERVER['SOGO_AUTH_INTERNAL'] ?? '') === '1');
|
||||||
|
|
||||||
// the loopback is no client: use the client of the original request for logs, netfilter and API IP checks
|
|
||||||
if ($is_internal_auth && filter_var($_SERVER['SOGO_AUTH_CLIENT_IP'] ?? '', FILTER_VALIDATE_IP)) {
|
|
||||||
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'] = $_SERVER['SOGO_AUTH_CLIENT_IP'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate credentials for basic auth requests
|
// validate credentials for basic auth requests
|
||||||
if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) {
|
if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) {
|
||||||
// load prerequisites only when required
|
// load prerequisites only when required
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if acl.app_passwds == 1 %}
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="col-12 col-md-3 d-flex">
|
<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>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 d-flex flex-column text-center align-items-center mt-4">
|
<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>
|
<a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a>
|
||||||
|
|||||||
+2
-1
@@ -428,7 +428,7 @@ services:
|
|||||||
- php-fpm-mailcow
|
- php-fpm-mailcow
|
||||||
- sogo-mailcow
|
- sogo-mailcow
|
||||||
- rspamd-mailcow
|
- rspamd-mailcow
|
||||||
image: ghcr.io/mailcow/nginx:1.30.6-1
|
image: ghcr.io/mailcow/nginx:1.30.6-2
|
||||||
dns:
|
dns:
|
||||||
- ${IPV4_NETWORK:-172.22.1}.254
|
- ${IPV4_NETWORK:-172.22.1}.254
|
||||||
environment:
|
environment:
|
||||||
@@ -448,6 +448,7 @@ services:
|
|||||||
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
|
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
|
||||||
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
|
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
|
||||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
||||||
|
- NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE:-128m}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/web:/web:ro,z
|
- ./data/web:/web:ro,z
|
||||||
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
|
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
|
||||||
|
|||||||
@@ -215,6 +215,10 @@ HTTPS_BIND=
|
|||||||
# Redirect HTTP connections to HTTPS - y/n
|
# Redirect HTTP connections to HTTPS - y/n
|
||||||
HTTP_REDIRECT=y
|
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
|
# Other bindings
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user