Compare commits

...

37 Commits

Author SHA1 Message Date
FreddleSpl0it 489db90512 Merge pull request #7391 from mailcow/staging
Update 2026-07a
2026-07-30 11:17:15 +02:00
FreddleSpl0it 50be483c39 Merge pull request #7390 from mailcow/fix/6859
[ACME] Skip mta-sts certificate request when MTA-STS is not active for a domain
2026-07-30 10:44:24 +02:00
FreddleSpl0it d64c923aca [ACME] Skip mta-sts certificate request when MTA-STS is not active for a domain 2026-07-30 10:33:55 +02:00
FreddleSpl0it 2d5f166ba8 Merge pull request #7389 from mailcow/fix/default-mbox-template
[Web] Create default mailbox template with eas and dav access
2026-07-30 09:23:24 +02:00
FreddleSpl0it 94b5a623ab Merge pull request #7388 from mailcow/fix/7329
[Web] Move mailcow update check to server side
2026-07-30 09:13:19 +02:00
FreddleSpl0it e406ecd461 Merge pull request #7387 from mailcow/fix/mfk25
Hardening mailcow
2026-07-30 09:07:10 +02:00
FreddleSpl0it 68a95c3af3 Merge pull request #7386 from mailcow/feat/rspamd-4.1.4
[Rspamd] update to 4.1.4
2026-07-30 08:59:04 +02:00
FreddleSpl0it bd037d5644 [Nginx] Set image tag to 1.30.4-1 2026-07-30 08:51:21 +02:00
FreddleSpl0it 7036dbf4f8 [Rspamd] update to 4.1.4 2026-07-30 08:50:18 +02:00
FreddleSpl0it 2f10a4633f Merge pull request #7385 from mailcow/fix/cors
[Web] harden CORS origin matching and add Vary: Origin
2026-07-30 08:41:01 +02:00
FreddleSpl0it 36c70db86c [Web] harden CORS origin matching and add Vary: Origin 2026-07-30 08:37:18 +02:00
FreddleSpl0it f4961c4023 Merge pull request #7333 from fallmo/fix/cors-settings-validation
fix: cors allowed origins settings validation
2026-07-28 15:35:29 +02:00
FreddleSpl0it 38de21592c Merge pull request #7358 from SYNLINQ/staging
Fix nginx CVE-2026-42533
2026-07-28 14:40:30 +02:00
FreddleSpl0it f44bd2f36a [Web] document sender_acl in get/mailbox API examples 2026-07-28 14:19:45 +02:00
FreddleSpl0it 95a77f2dcb Merge pull request #7348 from smpaz7467/fix/api-get-mailbox-sender-acl
[Web] return sender_acl in get/mailbox API
2026-07-28 14:12:57 +02:00
FreddleSpl0it e856510fb2 Merge pull request #7345 from smpaz7467/fix/time-limited-alias-api
[Web] fix add/time_limited_alias silently discarding requests and validity
2026-07-28 13:49:37 +02:00
FreddleSpl0it d51d06d716 Merge pull request #7343 from smpaz7467/fix/nginx-ipv6-default-server
[Nginx] only bind IPv6 default_server when ENABLE_IPV6 is set
2026-07-28 13:35:17 +02:00
FreddleSpl0it 54170d075a [Web] Move mailcow update check to server side 2026-07-28 13:32:18 +02:00
renovate[bot] b4bb1a625b Update actions/stale action to v11 (#7375) 2026-07-28 11:07:20 +02:00
FreddleSpl0it 2e5a29bf69 Merge pull request #7367 from oidipos/fix/quarantine-subject
fix: restore subject display in quarantine overview
2026-07-28 10:54:23 +02:00
FreddleSpl0it e245ac04d9 [Web] enforce tenant boundary for SOGo SSO 2026-07-28 09:21:29 +02:00
oidipos c877fdf0a5 fix: remove MIME decoding of JSON encoded subject
Since moving to rspamd's multipart metadata_exporter,
`subject` is a JSON encoded UTF-8 string and must not be MIME decoded.
2026-07-24 21:03:00 +02:00
FreddleSpl0it fea38c8e1b [Web] escape mailbox name 2026-07-24 11:16:39 +02:00
FreddleSpl0it cc9af65852 [Web] remove domain admin sso token after use 2026-07-24 10:26:23 +02:00
FreddleSpl0it 92cc8bec90 [Web] Use parameterized LIKE for sogo_acl deletion 2026-07-23 15:29:41 +02:00
FreddleSpl0it 145745329e [Web] add dot stuffing for quarantine raw release 2026-07-23 15:01:44 +02:00
FreddleSpl0it 8e72d22c56 [Web] Escape rspamd_history and rllog 2026-07-23 11:17:55 +02:00
FreddleSpl0it ddd76d99cd [Web] Add sogo_auth_internal nginx marker for sogo-auth.php 2026-07-23 10:10:29 +02:00
SYNLINQ 8c9524a2fe Update docker-compose.yml
reference patched nginx image
2026-07-20 21:32:57 +02:00
SYNLINQ 2ebd32d2ee Update Dockerfile
bump nginx version to 1.30.4
2026-07-20 21:31:57 +02:00
FreddleSpl0it 641ed63782 Merge pull request #7353 from mailcow/staging
update README.md sponsors
2026-07-15 08:31:16 +02:00
FreddleSpl0it 4b62af9d02 update README.md sponsors 2026-07-15 08:29:46 +02:00
Stephen Ritz 14772c3a20 [Web] return sender_acl in get/mailbox API
sender_acl can be set through edit/mailbox but was never returned by
get/mailbox, so an API client could not read back what it had written,
and get/mailbox/all / get/mailbox/{mailbox} both omitted it.

Add the mailbox's internal send-as ACL (the sender_acl table rows with
external = 0) to mailbox_details as sender_acl, an array of send_as
values, mirroring the field edit/mailbox accepts. It is added in the same
block as the other detailed fields, so the lightweight get/mailbox/reduced
endpoint is unaffected.

