From b0a4a657aa3552c8ba3b1bffb3f11e47734b1049 Mon Sep 17 00:00:00 2001 From: dreamhunter2333 Date: Mon, 7 Sep 2026 18:27:11 +0800 Subject: [PATCH] fix: validate mailbox credentials and isolate E2E APIs --- CHANGELOG.md | 3 +- CHANGELOG_EN.md | 3 +- e2e/Dockerfile.worker | 2 + e2e/README.md | 3 +- e2e/fixtures/email.ts | 3 + .../fixtures/mail-api.ts | 55 +++---- e2e/fixtures/test-helpers.ts | 2 +- e2e/fixtures/worker.ts | 19 +++ e2e/fixtures/wrangler.toml.e2e | 7 +- e2e/fixtures/wrangler.toml.e2e.env-off | 6 +- e2e/fixtures/wrangler.toml.e2e.gzip | 6 +- .../wrangler.toml.e2e.send-mail-domain | 6 +- e2e/fixtures/wrangler.toml.e2e.site-password | 6 +- e2e/package-lock.json | 11 ++ e2e/package.json | 1 + e2e/tests/api-gzip/mail-gzip.spec.ts | 4 +- .../api/address-activity-throttle.spec.ts | 18 ++- e2e/tests/api/address-credential.spec.ts | 124 +++++++++++++++ e2e/tests/api/auto-reply-trigger.spec.ts | 2 +- e2e/tests/api/cleanup.spec.ts | 6 +- ...email-forward-domain-normalization.spec.ts | 6 +- e2e/tests/api/login-endpoints.spec.ts | 1 + e2e/tests/api/mail-deletion.spec.ts | 2 +- e2e/tests/api/mail-read.spec.ts | 2 +- e2e/tests/api/redeem-code-access.spec.ts | 3 + e2e/tests/api/send-mail.spec.ts | 29 ++++ e2e/tests/api/telegram-ai-extract.spec.ts | 2 +- e2e/tests/api/telegram-binding.spec.ts | 147 ++++++++++++++++++ e2e/tests/api/webhook-trigger.spec.ts | 4 +- e2e/tests/browser/user-access-token.spec.ts | 30 ++++ frontend/src/api/error-codes.js | 1 + frontend/src/api/index.js | 6 +- worker/src/address_auth.ts | 46 ++++++ worker/src/admin_api/index.ts | 5 - worker/src/email/auto_reply.ts | 20 +-- worker/src/error_codes.ts | 1 + worker/src/mails_api/mails_crud.ts | 27 +--- worker/src/mails_api/send_mail_api.ts | 13 +- worker/src/open_api/auth.ts | 10 +- worker/src/telegram_api/common.ts | 65 ++++---- worker/src/telegram_api/miniapp.ts | 4 +- worker/src/types.d.ts | 3 - worker/src/worker.ts | 8 +- 43 files changed, 564 insertions(+), 158 deletions(-) create mode 100644 e2e/fixtures/email.ts rename worker/src/admin_api/e2e_test_api.ts => e2e/fixtures/mail-api.ts (64%) create mode 100644 e2e/fixtures/worker.ts create mode 100644 e2e/tests/api/address-credential.spec.ts create mode 100644 e2e/tests/api/telegram-binding.spec.ts create mode 100644 worker/src/address_auth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 610e382..e94af21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Bug Fixes +- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分站点认证错误以避免重复登录,并将 E2E 测试接口移出生产代码 - fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性 - fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题 - fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题 @@ -242,7 +243,7 @@ - test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行 - test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护 -- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用 +- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点 ### Improvements diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 513d099..7dc21ea 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -21,6 +21,7 @@ ### Bug Fixes +- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish site authentication errors to prevent login loops; move E2E test endpoints out of production code - fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script - fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading - fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs @@ -242,7 +243,7 @@ - test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test` - test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization -- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled +- test: |Worker| Add `/admin/test/seed_mail` test endpoint ### Improvements diff --git a/e2e/Dockerfile.worker b/e2e/Dockerfile.worker index b3b81d1..0550321 100644 --- a/e2e/Dockerfile.worker +++ b/e2e/Dockerfile.worker @@ -10,6 +10,8 @@ COPY worker/patches/ patches/ RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install) COPY worker/src/ src/ +COPY e2e/fixtures/*.ts /app/e2e/fixtures/ +RUN ln -s /app/worker/node_modules /app/e2e/node_modules COPY worker/tsconfig.json ./ ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e COPY ${WRANGLER_TOML} wrangler.toml diff --git a/e2e/README.md b/e2e/README.md index a849b0d..e9df66d 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -51,7 +51,8 @@ Test results and HTML reports are exported via volumes: ## Configuration The E2E worker uses `fixtures/wrangler.toml.e2e` with: -- `E2E_TEST_MODE = true` — enables test seed endpoint - `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls - `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval - SMTP pointed at Mailpit container (`mailpit:1025`) + +Test-only endpoints under `/__test/*` are registered in `e2e/fixtures/worker.ts` and are excluded from the production Worker. diff --git a/e2e/fixtures/email.ts b/e2e/fixtures/email.ts new file mode 100644 index 0000000..2cb53b5 --- /dev/null +++ b/e2e/fixtures/email.ts @@ -0,0 +1,3 @@ +export class EmailMessage { + constructor(public from: string, public to: string, public raw: string) {} +} diff --git a/worker/src/admin_api/e2e_test_api.ts b/e2e/fixtures/mail-api.ts similarity index 64% rename from worker/src/admin_api/e2e_test_api.ts rename to e2e/fixtures/mail-api.ts index 403c8ba..baf5922 100644 --- a/worker/src/admin_api/e2e_test_api.ts +++ b/e2e/fixtures/mail-api.ts @@ -1,45 +1,41 @@ -import { Context } from 'hono' -import { getBooleanValue } from '../utils' - // Direct DB insert — bypasses the email() handler. -const seedMail = async (c: Context) => { - if (!getBooleanValue(c.env.E2E_TEST_MODE)) { - return c.text("Not available", 404); - } - const { address, source, raw, message_id, created_at, address_updated_at, address_created_at } = await c.req.json(); +const seedMail = async (request: Request, env: Bindings) => { + const { address, source, raw, message_id, created_at, address_updated_at, address_created_at } = await request.json<{ + address: string; source?: string; raw: string; message_id?: string; + created_at?: string; address_updated_at?: string; address_created_at?: string; + }>(); if (!address || !raw) { - return c.text("address and raw are required", 400); + return new Response("address and raw are required", { status: 400 }); } if (raw.length > 1_000_000) { - return c.text("raw content too large", 400); + return new Response("raw content too large", { status: 400 }); } if (message_id && message_id.length > 255) { - return c.text("message_id too long", 400); + return new Response("message_id too long", { status: 400 }); } if (address_updated_at !== undefined) { - await c.env.DB.prepare(`UPDATE address SET updated_at = ? WHERE name = ?`) + await env.DB.prepare(`UPDATE address SET updated_at = ? WHERE name = ?`) .bind(address_updated_at, address).run(); } if (address_created_at !== undefined) { - await c.env.DB.prepare(`UPDATE address SET created_at = ? WHERE name = ?`) + await env.DB.prepare(`UPDATE address SET created_at = ? WHERE name = ?`) .bind(address_created_at, address).run(); } const msgId = message_id || ``; - const { success } = await c.env.DB.prepare( + const { success } = await env.DB.prepare( `INSERT INTO raw_mails (message_id, source, address, raw, created_at)` + ` VALUES (?, ?, ?, ?, COALESCE(?, datetime('now')))` ).bind(msgId, source || address, address, raw, created_at ?? null).run(); - return c.json({ success }); + return Response.json({ success }); }; // Exercises the real email() handler with a mock ForwardableEmailMessage. -const receiveMail = async (c: Context) => { - if (!getBooleanValue(c.env.E2E_TEST_MODE)) { - return c.text("Not available", 404); - } - const { from, to, raw, ai_extract_result } = await c.req.json(); +const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => { + const { from, to, raw, ai_extract_result } = await request.json<{ + from: string; to: string; raw: string; ai_extract_result?: unknown; + }>(); if (!from || !to || !raw) { - return c.text("from, to and raw are required", 400); + return new Response("from, to and raw are required", { status: 400 }); } // Parse MIME headers (unfold continuation lines, extract key:value pairs) @@ -61,24 +57,19 @@ const receiveMail = async (c: Context) => { forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; }, reply: async () => { state.replyCalled = true; return { messageId: '' }; }, }; - const { email: emailHandler } = await import('../email'); + const { email: emailHandler } = await import('../../worker/src/email'); const aiExtractEnvOverrides: Partial = { ENABLE_AI_EMAIL_EXTRACT: true, AI: { run: async () => ({ response: ai_extract_result }) } as unknown as Ai, }; - const env = ai_extract_result - ? { ...c.env, ...aiExtractEnvOverrides } - : c.env; - const executionContext: ExecutionContext = { - waitUntil: () => {}, - passThroughOnException: () => {}, - props: {} - }; - await emailHandler(mockMessage, env, executionContext); + const emailEnv = ai_extract_result + ? { ...env, ...aiExtractEnvOverrides } + : env; + await emailHandler(mockMessage, emailEnv, ctx); - return c.json({ + return Response.json({ success: !state.rejected, replyCalled: state.replyCalled, forwardedTo: state.forwardedTo, diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 5aabd39..e456de1 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -78,7 +78,7 @@ export async function seedTestMail( `--${boundary}--`, ].join('\r\n'); - const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, { + const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, { data: { from, to: address, raw }, }); if (!res.ok()) { diff --git a/e2e/fixtures/worker.ts b/e2e/fixtures/worker.ts new file mode 100644 index 0000000..a0fd683 --- /dev/null +++ b/e2e/fixtures/worker.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono'; +import worker from '../../worker/src/worker'; +import { CONSTANTS } from '../../worker/src/constants'; +import mailApi from './mail-api'; + +const testApi = new Hono(); +testApi.post('/seed_mail', c => mailApi.seedMail(c.req.raw, c.env)); +testApi.post('/receive_mail', c => mailApi.receiveMail(c.req.raw, c.env, c.executionCtx as ExecutionContext)); +testApi.get('/telegram_binding', async c => { + const address = c.req.query('address'); + if (!address) return c.text('address is required', 400); + return c.json(await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`)); +}); + +const app = new Hono(); +app.route('/__test', testApi); +app.all('*', c => worker.fetch(c.req.raw, c.env, c.executionCtx)); + +export default { ...worker, fetch: app.fetch }; diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 1d1c00a..6239e04 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -1,5 +1,5 @@ name = "cloudflare_temp_email" -main = "src/worker.ts" +main = "../e2e/fixtures/worker.ts" compatibility_date = "2025-04-01" compatibility_flags = [ "nodejs_compat" ] keep_vars = true @@ -15,6 +15,7 @@ USER_ROLES = [ { domains = [], role = "empty-role", prefix = "EMPTY" }, ] JWT_SECRET = "e2e-test-secret-key" +TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token" BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true @@ -29,7 +30,6 @@ DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_USER_ROLE = "admin" ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true -E2E_TEST_MODE = true CLEANUP_BATCH_SIZE = 10 SMTP_CONFIG = """ {"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} @@ -43,3 +43,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000" binding = "DB" database_name = "e2e-temp-email" database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000" + +[alias] +"cloudflare:email" = "../e2e/fixtures/email.ts" diff --git a/e2e/fixtures/wrangler.toml.e2e.env-off b/e2e/fixtures/wrangler.toml.e2e.env-off index 17d901d..e46573d 100644 --- a/e2e/fixtures/wrangler.toml.e2e.env-off +++ b/e2e/fixtures/wrangler.toml.e2e.env-off @@ -1,5 +1,5 @@ name = "cloudflare_temp_email_env_off" -main = "src/worker.ts" +main = "../e2e/fixtures/worker.ts" compatibility_date = "2025-04-01" compatibility_flags = [ "nodejs_compat" ] keep_vars = true @@ -21,7 +21,6 @@ ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true -E2E_TEST_MODE = true SMTP_CONFIG = """ {"test.example.com":{"host":"mailpit","port":1025,"secure":false}} """ @@ -34,3 +33,6 @@ id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000" binding = "DB" database_name = "e2e-temp-email-env-off" database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000" + +[alias] +"cloudflare:email" = "../e2e/fixtures/email.ts" diff --git a/e2e/fixtures/wrangler.toml.e2e.gzip b/e2e/fixtures/wrangler.toml.e2e.gzip index f52fb31..1550a7c 100644 --- a/e2e/fixtures/wrangler.toml.e2e.gzip +++ b/e2e/fixtures/wrangler.toml.e2e.gzip @@ -1,5 +1,5 @@ name = "cloudflare_temp_email_gzip" -main = "src/worker.ts" +main = "../e2e/fixtures/worker.ts" compatibility_date = "2025-04-01" compatibility_flags = [ "nodejs_compat" ] keep_vars = true @@ -18,7 +18,6 @@ ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true -E2E_TEST_MODE = true ENABLE_MAIL_GZIP = true SMTP_CONFIG = """ {"test.example.com":{"host":"mailpit","port":1025,"secure":false}} @@ -32,3 +31,6 @@ id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000" binding = "DB" database_name = "e2e-temp-email-gzip" database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000" + +[alias] +"cloudflare:email" = "../e2e/fixtures/email.ts" diff --git a/e2e/fixtures/wrangler.toml.e2e.send-mail-domain b/e2e/fixtures/wrangler.toml.e2e.send-mail-domain index b227ad4..e4f3b94 100644 --- a/e2e/fixtures/wrangler.toml.e2e.send-mail-domain +++ b/e2e/fixtures/wrangler.toml.e2e.send-mail-domain @@ -1,5 +1,5 @@ name = "cloudflare_temp_email" -main = "src/worker.ts" +main = "../e2e/fixtures/worker.ts" compatibility_date = "2025-04-01" compatibility_flags = [ "nodejs_compat" ] keep_vars = true @@ -23,7 +23,6 @@ ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true -E2E_TEST_MODE = true SMTP_CONFIG = """ {"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} """ @@ -36,3 +35,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000" binding = "DB" database_name = "e2e-temp-email" database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000" + +[alias] +"cloudflare:email" = "../e2e/fixtures/email.ts" diff --git a/e2e/fixtures/wrangler.toml.e2e.site-password b/e2e/fixtures/wrangler.toml.e2e.site-password index 3d68e1e..ce5aadf 100644 --- a/e2e/fixtures/wrangler.toml.e2e.site-password +++ b/e2e/fixtures/wrangler.toml.e2e.site-password @@ -1,5 +1,5 @@ name = "cloudflare_temp_email_site_password" -main = "src/worker.ts" +main = "../e2e/fixtures/worker.ts" compatibility_date = "2025-04-01" compatibility_flags = [ "nodejs_compat" ] keep_vars = true @@ -20,7 +20,6 @@ ENABLE_ADDRESS_PASSWORD = true ADDRESS_CHECK_REGEX = "^(?!.*blocked).*$" DISABLE_ADMIN_PASSWORD_CHECK = false ADMIN_PASSWORDS = '["e2e-admin-pass"]' -E2E_TEST_MODE = true [[kv_namespaces]] binding = "KV" @@ -30,3 +29,6 @@ id = "e2e-test-kv-site-password-00000000-0000-0000-0000-000000000000" binding = "DB" database_name = "e2e-temp-email-site-password" database_id = "e2e-test-db-site-password-00000000-0000-0000-0000-000000000000" + +[alias] +"cloudflare:email" = "../e2e/fixtures/email.ts" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index d01842b..224868b 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -12,6 +12,7 @@ "devDependencies": { "@playwright/test": "1.63.0", "@types/ws": "^8.18.1", + "hono": "^4.13.7", "ws": "^8.21.3" } }, @@ -86,6 +87,16 @@ "node": ">=18.0.0" } }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", diff --git a/e2e/package.json b/e2e/package.json index f168eef..771ebbc 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -10,6 +10,7 @@ "devDependencies": { "@playwright/test": "1.63.0", "@types/ws": "^8.18.1", + "hono": "^4.13.7", "ws": "^8.21.3" }, "dependencies": { diff --git a/e2e/tests/api-gzip/mail-gzip.spec.ts b/e2e/tests/api-gzip/mail-gzip.spec.ts index f4d8a44..266bfb5 100644 --- a/e2e/tests/api-gzip/mail-gzip.spec.ts +++ b/e2e/tests/api-gzip/mail-gzip.spec.ts @@ -48,7 +48,7 @@ async function receiveGzipMail( `--${boundary}--`, ].join('\r\n'); - const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, { + const res = await ctx.post(`${WORKER_GZIP_URL}/__test/receive_mail`, { data: { from, to: address, raw }, }); if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`); @@ -74,7 +74,7 @@ async function seedPlaintextMail( opts.text || 'Hello plaintext from E2E', ].join('\r\n'); - const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, { + const res = await ctx.post(`${WORKER_GZIP_URL}/__test/seed_mail`, { data: { address, source: from, raw, message_id: messageId }, }); if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`); diff --git a/e2e/tests/api/address-activity-throttle.spec.ts b/e2e/tests/api/address-activity-throttle.spec.ts index a3a29b5..ea87dc0 100644 --- a/e2e/tests/api/address-activity-throttle.spec.ts +++ b/e2e/tests/api/address-activity-throttle.spec.ts @@ -135,7 +135,7 @@ for (const { base, disabled } of [ }); } async function seed(request: APIRequestContext, mailbox: Mailbox, updatedAt: string | null = OLD, createdAt?: string) { - await call(request, '/admin/test/seed_mail', { + await call(request, '/__test/seed_mail', { method: 'POST', data: { address: mailbox.address, raw: `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: activity\r\n\r\nBody`, address_updated_at: updatedAt, address_created_at: createdAt, created_at: OLD, @@ -170,7 +170,17 @@ for (const { base, disabled } of [ const inbox = await list(request, '/admin/mails', { address: mailbox.address }); const sent = await list(request, '/admin/sendbox', { address: mailbox.address }); const sender = await list(request, '/admin/address_sender', { address: mailbox.address }); - const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) })).json(); + let replyMailbox = mailbox; + if (!address) { + await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) }, 401); + // A new credential lets us check that no old auto-reply data survived cleanup. + const [name, domain] = mailbox.address.split('@'); + replyMailbox = await (await call(request, '/admin/new_address', { + method: 'POST', data: { name, domain, enablePrefix: false }, + })).json(); + } + const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(replyMailbox) })).json(); + if (!address) await call(request, `/admin/delete_address/${replyMailbox.address_id}`, { method: 'DELETE' }); const bound = await (await call(request, '/user_api/bind_address', { headers: userAuth(user) })).json(); return [Number(!!address), inbox.count, sent.count, sender.count, Number(!!reply.subject), bound.results.filter((row: { name: string }) => row.name === mailbox.address).length]; @@ -267,7 +277,7 @@ for (const { base, disabled } of [ await bind(request, mailbox, user); await seed(request, mailbox); const raw = `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: incoming\r\n\r\nBody`; - const received = await call(request, '/admin/test/receive_mail', { + const received = await call(request, '/__test/receive_mail', { method: 'POST', data: { from: 'sender@test.example.com', to: mailbox.address, raw }, }); expect((await received.json()).success).toBe(true); @@ -359,7 +369,7 @@ for (const { base, disabled } of [ if (cleanType === 'mails_unknow') { queryAddress = `unknown${Date.now()}@test.example.com`; orphanAddresses.push(queryAddress); - await call(request, '/admin/test/seed_mail', { + await call(request, '/__test/seed_mail', { method: 'POST', data: { address: queryAddress, raw: 'old unknown mail', created_at: OLD }, }); } diff --git a/e2e/tests/api/address-credential.spec.ts b/e2e/tests/api/address-credential.spec.ts new file mode 100644 index 0000000..00bf774 --- /dev/null +++ b/e2e/tests/api/address-credential.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; +import { createHmac } from 'node:crypto'; + +import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail } from '../../fixtures/test-helpers'; + +function signToken(payload: Record) { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = createHmac('sha256', 'e2e-test-secret-key') + .update(`${header}.${body}`).digest('base64url'); + return `${header}.${body}.${signature}`; +} + +async function expectRejected(request: APIRequestContext, jwt: string) { + for (const [method, path] of [ + ['GET', '/api/settings'], + ['GET', '/api/mails?limit=20&offset=0'], + ['GET', '/api/mail/1'], + ['GET', '/api/parsed_mails?limit=20&offset=0'], + ['GET', '/api/parsed_mail/1'], + ['GET', '/api/sendbox?limit=20&offset=0'], + ['GET', '/api/auto_reply'], + ['POST', '/api/webhook/settings'], + ['POST', '/api/attachment/get_url'], + ['POST', '/api/address_change_password'], + ['POST', '/api/request_send_mail_access'], + ['POST', '/api/send_mail'], + ['PATCH', '/api/mails/1/read'], + ['DELETE', '/api/mails/1'], + ['DELETE', '/api/sendbox/1'], + ['DELETE', '/api/clear_inbox'], + ['DELETE', '/api/clear_sent_items'], + ['DELETE', '/api/delete_address'], + ]) { + const response = await request.fetch(`${WORKER_URL}${path}`, { + method, + headers: { Authorization: `Bearer ${jwt}` }, + ...(method === 'POST' || method === 'PATCH' ? { data: {} } : {}), + }); + expect(response.status(), `${method} ${path}`).toBe(401); + } + const login = await request.post(`${WORKER_URL}/open_api/credential_login`, { + data: { credential: jwt }, + }); + expect(login.status()).toBe(401); + const send = await request.post(`${WORKER_URL}/external/api/send_mail`, { + headers: { 'x-lang': 'en' }, + data: { token: jwt }, + }); + expect(send.status()).toBe(400); + expect(await send.text()).toBe('Failed to send mail Invalid address credential'); + const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, { + headers: { + Authorization: `Bearer ${jwt}`, + 'x-user-token': signToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 60 }), + }, + }); + expect(bind.status()).toBe(401); +} + +test('deleted credentials cannot access or delete a recreated mailbox', async ({ request }) => { + const name = `credential${Date.now()}`; + const create = async () => { + const response = await request.post(`${WORKER_URL}/api/new_address`, { + data: { name, domain: TEST_DOMAIN }, + }); + expect(response.ok()).toBe(true); + return await response.json(); + }; + const original = await create(); + await deleteAddress(request, original.jwt); + await expectRejected(request, original.jwt); + + const recreated = await create(); + try { + expect(recreated.address).toBe(original.address); + expect(recreated.address_id).not.toBe(original.address_id); + await seedTestMail(request, recreated.address, { subject: 'New owner mail' }); + await expectRejected(request, original.jwt); + const mails = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, { + headers: { Authorization: `Bearer ${recreated.jwt}` }, + }); + expect(mails.ok()).toBe(true); + expect((await mails.json()).count).toBe(1); + } finally { + await deleteAddress(request, recreated.jwt); + } +}); + +test('valid numeric/string IDs work; missing, invalid and mismatched IDs are rejected', async ({ request }) => { + const mailbox = await createTestAddress(request, 'credential-id'); + try { + for (const address_id of [mailbox.address_id, String(mailbox.address_id)]) { + const token = signToken({ address: mailbox.address, address_id }); + const settings = await request.get(`${WORKER_URL}/api/settings`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(settings.ok()).toBe(true); + const login = await request.post(`${WORKER_URL}/open_api/credential_login`, { + data: { credential: token }, + }); + expect(login.ok()).toBe(true); + } + for (const payload of [ + { address: mailbox.address }, + ...[0, -1, 1.5, true, null, '', '1e3', {}, Number.MAX_SAFE_INTEGER + 1] + .map(address_id => ({ address: mailbox.address, address_id })), + { address: `other@${TEST_DOMAIN}`, address_id: mailbox.address_id }, + { address_id: mailbox.address_id }, + ]) { + const jwt = signToken(payload); + const response = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(response.status(), JSON.stringify(payload)).toBe(401); + const login = await request.post(`${WORKER_URL}/open_api/credential_login`, { + data: { credential: jwt }, + }); + expect(login.status(), JSON.stringify(payload)).toBe(401); + } + } finally { + await deleteAddress(request, mailbox.jwt); + } +}); diff --git a/e2e/tests/api/auto-reply-trigger.spec.ts b/e2e/tests/api/auto-reply-trigger.spec.ts index fb051c6..448db21 100644 --- a/e2e/tests/api/auto-reply-trigger.spec.ts +++ b/e2e/tests/api/auto-reply-trigger.spec.ts @@ -175,7 +175,7 @@ async function seedTestMailWithReply( text, ].join('\r\n'); - const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, { + const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, { data: { from, to: address, raw }, }); if (!res.ok()) { diff --git a/e2e/tests/api/cleanup.spec.ts b/e2e/tests/api/cleanup.spec.ts index 59c82db..e30ddb9 100644 --- a/e2e/tests/api/cleanup.spec.ts +++ b/e2e/tests/api/cleanup.spec.ts @@ -13,7 +13,7 @@ test.describe('Bounded cleanup', () => { test('cleans at most one batch and continues on the next run', async ({ request }) => { const address = `cleanup-batch-${Date.now()}@test.example.com`; const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) => - request.post(`${WORKER_URL}/admin/test/seed_mail`, { + request.post(`${WORKER_URL}/__test/seed_mail`, { data: { address, raw: 'old cleanup mail', @@ -40,7 +40,7 @@ test.describe('Bounded cleanup', () => { const afterSecondCleanup = await listMails(request, address); expect(afterSecondCleanup.count).toBe(0); - const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, { + const recentMailResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, { data: { address, raw: 'recent cleanup mail', @@ -63,7 +63,7 @@ test.describe('Bounded cleanup', () => { test('deletes one address batch and its related data', async ({ request }) => { const oldAddress = await createTestAddress(request, 'cleanup-old'); - const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, { + const seedResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, { data: { address: oldAddress.address, raw: 'address cleanup mail', diff --git a/e2e/tests/api/email-forward-domain-normalization.spec.ts b/e2e/tests/api/email-forward-domain-normalization.spec.ts index 7f134c2..b6d9fe4 100644 --- a/e2e/tests/api/email-forward-domain-normalization.spec.ts +++ b/e2e/tests/api/email-forward-domain-normalization.spec.ts @@ -65,7 +65,7 @@ test.describe('Email forward domain normalization', () => { `Forward domain normalization test`, ].join('\r\n'); - const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { data: { from: 'sender@test.example.com', to, raw }, }); expect(res.ok()).toBe(true); @@ -118,7 +118,7 @@ test.describe('Email forward domain normalization', () => { `Forward domain boundary test`, ].join('\r\n'); - const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { data: { from: 'sender@test.example.com', to, raw }, }); expect(res.ok()).toBe(true); @@ -161,7 +161,7 @@ test.describe('Email forward domain normalization', () => { `Forward catch-all domain test`, ].join('\r\n'); - const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { data: { from: 'sender@test.example.com', to: address, raw }, }); expect(res.ok()).toBe(true); diff --git a/e2e/tests/api/login-endpoints.spec.ts b/e2e/tests/api/login-endpoints.spec.ts index a605194..6b2520a 100644 --- a/e2e/tests/api/login-endpoints.spec.ts +++ b/e2e/tests/api/login-endpoints.spec.ts @@ -19,6 +19,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled } }); expect(res.status()).toBe(401); + expect(await res.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID', message: expect.any(String) }); }); }); diff --git a/e2e/tests/api/mail-deletion.spec.ts b/e2e/tests/api/mail-deletion.spec.ts index dc914a3..afdec6b 100644 --- a/e2e/tests/api/mail-deletion.spec.ts +++ b/e2e/tests/api/mail-deletion.spec.ts @@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => { `--${boundary}--`, ].join('\r\n'); - const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, { + const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, { data: { from, to: address, raw }, }); expect(seedRes.ok()).toBe(true); diff --git a/e2e/tests/api/mail-read.spec.ts b/e2e/tests/api/mail-read.spec.ts index a1d1311..05d77be 100644 --- a/e2e/tests/api/mail-read.spec.ts +++ b/e2e/tests/api/mail-read.spec.ts @@ -14,7 +14,7 @@ test.describe('Mail read status', () => { test('keeps historical mail read and switches one new mail state', async ({ request }) => { const mailbox = await createTestAddress(request, 'mail-read'); try { - const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, { + const historical = await request.post(`${WORKER_URL}/__test/seed_mail`, { data: { address: mailbox.address, source: 'sender@test.example.com', diff --git a/e2e/tests/api/redeem-code-access.spec.ts b/e2e/tests/api/redeem-code-access.spec.ts index d423fd5..cb15b57 100644 --- a/e2e/tests/api/redeem-code-access.spec.ts +++ b/e2e/tests/api/redeem-code-access.spec.ts @@ -83,6 +83,7 @@ test.describe('Redemption feature access boundaries', () => { }, ); expect(blockedAdminResponse.status()).toBe(401); + expect(await blockedAdminResponse.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' }); const createResponse = await request.post( `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`, @@ -106,11 +107,13 @@ test.describe('Redemption feature access boundaries', () => { { data: { code } }, ); expect(missingPassword.status()).toBe(401); + expect(await missingPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' }); const wrongPassword = await request.post( `${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`, { headers: { 'x-custom-auth': 'wrong' }, data: { code } }, ); expect(wrongPassword.status()).toBe(401); + expect(await wrongPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' }); } const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, { headers: SITE_HEADERS, diff --git a/e2e/tests/api/send-mail.spec.ts b/e2e/tests/api/send-mail.spec.ts index 3d5b44f..e86d291 100644 --- a/e2e/tests/api/send-mail.spec.ts +++ b/e2e/tests/api/send-mail.spec.ts @@ -51,4 +51,33 @@ test.describe('Send Mail via SMTP', () => { // Cleanup await deleteAddress(request, jwt); }); + + test('external sending stores mail fields without bearer credentials', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'external-sender'); + const mail = { + from_name: 'External sender', to_name: 'Recipient', to_mail: 'recipient@test.example.com', + subject: `External ${Date.now()}`, content: 'External message', is_html: false, + }; + try { + const listener = onMailpitMessage(message => message.Subject === mail.subject); + await listener.ready; + const response = await request.post(`${WORKER_URL}/external/api/send_mail`, { + data: { ...mail, token: jwt, extra_credential: 'must-not-be-stored' }, + }); + expect(response.ok(), await response.text()).toBe(true); + expect((await listener.message).From.Address).toBe(address); + const sendbox = await request.get(`${WORKER_URL}/api/sendbox?limit=20&offset=0`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(sendbox.ok()).toBe(true); + const { results } = await sendbox.json(); + expect(results).toHaveLength(1); + const stored = JSON.parse(results[0].raw); + expect(stored).toMatchObject(mail); + expect(stored).not.toHaveProperty('token'); + expect(stored).not.toHaveProperty('extra_credential'); + } finally { + await deleteAddress(request, jwt); + } + }); }); diff --git a/e2e/tests/api/telegram-ai-extract.spec.ts b/e2e/tests/api/telegram-ai-extract.spec.ts index 0393e88..bde79e0 100644 --- a/e2e/tests/api/telegram-ai-extract.spec.ts +++ b/e2e/tests/api/telegram-ai-extract.spec.ts @@ -18,7 +18,7 @@ test.describe('Telegram AI extraction rendering', () => { 'Telegram AI extraction realtime body', ].join('\r\n'); - const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, { data: { from: 'sender@test.example.com', to: address, diff --git a/e2e/tests/api/telegram-binding.spec.ts b/e2e/tests/api/telegram-binding.spec.ts new file mode 100644 index 0000000..e00545f --- /dev/null +++ b/e2e/tests/api/telegram-binding.spec.ts @@ -0,0 +1,147 @@ +import { expect, test, type APIRequestContext } from '@playwright/test'; +import { createHmac } from 'node:crypto'; + +import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers'; + +function initData(userId: number) { + const fields = { + auth_date: String(Math.floor(Date.now() / 1000)), + user: JSON.stringify({ id: userId }), + }; + const key = createHmac('sha256', 'WebAppData').update('e2e-telegram-test-token').digest(); + const hash = createHmac('sha256', key) + .update(Object.entries(fields).map(([name, value]) => `${name}=${value}`).join('\n')) + .digest('hex'); + return new URLSearchParams({ ...fields, hash }).toString(); +} + +async function bind(request: APIRequestContext, userId: number, jwt: string) { + const response = await request.post(`${WORKER_URL}/telegram/bind_address`, { + data: { initData: initData(userId), jwt }, + }); + expect(response.ok(), await response.text()).toBe(true); +} + +async function unbind(request: APIRequestContext, userId: number, address: string, status = 200) { + const response = await request.post(`${WORKER_URL}/telegram/unbind_address`, { + data: { initData: initData(userId), address }, + }); + expect(response.status(), await response.text()).toBe(status); +} + +async function addressList(request: APIRequestContext, userId: number) { + const response = await request.post(`${WORKER_URL}/telegram/get_bind_address`, { + data: { initData: initData(userId) }, + }); + expect(response.ok(), await response.text()).toBe(true); + return response.json(); +} + +async function expectPushOwner(request: APIRequestContext, address: string, userId: number | null) { + const response = await request.get(`${WORKER_URL}/__test/telegram_binding`, { + params: { address }, + }); + expect(response.ok(), await response.text()).toBe(true); + expect(await response.json()).toBe(userId === null ? null : String(userId)); +} + +test('Telegram users can remove their own bindings after another user binds the mailbox', async ({ request }) => { + const mailbox = await createTestAddress(request, 'tg-owner'); + const owner = Date.now(); + const other = owner + 1; + try { + await bind(request, owner, mailbox.jwt); + await unbind(request, other, mailbox.address, 400); + await expectPushOwner(request, mailbox.address, owner); + await unbind(request, owner, mailbox.address); + await expectPushOwner(request, mailbox.address, null); + + await bind(request, owner, mailbox.jwt); + await bind(request, other, mailbox.jwt); + await expectPushOwner(request, mailbox.address, other); + await unbind(request, owner, mailbox.address); + expect(await addressList(request, owner)).toEqual([]); + expect(await addressList(request, other)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]); + await expectPushOwner(request, mailbox.address, other); + await unbind(request, other, mailbox.address); + expect(await addressList(request, other)).toEqual([]); + await expectPushOwner(request, mailbox.address, null); + + await bind(request, owner, mailbox.jwt); + await bind(request, other, mailbox.jwt); + await bind(request, owner, mailbox.jwt); + expect(await addressList(request, owner)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]); + await expectPushOwner(request, mailbox.address, owner); + await unbind(request, other, mailbox.address); + await expectPushOwner(request, mailbox.address, owner); + await unbind(request, owner, mailbox.address); + expect(await addressList(request, owner)).toEqual([]); + await expectPushOwner(request, mailbox.address, null); + } finally { + await request.delete(`${WORKER_URL}/api/delete_address`, { + headers: { Authorization: `Bearer ${mailbox.jwt}` }, + }); + } +}); + +test('stale Telegram credentials cannot unbind; internal mailbox cleanup still works', async ({ request }) => { + const original = await createTestAddress(request, 'tg-stale'); + const owner = Date.now(); + await bind(request, owner, original.jwt); + const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`); + expect(deletion.ok()).toBe(true); + const [name, domain] = original.address.split('@'); + const creation = await request.post(`${WORKER_URL}/admin/new_address`, { + data: { name, domain, enablePrefix: false }, + }); + expect(creation.ok()).toBe(true); + const recreated = await creation.json(); + try { + expect(recreated.address_id).not.toBe(original.address_id); + await unbind(request, owner, recreated.address, 400); + await expectPushOwner(request, recreated.address, owner); + const response = await request.delete(`${WORKER_URL}/api/delete_address`, { + headers: { Authorization: `Bearer ${recreated.jwt}` }, + }); + expect(response.ok(), await response.text()).toBe(true); + await expectPushOwner(request, recreated.address, null); + + const listing = await request.post(`${WORKER_URL}/telegram/get_bind_address`, { + data: { initData: initData(owner) }, + }); + expect(listing.ok()).toBe(true); + expect(await listing.json()).toEqual([]); + } finally { + await request.delete(`${WORKER_URL}/admin/delete_address/${recreated.address_id}`); + } +}); + +test('Telegram unbind accepts a current credential after a stale credential for the same address', async ({ request }) => { + const original = await createTestAddress(request, 'tg-recreated'); + const unrelated = await createTestAddress(request, 'tg-retained'); + const owner = Date.now(); + await bind(request, owner, original.jwt); + await bind(request, owner, unrelated.jwt); + const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`); + expect(deletion.ok()).toBe(true); + const [name, domain] = original.address.split('@'); + const creation = await request.post(`${WORKER_URL}/admin/new_address`, { + data: { name, domain, enablePrefix: false }, + }); + expect(creation.ok()).toBe(true); + const recreated = await creation.json(); + try { + expect(recreated.address_id).not.toBe(original.address_id); + await bind(request, owner, recreated.jwt); + await unbind(request, owner, recreated.address); + await expectPushOwner(request, recreated.address, null); + expect(await addressList(request, owner)).toEqual([{ address: unrelated.address, jwt: unrelated.jwt }]); + await expectPushOwner(request, unrelated.address, owner); + } finally { + for (const mailbox of [recreated, unrelated]) { + await request.delete(`${WORKER_URL}/api/delete_address`, { + headers: { Authorization: `Bearer ${mailbox.jwt}` }, + }); + } + } +}); diff --git a/e2e/tests/api/webhook-trigger.spec.ts b/e2e/tests/api/webhook-trigger.spec.ts index e82d7dc..596fab2 100644 --- a/e2e/tests/api/webhook-trigger.spec.ts +++ b/e2e/tests/api/webhook-trigger.spec.ts @@ -95,7 +95,7 @@ test.describe('Webhook — triggered on incoming mail', () => { `Webhook trigger test body`, ].join('\r\n'); - const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { data: { from, to: address, @@ -157,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => { `Should not trigger webhook`, ].join('\r\n'); - const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { data: { from: 'sender@test.example.com', to: address, raw }, }); expect(res.ok()).toBe(true); diff --git a/e2e/tests/browser/user-access-token.spec.ts b/e2e/tests/browser/user-access-token.spec.ts index 282e092..7ff583d 100644 --- a/e2e/tests/browser/user-access-token.spec.ts +++ b/e2e/tests/browser/user-access-token.spec.ts @@ -17,6 +17,36 @@ const openApiTestPage = async (page: Page) => { const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } }; +for (const scenario of [ + { name: 'mailbox credential', path: '/api/settings', site: false, admin: false }, + { name: 'account credential', path: '/user_api/settings', site: false, admin: false }, + { name: 'site password', path: '/api/settings', site: true, admin: false }, + { name: 'site password on admin API', path: '/admin/db_version', site: true, admin: false }, + { name: 'admin password', path: '/admin/db_version', site: false, admin: true }, +]) { + test(`Authentication dialog distinguishes ${scenario.name}`, async ({ page }) => { + await openApiTestPage(page); + await page.route(`**${scenario.path}`, route => route.fulfill(scenario.site + ? { status: 401, json: { code: 'AUTH_SITE_PASSWORD_INVALID', message: 'Site password required' } } + : { status: 401, body: 'Invalid credential' })); + const result = await page.evaluate(async (path) => { + const apiModule = '/src/api/index.js'; + const storeModule = '/src/store/index.js'; + const { api } = await import(apiModule); + const state = (await import(storeModule)).useGlobalState(); + state.openSettings.value.needAuth = true; + state.showAuth.value = false; + state.showAdminAuth.value = false; + try { + await api.fetch(path); + } catch { + return { site: state.showAuth.value, admin: state.showAdminAuth.value }; + } + }, scenario.path); + expect(result).toEqual({ site: scenario.site, admin: scenario.admin }); + }); +} + for (const scenario of ['expired', 'expiring', 'valid', 'no account', 'login expired', 'wrong password', 'text zh', 'text en', 'retry expired', 'retry unauthorized', 'unmatched path', 'server error', 'json client error', 'json server error'] as const) { test(`Access token response handling: ${scenario}`, async ({ page }) => { await openApiTestPage(page); diff --git a/frontend/src/api/error-codes.js b/frontend/src/api/error-codes.js index b031fa1..ae4076f 100644 --- a/frontend/src/api/error-codes.js +++ b/frontend/src/api/error-codes.js @@ -1,4 +1,5 @@ export const ErrorCode = { INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', + AUTH_SITE_PASSWORD_INVALID: 'AUTH_SITE_PASSWORD_INVALID', AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', }; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 883016b..6e516a1 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -8,6 +8,7 @@ import { safeBearerHeader, safeHeaderValue } from '../utils/headers' import { sanitizeHtml } from '../utils/sanitize-html' import { APP_CONFIG } from '../config' import { isUserAccessTokenError, createUserAccessTokenInterceptor } from './user-access-token-interceptor' +import { ErrorCode } from './error-codes' const API_BASE = APP_CONFIG.API_BASE || ""; const { @@ -63,10 +64,11 @@ const apiFetch = async (path, options = {}) => { headers, }); const response = await interceptResponse(path, initialResponse); - if (response.status === 401 && path.startsWith("/admin") && !isUserAccessTokenError(response)) { + const isSiteAuthError = response.status === 401 && response.data?.code === ErrorCode.AUTH_SITE_PASSWORD_INVALID; + if (response.status === 401 && path.startsWith("/admin") && !isUserAccessTokenError(response) && !isSiteAuthError) { showAdminAuth.value = true; } - if (response.status === 401 && openSettings.value.needAuth && !isUserAccessTokenError(response)) { + if (isSiteAuthError && openSettings.value.needAuth) { showAuth.value = true; } if (response.status >= 300) { diff --git a/worker/src/address_auth.ts b/worker/src/address_auth.ts new file mode 100644 index 0000000..ca6415a --- /dev/null +++ b/worker/src/address_auth.ts @@ -0,0 +1,46 @@ +import { Context, Next } from 'hono'; +import { jwt } from 'hono/jwt'; +import { Jwt } from 'hono/utils/jwt'; + +import i18n from './i18n'; + +export const validateAddressPayload = async ( + c: Context, + payload: Record, +): Promise => { + const { address, address_id } = payload; + if (typeof address !== 'string' || !address) return null; + if (typeof address_id !== 'number' + && (typeof address_id !== 'string' || !/^\d+$/.test(address_id)) + ) return null; + const addressId = Number(address_id); + if (!Number.isSafeInteger(addressId) || addressId <= 0) return null; + const exists = await c.env.DB.prepare( + `SELECT id FROM address WHERE id = ? AND name = ?` + ).bind(addressId, address).first('id'); + return exists ? { address, address_id: addressId } : null; +}; + +export const verifyAddressToken = async ( + c: Context, + token: string, +): Promise => { + const payload = await Jwt.verify(token, c.env.JWT_SECRET, 'HS256'); + const addressPayload = await validateAddressPayload(c, payload); + if (!addressPayload) { + throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg); + } + return addressPayload; +}; + +export const addressJwtAuth = async (c: Context, next: Next) => ( + jwt({ secret: c.env.JWT_SECRET, alg: 'HS256' })(c, async () => { + const payload = await validateAddressPayload(c, c.get('jwtPayload')); + if (!payload) { + c.res = c.text(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg, 401); + return; + } + c.set('jwtPayload', payload); + await next(); + }) +); diff --git a/worker/src/admin_api/index.ts b/worker/src/admin_api/index.ts index 18cabfa..2238080 100644 --- a/worker/src/admin_api/index.ts +++ b/worker/src/admin_api/index.ts @@ -17,7 +17,6 @@ import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail' import db_api from './db_api' import ip_blacklist_settings from './ip_blacklist_settings' import ai_extract_settings from './ai_extract_settings' -import e2e_test_api from './e2e_test_api' import config_api from './config_api' import redeem_code_api from '../redeem_api/admin_redeem_code_api' @@ -118,7 +117,3 @@ api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSe // AI extract settings api.get('/admin/ai_extract/settings', ai_extract_settings.getAiExtractSettings) api.post('/admin/ai_extract/settings', ai_extract_settings.saveAiExtractSettings) - -// E2E test endpoints -api.post('/admin/test/seed_mail', e2e_test_api.seedMail) -api.post('/admin/test/receive_mail', e2e_test_api.receiveMail) diff --git a/worker/src/email/auto_reply.ts b/worker/src/email/auto_reply.ts index 31bc4ca..2cc3f05 100644 --- a/worker/src/email/auto_reply.ts +++ b/worker/src/email/auto_reply.ts @@ -49,18 +49,14 @@ export const auto_reply = async ( contentType: 'text/plain', data: results.message || "This is an auto-reply message, please reconact later." }); - if (getBooleanValue(env.E2E_TEST_MODE)) { - await message.reply(msg.asRaw()); - } else { - const { EmailMessage } = await import('cloudflare:email'); - const replyMessage = new EmailMessage( - toAddress, - message.from, - msg.asRaw() - ); - // @ts-ignore - await message.reply(replyMessage); - } + const { EmailMessage } = await import('cloudflare:email'); + const replyMessage = new EmailMessage( + toAddress, + message.from, + msg.asRaw() + ); + // @ts-ignore + await message.reply(replyMessage); } } catch (error) { console.log("reply email error", error); diff --git a/worker/src/error_codes.ts b/worker/src/error_codes.ts index bb32ef9..6f1c80c 100644 --- a/worker/src/error_codes.ts +++ b/worker/src/error_codes.ts @@ -1,4 +1,5 @@ export enum ErrorCode { INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + AUTH_SITE_PASSWORD_INVALID = 'AUTH_SITE_PASSWORD_INVALID', AUTH_USER_ACCESS_TOKEN_EXPIRED = 'AUTH_USER_ACCESS_TOKEN_EXPIRED', } diff --git a/worker/src/mails_api/mails_crud.ts b/worker/src/mails_api/mails_crud.ts index 7c409ce..9be72a4 100644 --- a/worker/src/mails_api/mails_crud.ts +++ b/worker/src/mails_api/mails_crud.ts @@ -62,32 +62,7 @@ const deleteMail = async (c: Context) => { }; const getSettings = async (c: Context) => { - const { address, address_id } = c.get("jwtPayload") - const msgs = i18n.getMessagesbyContext(c); - if (address_id && address_id > 0) { - try { - const db_address_id = await c.env.DB.prepare( - `SELECT id FROM address where id = ? ` - ).bind(address_id).first("id"); - if (!db_address_id) { - return c.text(msgs.InvalidAddressMsg, 400) - } - } catch (error) { - return c.text(msgs.InvalidAddressMsg, 400) - } - } - try { - if (!address_id) { - const db_address_id = await c.env.DB.prepare( - `SELECT id FROM address where name = ? ` - ).bind(address).first("id"); - if (!db_address_id) { - return c.text(msgs.InvalidAddressMsg, 400) - } - } - } catch (error) { - return c.text(msgs.InvalidAddressMsg, 400) - } + const { address } = c.get("jwtPayload") updateAddressUpdatedAt(c, address); diff --git a/worker/src/mails_api/send_mail_api.ts b/worker/src/mails_api/send_mail_api.ts index 719ee0f..34cfe4c 100644 --- a/worker/src/mails_api/send_mail_api.ts +++ b/worker/src/mails_api/send_mail_api.ts @@ -1,5 +1,5 @@ import { Context, Hono } from 'hono' -import { Jwt } from 'hono/utils/jwt' +import { verifyAddressToken } from '../address_auth'; import { createMimeMessage } from 'mimetext'; import { Resend } from 'resend'; import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer'; @@ -261,14 +261,11 @@ api.post('/api/send_mail', async (c) => { }) api.post('/external/api/send_mail', async (c) => { - const msgs = i18n.getMessagesbyContext(c); - const { token } = await c.req.json(); + const body = await c.req.json(); try { - const { address } = await Jwt.verify(token, c.env.JWT_SECRET, "HS256"); - if (!address) { - return c.text(msgs.AddressNotFoundMsg, 400) - } - const reqJson = await c.req.json(); + const { address } = await verifyAddressToken(c, body?.token); + const { from_name, to_mail, to_name, subject, content, is_html } = body; + const reqJson = { from_name, to_mail, to_name, subject, content, is_html }; await sendMail(c, address as string, reqJson); return c.json({ status: "ok" }) } catch (e) { diff --git a/worker/src/open_api/auth.ts b/worker/src/open_api/auth.ts index 03b681e..23ca380 100644 --- a/worker/src/open_api/auth.ts +++ b/worker/src/open_api/auth.ts @@ -1,8 +1,9 @@ import { Hono } from 'hono' -import { Jwt } from 'hono/utils/jwt' +import { verifyAddressToken } from '../address_auth'; import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils'; import i18n from '../i18n'; +import { ErrorCode } from '../error_codes'; const api = new Hono() @@ -19,7 +20,7 @@ api.post('/open_api/site_login', async (c) => { const passwords = getPasswords(c); const hashedPasswords = await Promise.all(passwords.map(p => hashPassword(p))); if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) { - return c.text(msgs.CustomAuthPasswordMsg, 401) + return c.json({ code: ErrorCode.AUTH_SITE_PASSWORD_INVALID, message: msgs.CustomAuthPasswordMsg }, 401) } return c.json({ success: true }) }) @@ -56,10 +57,7 @@ api.post('/open_api/credential_login', async (c) => { return c.text(msgs.InvalidAddressCredentialMsg, 401) } try { - const payload = await Jwt.verify(credential, c.env.JWT_SECRET, "HS256"); - if (!payload.address) { - return c.text(msgs.InvalidAddressCredentialMsg, 401) - } + await verifyAddressToken(c, credential); } catch (error) { return c.text(msgs.InvalidAddressCredentialMsg, 401) } diff --git a/worker/src/telegram_api/common.ts b/worker/src/telegram_api/common.ts index aad0e00..d1618d1 100644 --- a/worker/src/telegram_api/common.ts +++ b/worker/src/telegram_api/common.ts @@ -1,9 +1,11 @@ import { Context } from "hono"; import { Jwt } from "hono/utils/jwt"; +import { validateAddressPayload, verifyAddressToken } from '../address_auth'; import { CONSTANTS } from "../constants"; import { getBooleanValue, getIntValue, getJsonSetting } from "../utils"; import { deleteAddressWithData, newAddress, generateRandomName } from "../common"; import { LocaleMessages } from "../i18n/type"; +import i18n from '../i18n'; export const tgUserNewAddress = async ( c: Context, userId: string, address: string, @@ -63,15 +65,7 @@ export const jwtListToAddressData = async ( const invalidJwtList = [] as string[]; for (const jwt of jwtList) { try { - const { address, address_id } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256"); - const name = await c.env.DB.prepare( - `SELECT name FROM address WHERE id = ? ` - ).bind(address_id).first("name"); - if (!name) { - addressList.push(msgs.TgInvalidAddressMsg); - invalidJwtList.push(jwt); - continue; - } + const { address, address_id } = await verifyAddressToken(c, jwt); addressList.push(address as string); addressIdMap[address as string] = address_id as number; } catch (e) { @@ -87,13 +81,11 @@ export const bindTelegramAddress = async ( c: Context, userId: string, jwt: string, msgs: LocaleMessages ): Promise => { - const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256"); - if (!address) { - throw Error(msgs.TgInvalidCredentialMsg); - } + const { address } = await verifyAddressToken(c, jwt); const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || []; const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs); if (address as string in addressIdMap) { + await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString()); return address as string; } if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) { @@ -105,25 +97,45 @@ export const bindTelegramAddress = async ( return address as string; } +const getTelegramBindings = async (c: Context, userId: string) => { + const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || []; + return Promise.all(jwtList.map(async (jwt) => { + try { + return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256") }; + } catch (e) { + console.log(`解绑失败: ${(e as Error).message}`); + return { jwt, payload: null }; + } + })); +} + +const removeTelegramBinding = async ( + c: Context, userId: string, address: string, + bindings: Awaited> +): Promise => { + const newJwtList = bindings.filter(({ payload }) => payload?.address !== address).map(({ jwt }) => jwt); + await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify(newJwtList)); + const owner = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`); + if (owner === userId) await c.env.KV.delete(`${CONSTANTS.TG_KV_PREFIX}:${address}`); + return true; +} + export const unbindTelegramAddress = async ( c: Context, userId: string, address: string ): Promise => { - const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || []; - const newJwtList = []; - for (const jwt of jwtList) { + const msgs = i18n.getMessagesbyContext(c); + const bindings = await getTelegramBindings(c, userId); + for (const { payload } of bindings) { + if (payload?.address !== address) continue; try { - const { address: kvAddress } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256"); - if (kvAddress == address) { - continue; - } + if (!await validateAddressPayload(c, payload)) continue; } catch (e) { - console.log(`解绑失败: ${(e as Error).message}`); + console.log(`Failed to validate Telegram binding: ${(e as Error).message}`); + continue; } - newJwtList.push(jwt); + return await removeTelegramBinding(c, userId, address, bindings); } - await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify(newJwtList)); - await c.env.KV.delete(`${CONSTANTS.TG_KV_PREFIX}:${address}`); - return true; + throw Error(msgs.TgAddressNotYoursMsg); } export const unbindTelegramByAddress = async ( @@ -132,7 +144,8 @@ export const unbindTelegramByAddress = async ( if (!c.env.KV) return true; const userId = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`) if (userId) { - return await unbindTelegramAddress(c, userId, address); + const bindings = await getTelegramBindings(c, userId); + return await removeTelegramBinding(c, userId, address, bindings); } return true; } diff --git a/worker/src/telegram_api/miniapp.ts b/worker/src/telegram_api/miniapp.ts index 884b583..da2ecfb 100644 --- a/worker/src/telegram_api/miniapp.ts +++ b/worker/src/telegram_api/miniapp.ts @@ -1,5 +1,5 @@ import { Context } from "hono"; -import { Jwt } from 'hono/utils/jwt' +import { verifyAddressToken } from '../address_auth'; import { CONSTANTS } from "../constants"; import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common"; import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils"; @@ -69,7 +69,7 @@ async function getTelegramBindAddress(c: Context): Promise { ) { const auth = c.req.raw.headers.get("x-custom-auth"); if (!auth || !passwords.includes(auth)) { - return c.text(msgs.CustomAuthPasswordMsg, 401) + return c.json({ code: ErrorCode.AUTH_SITE_PASSWORD_INVALID, message: msgs.CustomAuthPasswordMsg }, 401) } } @@ -173,7 +173,7 @@ app.use('/api/*', async (c, next) => { } try { - return await jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next); + return await addressJwtAuth(c, next); } catch (e) { console.warn(e); const lang = c.get("lang") || c.env.DEFAULT_LANG; @@ -224,7 +224,7 @@ app.use('/user_api/*', async (c, next) => { if (c.req.path.startsWith('/user_api/bind_address') && c.req.method === 'POST' ) { - return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next); + return addressJwtAuth(c, next); } await next(); });