Prechádzať zdrojové kódy

fix: make Roundcube one-click login reliable

AI-Co-Authored-By: Codex
chendeben 1 mesiac pred
rodič
commit
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.
 WEBMAIL_SSO_URL=
 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_CREDENTIAL_TTL_SECONDS=43200
 

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

@@ -1,7 +1,7 @@
 <?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
  */
@@ -116,10 +116,13 @@ class mailhub_sso extends rcube_plugin
         $_SESSION['mailhub_sso_authenticated'] = true;
         $_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)

+ 10 - 6
docs/webmail-sso.md

@@ -16,6 +16,7 @@ MailHub 的“一键登录 Webmail”使用两层短期凭据,不会向浏览
 ```dotenv
 WEBMAIL_SSO_URL=https://mail.us.ss5.xyz/
 WEBMAIL_SSO_SECRET_FILE=./data/secrets/webmail_sso_secret
+WEBMAIL_SSO_READER_GID=33
 WEBMAIL_SSO_TICKET_TTL_SECONDS=60
 WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200
 ```
@@ -27,22 +28,23 @@ Secret 只接受 64–512 个十六进制字符。首次部署前可直接运行
 ```bash
 install -d -m 0700 data/secrets
 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
 ```
 
-同机 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。
 
-## Roundcube 1.6.x 安装
+## Roundcube 1.6/1.7 安装
 
 Roundcube PHP 运行环境需要启用 cURL 扩展。
 
 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 配置。
 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,不带路径和结尾斜杠。
 
 若 HTTPS 终止在 Roundcube 前方的反向代理,将 Roundcube 的 `use_https` 设为 `true`;不要同时启用与它互斥的 `force_https`。
@@ -75,8 +77,8 @@ Roundcube 使用独立 Compose 时,可将现有 MailHub 网络声明为 extern
 ```yaml
 services:
   roundcube:
-    group_add:
-      - "1000"
+    extra_hosts:
+      - "in.ss5.xyz:host-gateway"
     volumes:
       - /absolute/path/to/mailhub/data/secrets/webmail_sso_secret:/run/secrets/webmail_sso_secret:ro
     networks:
@@ -88,6 +90,8 @@ networks:
     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 是内网接口的第二层校验,不应代替网络隔离。
 
 内部接口只接受 `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}"
 webmail_secret_file="${MAILHUB_WEBMAIL_SSO_SECRET_FILE:-${data_dir}/secrets/webmail_sso_secret}"
 maildir_root="${MAILHUB_MAILDIR_ROOT:-${data_dir}/maildir}"
+host_os="$(uname -s)"
 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."
 fi
 
@@ -67,18 +89,24 @@ prepare_secret "${secret_file}" "dovecot-auth"
 prepare_secret "${webmail_secret_file}" "webmail-sso"
 
 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 \
     "${secret_dir}" \
-    "${webmail_secret_dir}" \
     "${secret_file}" \
-    "${webmail_secret_file}" \
     "${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
 chmod 0700 "${maildir_root}"
 chmod 0750 "${secret_dir}" "${webmail_secret_dir}"
 chmod 0400 "${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."

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

@@ -1,5 +1,5 @@
 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 path from 'node:path';
 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).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, /add_hook\('session_destroy'/);
   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 () => {
@@ -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, /^\s{2}roundcube:/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_CREDENTIAL_TTL_SECONDS=43200/);
   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(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/);
 });