Răsfoiți Sursa

fix: make Roundcube one-click login reliable

AI-Co-Authored-By: Codex
chendeben 1 lună în urmă
părinte
comite
ecfc56b610

+ 3 - 0
.env.example

@@ -53,6 +53,9 @@ MAILDIR_SYNC_INTERVAL_MS=300000
 # ./data/secrets/webmail_sso_secret and expose it read-only to Roundcube.
 # ./data/secrets/webmail_sso_secret and expose it read-only to Roundcube.
 WEBMAIL_SSO_URL=
 WEBMAIL_SSO_URL=
 WEBMAIL_SSO_SECRET_FILE=./data/secrets/webmail_sso_secret
 WEBMAIL_SSO_SECRET_FILE=./data/secrets/webmail_sso_secret
+# Host-side reader gid for a same-host Roundcube PHP worker. Debian/Apache
+# images commonly use www-data gid 33. Leave 1000 when no external reader exists.
+WEBMAIL_SSO_READER_GID=1000
 WEBMAIL_SSO_TICKET_TTL_SECONDS=60
 WEBMAIL_SSO_TICKET_TTL_SECONDS=60
 WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200
 WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200
 
 

+ 8 - 5
docker/roundcube/plugins/mailhub_sso/mailhub_sso.php

@@ -1,7 +1,7 @@
 <?php
 <?php
 
 
 /**
 /**
- * MailHub one-click Webmail login for Roundcube 1.6.x.
+ * MailHub one-click Webmail login for Roundcube 1.6 and 1.7.
  *
  *
  * @license MIT
  * @license MIT
  */
  */
@@ -116,10 +116,13 @@ class mailhub_sso extends rcube_plugin
         $_SESSION['mailhub_sso_authenticated'] = true;
         $_SESSION['mailhub_sso_authenticated'] = true;
         $_SESSION['mailhub_sso_audience'] = $this->audience;
         $_SESSION['mailhub_sso_audience'] = $this->audience;
 
 
-        return [
-            '_task' => 'mail',
-            '_mbox' => 'INBOX',
-        ];
+        // exec_hook merges returned values with the original query. Clear all
+        // inherited keys first so a stale _url cannot override the fixed inbox.
+        $redirect = array_fill_keys(array_keys($args), null);
+        $redirect['_task'] = 'mail';
+        $redirect['_mbox'] = 'INBOX';
+
+        return $redirect;
     }
     }
 
 
     public function loginFailed($args)
     public function loginFailed($args)

+ 10 - 6
docs/webmail-sso.md

@@ -16,6 +16,7 @@ MailHub 的“一键登录 Webmail”使用两层短期凭据,不会向浏览
 ```dotenv
 ```dotenv
 WEBMAIL_SSO_URL=https://mail.us.ss5.xyz/
 WEBMAIL_SSO_URL=https://mail.us.ss5.xyz/
 WEBMAIL_SSO_SECRET_FILE=./data/secrets/webmail_sso_secret
 WEBMAIL_SSO_SECRET_FILE=./data/secrets/webmail_sso_secret
+WEBMAIL_SSO_READER_GID=33
 WEBMAIL_SSO_TICKET_TTL_SECONDS=60
 WEBMAIL_SSO_TICKET_TTL_SECONDS=60
 WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200
 WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200
 ```
 ```
@@ -27,22 +28,23 @@ Secret 只接受 64–512 个十六进制字符。首次部署前可直接运行
 ```bash
 ```bash
 install -d -m 0700 data/secrets
 install -d -m 0700 data/secrets
 openssl rand -hex 32 > data/secrets/webmail_sso_secret
 openssl rand -hex 32 > data/secrets/webmail_sso_secret
-chown 1000:1000 data/secrets/webmail_sso_secret
+chown 1000:33 data/secrets data/secrets/webmail_sso_secret
+chmod 0750 data/secrets
 chmod 0440 data/secrets/webmail_sso_secret
 chmod 0440 data/secrets/webmail_sso_secret
 ```
 ```
 
 