Fixes #7011

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:36:40 -07:00
Stephen Ritz c17cc8a792 [Web] fix add/time_limited_alias silently discarding requests and validity
Three defects in add/time_limited_alias:

The description was read as $_data['description'] without a guard. When a
client omits it, null is bound to spamalias.description, which is TEXT NOT
NULL, so the insert raises a PDOException. The global exception handler is
terminal, so process_add_return() never echoes anything and the caller sees
HTTP 200 with an empty body while no alias was created. Default it to an
empty string instead.

The validity guard used a single condition whose else branch also caught the
success case, so every valid validity was overwritten with the 8760 hour
default and the parameter did nothing. Only invalid values were rejected.
Nest the range check so a valid value survives.

The OpenAPI spec documented only username and domain, while the code also
reads description, validity and permanent. Spec driven clients therefore
could not construct a working request. Document all three.

spamalias.description is the only NOT NULL description column in the schema,
which is why the same unguarded read in add/domain and add/resource does not
fail, both of those columns are nullable.

This does not change the generic exception handling. A database error is
still swallowed into an empty HTTP 200, and the message the handler builds
carries the raw PDOException, so surfacing it to API clients would need
sanitising first. That is left for a separate change.

Refs #7287

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:23:31 -07:00
Stephen Ritz 7dab6c63d1 [Nginx] only bind IPv6 default_server when ENABLE_IPV6 is set
The HTTP-to-HTTPS redirect server block bound `listen [::]:{{ HTTP_PORT }}
default_server` unconditionally, while every other IPv6 listen directive in
this template is guarded by `{% if ENABLE_IPV6 %}`. On hosts where IPv6 is
disabled at the kernel level, nginx cannot bind `::` and fails to start.

This only triggers when HTTP_REDIRECT is enabled and ENABLE_IPV6 is false,
which is why it survives testing with ENABLE_IPV6=false alone.

Guard the directive like the other six IPv6 listeners in the template.