-同机 Roundcube 建议使用 `0440`、`1000:1000`,并仅把 Roundcube PHP 进程加入补充组 `1000`;不要改成全局可读。MailHub 的准备脚本会自动维持该权限。若 Roundcube 在另一台机器,复制相同内容到仅由其 PHP 运行用户可读的独立文件即可。
+同机 Roundcube 应把 `WEBMAIL_SSO_READER_GID` 设置为其 **PHP 工作进程的实际主组 GID**,Debian/Apache 镜像中的 `www-data` 通常为 `33`。Secret 保持 MailHub 的 uid `1000` 所有、对该组只读(目录 `0750`、文件 `0440`),不要改成全局可读。仅配置 Compose `group_add` 并不可靠,因为 Apache 降权后的 worker 可能主动丢弃补充组。MailHub 的准备脚本会从 `.env` 读取该纯数字 GID 并在后续部署中持续维护权限。若 Roundcube 在另一台机器,复制相同内容到仅由其 PHP 运行用户可读的独立文件即可。
 
 
 该 Secret 不能复用 Session、Dovecot、Token 或其他业务密钥,也不能提交到 Git。
 该 Secret 不能复用 Session、Dovecot、Token 或其他业务密钥,也不能提交到 Git。
 
 
-## Roundcube 1.6.x 安装
+## Roundcube 1.6/1.7 安装
 
 
 Roundcube PHP 运行环境需要启用 cURL 扩展。
 Roundcube PHP 运行环境需要启用 cURL 扩展。
 
 
 1. 将 [`docker/roundcube/plugins/mailhub_sso`](../docker/roundcube/plugins/mailhub_sso) 复制到 Roundcube 的 `plugins/mailhub_sso`。
 1. 将 [`docker/roundcube/plugins/mailhub_sso`](../docker/roundcube/plugins/mailhub_sso) 复制到 Roundcube 的 `plugins/mailhub_sso`。
 2. 将 `plugins/mailhub_sso/config.inc.php.dist` 复制为 `plugins/mailhub_sso/config.inc.php`,按实际网络修改内部地址;不要把 Secret 内容写进 PHP 配置。
 2. 将 `plugins/mailhub_sso/config.inc.php.dist` 复制为 `plugins/mailhub_sso/config.inc.php`,按实际网络修改内部地址;不要把 Secret 内容写进 PHP 配置。
 3. 把 `mailhub_sso` 加入 Roundcube `$config['plugins']`。可参考 [`docker/roundcube/config.inc.php.example`](../docker/roundcube/config.inc.php.example)。
 3. 把 `mailhub_sso` 加入 Roundcube `$config['plugins']`。可参考 [`docker/roundcube/config.inc.php.example`](../docker/roundcube/config.inc.php.example)。
-4. 将与 MailHub 完全相同的 Secret 只读挂载到 Roundcube 的 `/run/secrets/webmail_sso_secret`;同机容器为 PHP 用户增加补充组 `1000`,确保它能读取 `0440` 文件。
+4. 将与 MailHub 完全相同的 Secret 只读挂载到 Roundcube 的 `/run/secrets/webmail_sso_secret`;同机容器把 `WEBMAIL_SSO_READER_GID` 设置为 PHP worker 的实际主组 GID,确保 worker 能穿过 `0750` 目录并读取 `0440` 文件。
 5. 保持外部 Webmail 全程 HTTPS,并把 `mailhub_sso_audience` 设置为精确 origin,不带路径和结尾斜杠。
 5. 保持外部 Webmail 全程 HTTPS,并把 `mailhub_sso_audience` 设置为精确 origin,不带路径和结尾斜杠。
 
 
 若 HTTPS 终止在 Roundcube 前方的反向代理,将 Roundcube 的 `use_https` 设为 `true`;不要同时启用与它互斥的 `force_https`。
 若 HTTPS 终止在 Roundcube 前方的反向代理,将 Roundcube 的 `use_https` 设为 `true`;不要同时启用与它互斥的 `force_https`。
@@ -75,8 +77,8 @@ Roundcube 使用独立 Compose 时,可将现有 MailHub 网络声明为 extern
 ```yaml
 ```yaml
 services:
 services:
   roundcube:
   roundcube:
-    group_add:
-      - "1000"
+    extra_hosts:
+      - "in.ss5.xyz:host-gateway"
     volumes:
     volumes:
       - /absolute/path/to/mailhub/data/secrets/webmail_sso_secret:/run/secrets/webmail_sso_secret:ro
       - /absolute/path/to/mailhub/data/secrets/webmail_sso_secret:/run/secrets/webmail_sso_secret:ro
     networks:
     networks:
@@ -88,6 +90,8 @@ networks:
     name: <mailhub-compose-project>_mailhub
     name: <mailhub-compose-project>_mailhub
 ```
 ```
 
 
+同机部署并通过宿主机公开的 `993/465` 端口连接时,应使用上述 `extra_hosts` 将邮件主机名映射到 Docker host gateway。这样既避免容器绕公网 IP 回环连接被拒绝,也继续使用 `in.ss5.xyz` 做 TLS 主机名校验;不要直接把 IMAP/SMTP 地址替换成裸网关 IP。
+
 同时在对外反向代理中拒绝公网访问 `/internal/webmail-sso/`;Bearer Secret 是内网接口的第二层校验,不应代替网络隔离。
 同时在对外反向代理中拒绝公网访问 `/internal/webmail-sso/`;Bearer Secret 是内网接口的第二层校验,不应代替网络隔离。
 
 
 内部接口只接受 `Authorization: Bearer <shared-secret>` 和 JSON:
 内部接口只接受 `Authorization: Bearer <shared-secret>` 和 JSON:

+ 33 - 5
scripts/prepare-dovecot.sh

@@ -16,9 +16,31 @@ data_dir="$(cd "${data_dir}" && pwd -P)"
 secret_file="${MAILHUB_DOVECOT_SECRET_FILE:-${data_dir}/secrets/dovecot_auth_secret}"
 secret_file="${MAILHUB_DOVECOT_SECRET_FILE:-${data_dir}/secrets/dovecot_auth_secret}"
 webmail_secret_file="${MAILHUB_WEBMAIL_SSO_SECRET_FILE:-${data_dir}/secrets/webmail_sso_secret}"
 webmail_secret_file="${MAILHUB_WEBMAIL_SSO_SECRET_FILE:-${data_dir}/secrets/webmail_sso_secret}"
 maildir_root="${MAILHUB_MAILDIR_ROOT:-${data_dir}/maildir}"
 maildir_root="${MAILHUB_MAILDIR_ROOT:-${data_dir}/maildir}"
+host_os="$(uname -s)"
 host_uid="$(id -u)"
 host_uid="$(id -u)"
+webmail_reader_gid="${MAILHUB_WEBMAIL_SSO_READER_GID:-}"
 
 
-if [[ "$(uname -s)" == "Linux" && "${host_uid}" != "0" && "${host_uid}" != "1000" ]]; then
+if [[ -z "${webmail_reader_gid}" && -f "${project_dir}/.env" ]]; then
+  webmail_reader_gid_count="$(awk -F= '
+    $1 == "WEBMAIL_SSO_READER_GID" { count++ }
+    END { print count + 0 }
+  ' "${project_dir}/.env")"
+  (( webmail_reader_gid_count <= 1 )) \
+    || fail "WEBMAIL_SSO_READER_GID must appear at most once in .env."
+  webmail_reader_gid="$(awk -F= '
+    $1 == "WEBMAIL_SSO_READER_GID" { value = substr($0, index($0, "=") + 1) }
+    END { print value }
+  ' "${project_dir}/.env")"
+  webmail_reader_gid="${webmail_reader_gid%$'\r'}"
+fi
+webmail_reader_gid="${webmail_reader_gid:-1000}"
+[[ "${webmail_reader_gid}" =~ ^[0-9]+$ ]] \
+  || fail "Webmail SSO reader gid must be a positive numeric gid."
+webmail_reader_gid="$((10#${webmail_reader_gid}))"
+(( webmail_reader_gid > 0 && webmail_reader_gid <= 2147483647 )) \
+  || fail "Webmail SSO reader gid must be a positive numeric gid."
+
+if [[ "${host_os}" == "Linux" && "${host_uid}" != "0" && "${host_uid}" != "1000" ]]; then
   fail "Linux preparation must run as root or host uid 1000 so the rootless containers can read and write their bind mounts."
   fail "Linux preparation must run as root or host uid 1000 so the rootless containers can read and write their bind mounts."
 fi
 fi
 
 
@@ -67,18 +89,24 @@ prepare_secret "${secret_file}" "dovecot-auth"
 prepare_secret "${webmail_secret_file}" "webmail-sso"
 prepare_secret "${webmail_secret_file}" "webmail-sso"
 
 
 if [[ "${host_uid}" == "0" ]]; then
 if [[ "${host_uid}" == "0" ]]; then
-  # MailHub runs as uid/gid 1000. The Webmail secret is group-readable so a
-  # Roundcube container can join supplemental gid 1000 without making it public.
   chown 1000:1000 \
   chown 1000:1000 \
     "${secret_dir}" \
     "${secret_dir}" \
-    "${webmail_secret_dir}" \
     "${secret_file}" \
     "${secret_file}" \
-    "${webmail_secret_file}" \
     "${maildir_root}"
     "${maildir_root}"
+  # MailHub remains the owner. The dedicated reader group allows the actual
+  # Roundcube PHP worker gid to read the shared secret without world access.
+  chown "1000:${webmail_reader_gid}" \
+    "${webmail_secret_dir}" \
+    "${webmail_secret_file}"
 fi
 fi
 chmod 0700 "${maildir_root}"
 chmod 0700 "${maildir_root}"
 chmod 0750 "${secret_dir}" "${webmail_secret_dir}"
 chmod 0750 "${secret_dir}" "${webmail_secret_dir}"
 chmod 0400 "${secret_file}"
 chmod 0400 "${secret_file}"
 chmod 0440 "${webmail_secret_file}"
 chmod 0440 "${webmail_secret_file}"
+if [[ "${host_os}" == "Linux" ]]; then
+  [[ "$(stat -c '%g' "${webmail_secret_dir}")" == "${webmail_reader_gid}" \
+    && "$(stat -c '%g' "${webmail_secret_file}")" == "${webmail_reader_gid}" ]] \
+    || fail "Webmail SSO secret reader gid could not be applied; run preparation as root."
+fi
 
 
 echo "Dovecot storage and authentication secrets are ready."
 echo "Dovecot storage and authentication secrets are ready."

+ 61 - 1
test/prepare-dovecot-script.test.js

@@ -1,5 +1,5 @@
 import assert from 'node:assert/strict';
 import assert from 'node:assert/strict';
-import { mkdtempSync, readFileSync, statSync } from 'node:fs';
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
 import path from 'node:path';
 import { spawnSync } from 'node:child_process';
 import { spawnSync } from 'node:child_process';
@@ -38,3 +38,63 @@ test('Dovecot preparation creates a private secret and Maildir root', () => {
   assert.equal(statSync(maildirRoot).isDirectory(), true);
   assert.equal(statSync(maildirRoot).isDirectory(), true);
   assert.equal(statSync(maildirRoot).mode & 0o777, 0o700);
   assert.equal(statSync(maildirRoot).mode & 0o777, 0o700);
 });
 });
+
+test('Dovecot preparation grants only the configured Roundcube worker gid', () => {
+  const projectDir = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-reader-'));
+  const binDir = path.join(projectDir, 'bin');
+  const chownLog = path.join(projectDir, 'chown.log');
+  mkdirSync(binDir);
+  writeExecutable(path.join(binDir, 'id'), '#!/usr/bin/env bash\necho 0\n');
+  writeExecutable(path.join(binDir, 'uname'), '#!/usr/bin/env bash\necho Darwin\n');
+  writeExecutable(path.join(binDir, 'chown'), `#!/usr/bin/env bash
+printf '%s\\n' "$*" >> "\${CHOWN_LOG}"
+`);
+  writeFileSync(path.join(projectDir, '.env'), 'WEBMAIL_SSO_READER_GID=33\n');
+
+  const result = spawnSync('bash', ['scripts/prepare-dovecot.sh'], {
+    cwd: path.resolve(import.meta.dirname, '..'),
+    env: {
+      ...process.env,
+      PATH: `${binDir}:${process.env.PATH}`,
+      CHOWN_LOG: chownLog,
+      MAILHUB_PROJECT_DIR: projectDir
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+
+  const chownCalls = readFileSync(chownLog, 'utf8').trim().split('\n');
+  const privateCall = chownCalls.find((line) => line.startsWith('1000:1000 '));
+  const webmailCall = chownCalls.find((line) => line.startsWith('1000:33 '));
+  assert.ok(privateCall?.includes('dovecot_auth_secret'));
+  assert.ok(privateCall?.includes('/maildir'));
+  assert.ok(!privateCall?.includes('webmail_sso_secret'));
+  assert.ok(webmailCall?.includes('/secrets'));
+  assert.ok(webmailCall?.includes('webmail_sso_secret'));
+});
+
+test('Dovecot preparation rejects ambiguous or invalid Roundcube reader gids', () => {
+  for (const envContents of [
+    'WEBMAIL_SSO_READER_GID=33\nWEBMAIL_SSO_READER_GID=34\n',
+    'WEBMAIL_SSO_READER_GID=www-data\n',
+    'WEBMAIL_SSO_READER_GID=0\n'
+  ]) {
+    const projectDir = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-reader-invalid-'));
+    writeFileSync(path.join(projectDir, '.env'), envContents);
+    const result = spawnSync('bash', ['scripts/prepare-dovecot.sh'], {
+      cwd: path.resolve(import.meta.dirname, '..'),
+      env: {
+        ...process.env,
+        MAILHUB_PROJECT_DIR: projectDir
+      },
+      encoding: 'utf8'
+    });
+    assert.notEqual(result.status, 0);
+    assert.match(result.stderr, /reader gid|must appear at most once/i);
+  }
+});
+
+function writeExecutable(filePath, contents) {
+  writeFileSync(filePath, contents);
+  chmodSync(filePath, 0o755);
+}

+ 9 - 0
test/roundcube-webmail-sso-config.test.js

@@ -25,6 +25,10 @@ test('Roundcube SSO keeps sensitive values out of output and error logging', asy
   assert.match(plugin, /'credential'\s*=>\s*\$credential/);
   assert.match(plugin, /'credential'\s*=>\s*\$credential/);
   assert.match(plugin, /add_hook\('session_destroy'/);
   assert.match(plugin, /add_hook\('session_destroy'/);
   assert.match(plugin, /mailhub_sso\.ssofailed/);
   assert.match(plugin, /mailhub_sso\.ssofailed/);
+  assert.match(plugin, /array_fill_keys\(array_keys\(\$args\),\s*null\)/);
+  assert.match(plugin, /\$redirect\['_task'\]\s*=\s*'mail'/);
+  assert.match(plugin, /\$redirect\['_mbox'\]\s*=\s*'INBOX'/);
+  assert.doesNotMatch(plugin, /X-MailHub-SSO-Diagnostic/i);
 });
 });
 
 
 test('Compose and examples use a dedicated file-backed Webmail SSO secret', async () => {
 test('Compose and examples use a dedicated file-backed Webmail SSO secret', async () => {
@@ -39,9 +43,14 @@ test('Compose and examples use a dedicated file-backed Webmail SSO secret', asyn
   assert.doesNotMatch(compose, /webmail_sso_secret:\s*\n\s+file:/);
   assert.doesNotMatch(compose, /webmail_sso_secret:\s*\n\s+file:/);
   assert.doesNotMatch(compose, /^\s{2}roundcube:/m);
   assert.doesNotMatch(compose, /^\s{2}roundcube:/m);
   assert.match(env, /^WEBMAIL_SSO_URL=\s*$/m);
   assert.match(env, /^WEBMAIL_SSO_URL=\s*$/m);
+  assert.match(env, /^WEBMAIL_SSO_READER_GID=1000$/m);
   assert.match(env, /WEBMAIL_SSO_TICKET_TTL_SECONDS=60/);
   assert.match(env, /WEBMAIL_SSO_TICKET_TTL_SECONDS=60/);
   assert.match(env, /WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200/);
   assert.match(env, /WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200/);
   assert.match(pluginConfig, /mailhub_sso_secret_file'\]\s*=\s*'\/run\/secrets\/webmail_sso_secret'/);
   assert.match(pluginConfig, /mailhub_sso_secret_file'\]\s*=\s*'\/run\/secrets\/webmail_sso_secret'/);
   assert.match(pluginConfig, /mailhub_sso_audience'\]\s*=\s*'https:\/\//);
   assert.match(pluginConfig, /mailhub_sso_audience'\]\s*=\s*'https:\/\//);
   assert.match(docs, /不运行 Roundcube/);
   assert.match(docs, /不运行 Roundcube/);
+  assert.match(docs, /WEBMAIL_SSO_READER_GID=33/);
+  assert.match(docs, /PHP 工作进程的实际主组 GID/);
+  assert.doesNotMatch(docs, /group_add:/);
+  assert.match(docs, /in\.ss5\.xyz:host-gateway/);
 });
 });