Refs #7296

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:54:28 -07:00
Mohamed Fall 6f7fee49cd fix: cors allowed origins settings validation 2026-07-13 15:29:48 +00:00
FreddleSpl0it 8ead057ce9 [Web] Create default mailbox template with eas and dav access 2026-04-15 09:33:23 +02:00
23 changed files with 283 additions and 80 deletions
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Mark/Close Stale Issues and Pull Requests 🗑️
uses: actions/stale@v10.4.0
uses: actions/stale@v11.0.0
with:
repo-token: ${{ secrets.STALE_ACTION_PAT }}
days-before-stale: 60
+3
View File
@@ -23,6 +23,9 @@ A big thank you to everyone supporting us on GitHub Sponsors—your contribution
<a href="https://www.maehdros.com/" target=_blank><img
src="https://avatars.githubusercontent.com/u/173894712" height="58"
/></a>
<a href="https://garske-systems.de/" target=_blank><img
src="https://garske-systems.de/img/logo_min.png" height="58"
/></a>
### 50$/Month Sponsors
<a href="https://github.com/vnukhr" target=_blank><img
+21
View File
@@ -249,12 +249,33 @@ while true; do
done <<< "${SQL_DOMAINS}"
if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then
# Fetch all domains with an active MTA-STS policy once.
unset MTA_STS_ACTIVE_DOMAINS
declare -A MTA_STS_ACTIVE_DOMAINS
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
SQL_MTA_STS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM mta_sts WHERE active = 1" -Bs)
if [[ $? -eq 0 ]]; then
while read mta_sts_domain; do
if [[ -z "${mta_sts_domain}" ]]; then
# ignore empty lines
continue
fi
MTA_STS_ACTIVE_DOMAINS["${mta_sts_domain}"]=1
done <<< "${SQL_MTA_STS_DOMAINS}"
fi
fi
for SQL_DOMAIN in "${SQL_DOMAIN_ARR[@]}"; do
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
# Skip mta-sts subdomain unless MTA-STS is enabled (active) for this domain
if [[ "${SUBDOMAIN}" == "mta-sts" && -z "${MTA_STS_ACTIVE_DOMAINS[${SQL_DOMAIN}]}" ]]; then
log_f "MTA-STS is not enabled for ${SQL_DOMAIN} - skipping mta-sts subdomain certificate"
continue
fi
# Skip if subdomain matches MAILCOW_HOSTNAME
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
continue
+1 -1
View File
@@ -1,4 +1,4 @@
FROM nginx:1.30.3-alpine
FROM nginx:1.30.4-alpine
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
ENV PIP_BREAK_SYSTEM_PACKAGES=1
+1 -1
View File
@@ -2,7 +2,7 @@ FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ARG RSPAMD_VER=rspamd_4.1.0-1~e2b0b18
ARG RSPAMD_VER=rspamd_4.1.4-1~beb659b
ARG CODENAME=trixie
ENV LC_ALL=C
+7
View File
@@ -42,12 +42,19 @@ http {
https https;
}
map $server_port $sogo_auth_internal {
65510 "1";
default "";
}
{% if HTTP_REDIRECT %}
# HTTP to HTTPS redirect
server {
root /web;
listen {{ HTTP_PORT }} default_server;
{% if ENABLE_IPV6 %}
listen [::]:{{ HTTP_PORT }} default_server;
{%endif%}
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
@@ -116,6 +116,8 @@ location ~ \.php$ {
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
# trusted internal-auth marker; empty for external clients (see nginx.conf map)
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600;
}
+1 -1
View File
@@ -53,7 +53,7 @@ $raw_data = mb_convert_encoding($raw_data_content, 'HTML-ENTITIES', "UTF-8");
$raw_size = (int)$_FILES['message']['size'];
$qid = $meta['qid'] ?? 'unknown';
$subject = iconv_mime_decode($meta['subject'] ?? '');
$subject = $meta['subject'] ?? '';
$score = $meta['score'] ?? 0;
$rcpts = $meta['rcpt'] ?? array();
$user = $meta['user'] ?? 'unknown';
+1 -1
View File
@@ -50,7 +50,7 @@ $qid = $meta['qid'] ?? 'unknown';
$rcpts = $meta['rcpt'] ?? array();
$sender = $meta['from'] ?? '';
$ip = $meta['ip'] ?? 'unknown';
$subject = iconv_mime_decode($meta['subject'] ?? '');
$subject = $meta['subject'] ?? '';
$messageid= $meta['message_id'] ?? '';
$priority = 0;
+16 -2
View File
@@ -185,6 +185,9 @@ paths:
example:
username: info@domain.tld
domain: domain.tld
description: my time limited alias
validity: 8760
permanent: false
properties:
username:
description: 'the mailbox an alias should be created for'
@@ -192,6 +195,15 @@ paths:
domain:
description: "the domain"
type: string
description:
description: "a description for the alias, defaults to an empty string"
type: string
validity:
description: "how many hours the alias stays valid, 1 to 87600, defaults to 8760 (one year)"
type: integer
permanent:
description: "keep the alias after it expired, defaults to false"
type: boolean
type: object
summary: Create time limited alias
/api/v1/add/app-passwd:
@@ -4908,6 +4920,7 @@ paths:
quota: 3221225472
quota_used: 0
rl: false
sender_acl: ["otherbox@doman3.tld", "@aliasdomain.tld"]
spam_aliases: 0
username: info@doman3.tld
tags: ["tag1", "tag2"]
@@ -5834,6 +5847,7 @@ paths:
quota: 3221225472
quota_used: 0
rl: false
sender_acl: ["otherbox@domain3.tld", "@aliasdomain.tld"]
spam_aliases: 0
username: info@domain3.tld
tags: ["tag1", "tag2"]
@@ -5856,7 +5870,7 @@ paths:
response:
value:
- type: "success"
log: ["cors", "edit", {"allowed_origins": ["*", "mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}]
log: ["cors", "edit", {"allowed_origins": ["*", "https://mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}]
msg: "cors_headers_edited"
description: OK
headers: { }
@@ -5873,7 +5887,7 @@ paths:
schema:
example:
attr:
allowed_origins: ["*", "mail.mailcow.tld"]
allowed_origins: ["*", "https://mail.mailcow.tld"]
allowed_methods: ["POST", "GET", "DELETE", "PUT"]
properties:
attr:
+67
View File
@@ -0,0 +1,67 @@
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
header('Content-Type: application/json');
// Admins only
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] !== 'admin') {
http_response_code(403);
echo json_encode(array('status' => 'error', 'message' => 'access denied'));
exit;
}
$owner = $GLOBALS['MAILCOW_GIT_OWNER'];
$repo = $GLOBALS['MAILCOW_GIT_REPO'];
$current = $GLOBALS['MAILCOW_GIT_VERSION'];
// Cache key is bound to the running version, so an update invalidates it naturally
$cache_key = 'MAILCOW_UPDATE_CHECK/' . $current;
// Serve cached result if present (one GitHub call per TTL, not one per page load)
$cached = $redis->get($cache_key);
if ($cached !== false) {
echo $cached;
exit;
}
// GitHub requires a User-Agent and rate-limits unauthenticated requests to 60/h per IP
function github_get($url) {
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => 'mailcow-update-check',
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array('Accept: application/vnd.github+json'),
));
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code !== 200) return false;
$json = json_decode($body, true);
return is_array($json) ? $json : false;
}
$latest = github_get("https://api.github.com/repos/{$owner}/{$repo}/releases/latest");
$release = github_get("https://api.github.com/repos/{$owner}/{$repo}/releases/tags/{$current}");
// Any failure (rate limit, offline, unexpected payload) -> honest error, cached briefly
if ($latest === false || $release === false ||
empty($latest['tag_name']) || empty($latest['created_at']) || empty($release['created_at'])) {
$result = json_encode(array('status' => 'error', 'message' => 'could not reach GitHub API'));
$redis->setex($cache_key, 300, $result);
echo $result;
exit;
}
$date_latest = strtotime($latest['created_at']);
$date_current = strtotime($release['created_at']);
if ($date_latest <= $date_current) {
$result = json_encode(array('status' => 'no_update'));
} else {
$result = json_encode(array('status' => 'update_available', 'latest_tag' => $latest['tag_name']));
}
// Cache the positive result for an hour
$redis->setex($cache_key, 3600, $result);
echo $result;
+14 -3
View File
@@ -429,14 +429,25 @@ function domain_admin_sso($_action, $_data) {
switch ($_action) {
case 'check':
$token = $_data;
$token = preg_replace('/[^a-zA-Z0-9-]/', '', $_data);
$stmt = $pdo->prepare("SELECT `t1`.`username` FROM `da_sso` AS `t1` JOIN `admin` AS `t2` ON `t1`.`username` = `t2`.`username` WHERE `t1`.`token` = :token AND `t1`.`created` > DATE_SUB(NOW(), INTERVAL '30' SECOND) AND `t2`.`active` = 1 AND `t2`.`superadmin` = 0;");
$stmt->execute(array(
':token' => preg_replace('/[^a-zA-Z0-9-]/', '', $token)
':token' => $token
));
$return = $stmt->fetch(PDO::FETCH_ASSOC);
return empty($return['username']) ? false : $return['username'];
if (empty($return['username'])) {
return false;
}
// single-use: consume the token; if a concurrent request already used it, deny
$del = $pdo->prepare("DELETE FROM `da_sso` WHERE `token` = :token");
$del->execute(array(
':token' => $token
));
if ($del->rowCount() < 1) {
return false;
}
return $return['username'];
case 'issue':
if ($_SESSION['mailcow_cc_role'] != "admin") {
$_SESSION['return'][] = array(
+60 -12
View File
@@ -53,6 +53,39 @@ function valid_network($network) {
function valid_hostname($hostname) {
return filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
}
// Validates a browser-style Origin: scheme://host[:port], no path/query/fragment/credentials
function valid_origin($origin) {
$parts = parse_url($origin);
if ($parts === false || !isset($parts['scheme']) || !isset($parts['host'])) {
return false;
}
if (!in_array(strtolower($parts['scheme']), array('http', 'https'), true)) {
return false;
}
if (isset($parts['user']) || isset($parts['pass']) || isset($parts['path']) || isset($parts['query']) || isset($parts['fragment'])) {
return false;
}
$host = $parts['host'];
$host_without_brackets = trim($host, '[]');
if (!valid_hostname($host_without_brackets) && !filter_var($host_without_brackets, FILTER_VALIDATE_IP)) {
return false;
}
// Reject anything that doesn't round-trip to the exact same origin (e.g. stray trailing slash)
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
$rebuilt = strtolower($parts['scheme']) . '://' . strtolower($host) . $port;
return strtolower($origin) === $rebuilt;
}
// Bring an origin into the exact form a browser sends it (scheme://host[:port], lowercase).
function normalize_cors_origin($origin) {
$origin = trim($origin);
if ($origin === '*') {
return '*';
}
if ($origin !== '' && !preg_match('~^[a-z][a-z0-9+.-]*://~i', $origin)) {
$origin = 'https://' . $origin;
}
return valid_origin($origin) ? strtolower($origin) : false;
}
// Thanks to https://stackoverflow.com/a/49373789
// Validates exact ip matches and ip-in-cidr, ipv4 and ipv6
function ip_acl($ip, $networks) {
@@ -2283,10 +2316,13 @@ function cors($action, $data = null) {
return false;
}
$allowed_origins = isset($data['allowed_origins']) ? $data['allowed_origins'] : array($_SERVER['SERVER_NAME']);
$allowed_origins = isset($data['allowed_origins']) ? $data['allowed_origins'] : array(getBaseURL());
$allowed_origins = !is_array($allowed_origins) ? array_filter(array_map('trim', explode("\n", $allowed_origins))) : $allowed_origins;
foreach ($allowed_origins as $origin) {
if (!filter_var($origin, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) && $origin != '*') {
foreach ($allowed_origins as &$origin) {
if ($origin === '*') {
continue;
}
if (!valid_origin($origin)) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $action, $data),
@@ -2294,7 +2330,10 @@ function cors($action, $data = null) {
);
return false;
}
// browsers always send a lowercase scheme/host in the Origin header, so normalize to match
$origin = strtolower($origin);
}
unset($origin);
$allowed_methods = isset($data['allowed_methods']) ? $data['allowed_methods'] : array('GET', 'POST', 'PUT', 'DELETE');
$allowed_methods = !is_array($allowed_methods) ? array_map('trim', preg_split( "/( |,|;|\n)/", $allowed_methods)) : $allowed_methods;
@@ -2342,26 +2381,35 @@ function cors($action, $data = null) {
);
}
$cors_settings = !$cors_settings ? array('allowed_origins' => $_SERVER['SERVER_NAME'], 'allowed_methods' => 'GET, POST, PUT, DELETE') : $cors_settings;
$cors_settings['allowed_origins'] = empty($cors_settings['allowed_origins']) ? $_SERVER['SERVER_NAME'] : $cors_settings['allowed_origins'];
$cors_settings = !$cors_settings ? array('allowed_origins' => getBaseURL(), 'allowed_methods' => 'GET, POST, PUT, DELETE') : $cors_settings;
$cors_settings['allowed_origins'] = empty($cors_settings['allowed_origins']) ? getBaseURL() : $cors_settings['allowed_origins'];
$cors_settings['allowed_methods'] = empty($cors_settings['allowed_methods']) ? 'GET, POST, PUT, DELETE, OPTION' : $cors_settings['allowed_methods'];
return $cors_settings;
break;
case "set_headers":
$cors_settings = cors('get');
// normalize the stored list; it may still hold bare hostnames written before origins were validated as origins
$allowed_origins = array_filter(array_map('normalize_cors_origin', explode(',', $cors_settings['allowed_origins'])));
$origin = isset($_SERVER['HTTP_ORIGIN']) ? normalize_cors_origin($_SERVER['HTTP_ORIGIN']) : false;
// check if requested origin is in allowed origins
$allowed_origins = explode(', ', $cors_settings['allowed_origins']);
$cors_settings['allowed_origins'] = $allowed_origins[0];
if (in_array('*', $allowed_origins)){
$cors_settings['allowed_origins'] = '*';
} else if (array_key_exists('HTTP_ORIGIN', $_SERVER) && in_array($_SERVER['HTTP_ORIGIN'], $allowed_origins)) {
$cors_settings['allowed_origins'] = $_SERVER['HTTP_ORIGIN'];
$allow_origin = null;
if (in_array('*', $allowed_origins, true)) {
$allow_origin = '*';
} else if ($origin !== false && in_array($origin, $allowed_origins, true)) {
$allow_origin = $origin;
}
// always allow OPTIONS for preflight request
$cors_settings["allowed_methods"] = empty($cors_settings["allowed_methods"]) ? 'OPTIONS' : $cors_settings["allowed_methods"] . ', ' . 'OPTIONS';
header('Access-Control-Allow-Origin: ' . $cors_settings['allowed_origins']);
// a disallowed origin gets no Access-Control-Allow-Origin at all; echoing a different origin than the requesting one tells a browser nothing
if ($allow_origin !== null) {
header('Access-Control-Allow-Origin: ' . $allow_origin);
}
if ($allow_origin !== '*') {
// the response depends on the request origin, keep caches from mixing them up
header('Vary: Origin', false);
}
header('Access-Control-Allow-Methods: '. $cors_settings['allowed_methods']);
header('Access-Control-Allow-Headers: Accept, Content-Type, X-Api-Key, Origin');
+31 -11
View File
@@ -41,13 +41,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
else {
$username = $_SESSION['mailcow_cc_username'];
}
if (isset($_data["validity"]) && !filter_var($_data["validity"], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 87600)))) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'validity_missing'
);
return false;
if (isset($_data["validity"])) {
if (!filter_var($_data["validity"], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 87600)))) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'validity_missing'
);
return false;
}
}
else {
// Default to 1 yr
@@ -60,7 +62,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$permanent = 0;
}
$domain = $_data['domain'];
$description = $_data['description'];
// spamalias.description is NOT NULL, an absent description must not become a null insert
$description = $_data['description'] ?? '';
$valid_domains[] = mailbox('get', 'mailbox_details', $username)['domain'];
$valid_alias_domains = user_get_alias_details($username)['alias_domains'];
if (!empty($valid_alias_domains)) {
@@ -5320,6 +5323,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$mailboxdata['rl_scope'] = 'domain';
}
$mailboxdata['is_relayed'] = $row['backupmx'];
// Internal send-as ACL for this mailbox, mirrors the sender_acl field accepted by edit/mailbox
$mailboxdata['sender_acl'] = array();
$stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `external` = '0'");
$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'];
}
}
$stmt = $pdo->prepare("SELECT `tag_name`
FROM `tags_mailbox` WHERE `username`= :username");
@@ -5647,8 +5658,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt->execute(array(
':username' => $username
));
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . $username . "/%' OR `c_uid` = :username");
// bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match)
$c_object_like = '%/' . addcslashes($username, '\\%_') . '/%';
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username");
$stmt->execute(array(
':c_object_like' => $c_object_like,
':username' => $username
));
$stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)");
@@ -6044,8 +6058,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt->execute(array(
':username' => $username
));
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . str_replace('%', '\%', $username) . "/%' OR `c_uid` = :username");
// bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match)
$c_object_like = '%/' . addcslashes($username, '\\%_') . '/%';
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username");
$stmt->execute(array(
':c_object_like' => $c_object_like,
':username' => $username
));
$stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)");
@@ -6201,8 +6218,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt->execute(array(
':username' => $name
));
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . $name . "/%' OR `c_uid` = :username");
// bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match)
$c_object_like = '%/' . addcslashes($name, '\\%_') . '/%';
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username");
$stmt->execute(array(
':c_object_like' => $c_object_like,
':username' => $name
));
$stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)");
+12 -8
View File
@@ -22,7 +22,7 @@ function quarantine($_action, $_data = null) {
return false;
}
$stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt`
WHERE `qhash` = :hash
WHERE `qhash` = :hash
AND user_acl.quarantine = 1
AND rcpt IN (SELECT username FROM mailbox)');
$stmt->execute(array(':hash' => $hash));
@@ -65,7 +65,7 @@ function quarantine($_action, $_data = null) {
return false;
}
$stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt`
WHERE `qhash` = :hash
WHERE `qhash` = :hash
AND `user_acl`.`quarantine` = 1
AND `username` IN (SELECT `username` FROM `mailbox`)');
$stmt->execute(array(':hash' => $hash));
@@ -170,6 +170,8 @@ function quarantine($_action, $_data = null) {
}
elseif ($release_format == 'raw') {
$detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
// dot-stuffing per RFC5321 4.5.2: escape leading dots
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
$postfix_talk = array(
array('220', 'HELO quarantine' . chr(10)),
array('250', 'MAIL FROM: ' . $sender . chr(10)),
@@ -180,7 +182,7 @@ function quarantine($_action, $_data = null) {
array('221', '')
);
// Thanks to https://stackoverflow.com/questions/6632399/given-an-email-as-raw-text-how-can-i-send-it-using-php
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
if (!$smtp_connection) {
logger(array('return' => array(
array(
@@ -192,7 +194,7 @@ function quarantine($_action, $_data = null) {
return false;
}
for ($i=0; $i < count($postfix_talk); $i++) {
$smtp_resource = fgets($smtp_connection, 256);
$smtp_resource = fgets($smtp_connection, 256);
if (substr($smtp_resource, 0, 3) !== $postfix_talk[$i][0]) {
$ret = substr($smtp_resource, 0, 3);
$ret = (empty($ret)) ? '-' : $ret;
@@ -465,17 +467,19 @@ function quarantine($_action, $_data = null) {
}
elseif ($release_format == 'raw') {
$row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
// dot-stuffing per RFC5321 4.5.2: escape leading dots
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
$postfix_talk = array(
array('220', 'HELO quarantine' . chr(10)),
array('250', 'MAIL FROM: ' . $sender . chr(10)),
array('250', 'RCPT TO: ' . $row['rcpt'] . chr(10)),
array('250', 'DATA' . chr(10)),
array('354', str_replace("\n.", '', $row['msg']) . chr(10) . '.' . chr(10)),
array('354', $row['msg'] . chr(10) . '.' . chr(10)),
array('250', 'QUIT' . chr(10)),
array('221', '')
);
// Thanks to https://stackoverflow.com/questions/6632399/given-an-email-as-raw-text-how-can-i-send-it-using-php
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
if (!$smtp_connection) {
$_SESSION['return'][] = array(
'type' => 'warning',
@@ -485,7 +489,7 @@ function quarantine($_action, $_data = null) {
return false;
}
for ($i=0; $i < count($postfix_talk); $i++) {
$smtp_resource = fgets($smtp_connection, 256);
$smtp_resource = fgets($smtp_connection, 256);
if (substr($smtp_resource, 0, 3) !== $postfix_talk[$i][0]) {
$ret = substr($smtp_resource, 0, 3);
$ret = (empty($ret)) ? '-' : $ret;
@@ -833,7 +837,7 @@ function quarantine($_action, $_data = null) {
)));
return false;
}
$stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash');
$stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash');
$stmt->execute(array(':hash' => $hash));
return $stmt->fetch(PDO::FETCH_ASSOC);
break;
+2
View File
@@ -1464,6 +1464,8 @@ function init_db_schema()
"pop3_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['pop3_access']),
"smtp_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['smtp_access']),
"sieve_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sieve_access']),
"eas_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['eas_access']),
"dav_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['dav_access']),
"acl_spam_alias" => 1,
"acl_tls_policy" => 1,
"acl_spam_score" => 1,
+25 -33
View File
@@ -30,7 +30,7 @@ $(document).ready(function() {
// create host cpu and mem charts
createHostCpuAndMemChart();
// check for new version
if (mailcow_info.branch === "master"){
if (mailcow_info.branch === "master" && mailcow_cc_role === "admin"){
check_update(mailcow_info.version_tag, mailcow_info.project_url);
}
$("#mailcow_version").click(function(){
@@ -1089,6 +1089,8 @@ jQuery(function($){
return str;
}).join('<br>\n');
item.subject = escapeHtml(item.subject);
if (item.sender_mime != null) item.sender_mime = escapeHtml(item.sender_mime);
if (item['message-id'] != null) item['message-id'] = escapeHtml(item['message-id']);
var scan_time = item.time_real.toFixed(3);
if (item.time_virtual) {
scan_time += ' / ' + item.time_virtual.toFixed(3);
@@ -1212,6 +1214,11 @@ jQuery(function($){
});
} else if (table == 'rllog') {
$.each(data, function (i, item) {
if (item.header_from != null) item.header_from = escapeHtml(item.header_from);
if (item.header_subject != null) item.header_subject = escapeHtml(item.header_subject);
if (item.from != null) item.from = escapeHtml(item.from);
if (item.rcpt != null) item.rcpt = escapeHtml(item.rcpt);
if (item.message_id != null) item.message_id = escapeHtml(item.message_id);
if (item.user == null) {
item.user = "none";
}
@@ -1669,41 +1676,26 @@ function createHostCpuAndMemChart(){
function check_update(current_version, github_repo_url){
if (!current_version || !github_repo_url) return false;
var github_account = github_repo_url.split("/")[3];
var github_repo_name = github_repo_url.split("/")[4];
// get details about latest release
window.fetch("https://api.github.com/repos/"+github_account+"/"+github_repo_name+"/releases/latest", {method:'GET',cache:'no-cache'}).then(function(response) {
window.fetch("/inc/ajax/update_check.php", {method:'GET',cache:'no-cache'}).then(function(response) {
if (!response.ok) throw new Error("update check returned " + response.status);
return response.json();
}).then(function(latest_data) {
// get details about current release
window.fetch("https://api.github.com/repos/"+github_account+"/"+github_repo_name+"/releases/tags/"+current_version, {method:'GET',cache:'no-cache'}).then(function(response) {
return response.json();
}).then(function(current_data) {
// compare releases
var date_current = new Date(current_data.created_at);
var date_latest = new Date(latest_data.created_at);
if (date_latest.getTime() <= date_current.getTime()){
// no update available
$("#mailcow_update").removeClass("text-warning text-danger").addClass("text-success");
$("#mailcow_update").html("<b>" + lang_debug.no_update_available + "</b>");
} else {
// update available
$("#mailcow_update").removeClass("text-danger text-success").addClass("text-warning");
$("#mailcow_update").html(lang_debug.update_available + ` <a href="#" id="mailcow_update_changelog">`+latest_data.tag_name+`</a>`);
$("#mailcow_update_changelog").click(function(){
if (mailcow_cc_role !== "admin" && mailcow_cc_role !== "domainadmin")
return;
}).then(function(data) {
if (data.status === "no_update") {
$("#mailcow_update").removeClass("text-warning text-danger").addClass("text-success");
$("#mailcow_update").html("<b>" + lang_debug.no_update_available + "</b>");
} else if (data.status === "update_available") {
$("#mailcow_update").removeClass("text-danger text-success").addClass("text-warning");
$("#mailcow_update").html(lang_debug.update_available + ` <a href="#" id="mailcow_update_changelog">`+data.latest_tag+`</a>`);
$("#mailcow_update_changelog").click(function(){
if (mailcow_cc_role !== "admin" && mailcow_cc_role !== "domainadmin")
return;
showVersionModal("New Release " + latest_data.tag_name, latest_data.tag_name);
})
}
}).catch(err => {
// err
console.log(err);
$("#mailcow_update").removeClass("text-success text-warning").addClass("text-danger");
$("#mailcow_update").html("<b>"+ lang_debug.update_failed +"</b>");
});
showVersionModal("New Release " + data.latest_tag, data.latest_tag);
})
} else {
throw new Error(data.message || "update check failed");
}
}).catch(err => {
// err
console.log(err);
+1
View File
@@ -996,6 +996,7 @@ jQuery(function($){
'style="min-width:2em;width:' + item.percent_in_use + '%">' + item.percent_in_use + '%' + '</div></div>'
};
item.username = escapeHtml(item.username);
item.name = escapeHtml(item.name);
if (Array.isArray(item.tags)){
var tags = '';
+1
View File
@@ -728,6 +728,7 @@
"mta_sts_mx": "MX-Server",
"mta_sts_mx_info": "Erlaubt das Senden nur an explizit aufgeführte Mailserver-Hostnamen; der sendende MTA überprüft, ob der DNS-MX-Hostname mit der Richtlinienliste übereinstimmt, und erlaubt die Zustellung nur mit einem gültigen TLS-Zertifikat (schützt vor MITM).",
"mta_sts_mx_notice": "Es können mehrere MX-Server angegeben werden (durch Kommas getrennt).",
"mta_sts_active_info": "Wenn nicht angehakt, wird die MTA-STS-Richtlinie nicht veröffentlicht und für die mta-sts-Subdomain kein Zertifikat über ACME angefordert.",
"multiple_bookings": "Mehrfaches Buchen",
"nexthop": "Next Hop",
"none_inherit": "Keine Auswahl / Erben",
+1
View File
@@ -728,6 +728,7 @@
"mta_sts_mx": "MX server",
"mta_sts_mx_info": "Allows sending only to explicitly listed mail server hostnames; the sending MTA checks if the DNS MX hostname matches the policy list, and only allows delivery with a valid TLS certificate (guards against MITM).",
"mta_sts_mx_notice": "Multiple MX servers can be specified (separated by commas).",
"mta_sts_active_info": "If unchecked, the MTA-STS policy will not be published and no certificate will be requested for the mta-sts subdomain via ACME.",
"multiple_bookings": "Multiple bookings",
"none_inherit": "None / Inherit",
"nexthop": "Next hop",
+11 -2
View File
@@ -8,8 +8,12 @@ $ALLOW_ADMIN_EMAIL_LOGIN = (preg_match(
$session_var_user_allowed = 'sogo-sso-user-allowed';
$session_var_pass = 'sogo-sso-pass';
// only the internal nginx auth_request loopback (127.0.0.1:65510) sets this;
// external clients can never supply it (bare fastcgi param, not an HTTP header)
$is_internal_auth = (($_SERVER['SOGO_AUTH_INTERNAL'] ?? '') === '1');
// validate credentials for basic auth requests
if (isset($_SERVER['PHP_AUTH_USER'])) {
if ($is_internal_auth && isset($_SERVER['PHP_AUTH_USER'])) {
// load prerequisites only when required
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
@@ -50,6 +54,11 @@ elseif (isset($_GET['login'])) {
(($_SESSION['acl']['login_as'] == "1" && $ALLOW_ADMIN_EMAIL_LOGIN !== 0) || ($is_dual === false && $login == $_SESSION['mailcow_cc_username']))) {
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
if (user_get_alias_details($login) !== false) {
// enforce tenant boundary
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $login)) {
header("Location: /");
exit;
}
// Block SOGo access if pending actions (2FA setup, password update)
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
header("Location: /");
@@ -80,7 +89,7 @@ elseif (isset($_GET['login'])) {
exit;
}
// only check for admin-login on sogo GUI requests
elseif (isset($_SERVER['HTTP_X_ORIGINAL_URI']) && strcasecmp(substr($_SERVER['HTTP_X_ORIGINAL_URI'], 0, 9), "/SOGo/so/") === 0) {
elseif ($is_internal_auth && isset($_SERVER['HTTP_X_ORIGINAL_URI']) && strcasecmp(substr($_SERVER['HTTP_X_ORIGINAL_URI'], 0, 9), "/SOGo/so/") === 0) {
// this is an nginx auth_request call, we check for existing sogo-sso session variables
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/vars.inc.php';
if (file_exists($_SERVER['DOCUMENT_ROOT'] . '/inc/vars.local.inc.php')) {
+1 -1
View File
@@ -340,7 +340,7 @@
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if mta_sts.active == '1' %} checked{% endif %}> {{ lang.edit.active }}</label>
<label><i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill m-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.edit.mta_sts_active_info|raw }}"></i><input type="checkbox" class="form-check-input" value="1" name="active"{% if mta_sts.active == '1' %} checked{% endif %}> {{ lang.edit.active }}</label>
</div>
</div>
</div>
+3 -3
View File
@@ -84,7 +84,7 @@ services:
- clamd
rspamd-mailcow:
image: ghcr.io/mailcow/rspamd:4.1.0-1
image: ghcr.io/mailcow/rspamd:4.1.4-1
stop_grace_period: 30s
depends_on:
- dovecot-mailcow
@@ -419,7 +419,7 @@ services:
- php-fpm-mailcow
- sogo-mailcow
- rspamd-mailcow
image: ghcr.io/mailcow/nginx:1.30.3-1
image: ghcr.io/mailcow/nginx:1.30.4-1
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
@@ -465,7 +465,7 @@ services:
condition: service_started
unbound-mailcow:
condition: service_healthy
image: ghcr.io/mailcow/acme:1.97
image: ghcr.io/mailcow/acme:1.98
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment: