diff --git a/CHANGELOG.md b/CHANGELOG.md index cedf9600..7953c463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 1.4.10 (2026-03-31) + +### Features + +- **Plugins**: Add plugin configuration UI with schema-driven admin config page, calendar event action slot, and Jitsi Meet plugin +- **Calendar**: Implement client-side recurrence expansion for calendar events +- **Calendar**: Add iCal subscription editing and batch event import +- **Calendar**: Add hover preview settings and functionality +- **Calendar**: Add virtual location input for calendar events (#121) +- **Email**: Add reply-to addresses support in email composer +- **Email**: Add mail layout settings and update email list components +- **Email**: Add auto-select reply identity feature with settings and localization +- **Email**: Enhance compose functionality with button integration and translations +- **Filters**: Preserve activation state when updating or creating Sieve scripts to avoid deactivating server-managed vacation scripts +- **Filters**: Skip server-managed vacation script in Sieve script handling +- **Settings**: Add support for custom JMAP server endpoints in login and settings +- **Settings**: Add folder expansion state management and settings navigation +- **UI**: Add options to hide account switcher and show account avatars on navigation rail +- **i18n**: Add JMAP server endpoint labels and hints in multiple languages +- **i18n**: Add missing translation keys to all non-English locales + +### Fixes + +- **Security**: Patch critical auth bypass and credential leak vulnerabilities +- **Security**: Support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys and add diagnostic logging (#35) +- **Security**: Account isolation, auto-import signer certs, and no-key error handling (#35) +- **Calendar**: Fix JSCalendar 2.0 recurrenceRule single-object compatibility (#116) +- **Calendar**: Enhance calendar event handling to distinguish between events and tasks +- **Calendar**: Link existing events to target calendar during iCal import instead of skipping (#113) +- **Calendar**: Deduplicate UIDs during iCal import to prevent mass failures (#113) +- **Calendar**: Fix events disappearing after iCal import/subscription refresh +- **Calendar**: Enhance calendar event handling with full-day detection and layout adjustments +- **Calendar**: Use UTC timestamps for timed event rendering +- **Calendar**: Work around Stalwart not returning Task objects via CalendarEvent/query +- **Email**: Enhance email loading and deduplication logic in email store (#119) +- **Email**: Ensure draft editing function is called correctly in EmailViewer component (#60) +- **Email**: Match hover action background to selected row state +- **Email**: Align tag counts with mailbox folder counts in sidebar +- **Auth**: Handle 2FA/TOTP session expiry with basic auth (#117) +- **Mailbox**: Improve mailbox tree logic and enhance mailbox handling with logging (#118) +- **UI**: Improve dark mode handling for media elements and background images +- **UI**: Adjust account list spacing and remove push connection indicator +- **UI**: Fix nested button in theme card + ## 1.4.9 (2026-03-27) ### Features diff --git a/README.md b/README.md index 22c6c75e..ff7b4b1f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.4.9-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.10-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index 4ea2b1f4..ac9f79ca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.9 +1.4.10 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 3c9167d7..4ec6a56d 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; -const APP_VERSION = "1.4.9"; +const APP_VERSION = "1.4.10"; const THEME_OPTIONS = [ { value: "light" as const, icon: Sun, label: "Light" }, diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts index 3b06534f..bd6e5ef2 100644 --- a/app/api/admin/plugins/[id]/config/route.ts +++ b/app/api/admin/plugins/[id]/config/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPlugin } from '@/lib/admin/plugin-registry'; import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config'; +import { requireAdminAuth } from '@/lib/admin/session'; /** * GET /api/admin/plugins/[id]/config — Read all config for a plugin @@ -44,6 +45,9 @@ export async function PUT( { params }: { params: Promise<{ id: string }> }, ) { try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + const { id } = await params; if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { @@ -88,6 +92,9 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> }, ) { try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + const { id } = await params; if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index f1733f50..21635efd 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -59,6 +59,45 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } + // Only return non-sensitive fields. Use PUT to retrieve full credentials. + const { serverUrl, username } = credentials; + return NextResponse.json( + { serverUrl, username }, + { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } }, + ); + } catch (error) { + logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * PUT — retrieve full credentials (including password) for session restoration. + * Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed. + */ +export async function PUT(request: NextRequest) { + try { + // Block non-browser and cross-origin requests + const secFetchSite = request.headers.get('sec-fetch-site'); + if (secFetchSite !== 'same-origin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const slot = getSlot(request); + const cookieName = sessionCookieName(slot); + const cookieStore = await cookies(); + const token = cookieStore.get(cookieName)?.value; + + if (!token) { + return NextResponse.json({ error: 'No session' }, { status: 401 }); + } + + const credentials = decryptSession(token); + if (!credentials) { + cookieStore.delete(cookieName); + return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); + } + return NextResponse.json(credentials, { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' }, }); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index d0f237e7..3e0c966e 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -53,16 +53,14 @@ function isEnabled(): boolean { /** * Verify identity against session cookies across all account slots. * With multi-account, the requesting account may be on any slot (0-4). - * Returns true if any slot matches OR if no session cookies exist at all. + * Returns true only if a matching session cookie is found. */ async function verifyIdentity(username: string, serverUrl: string): Promise { const cookieStore = await cookies(); - let hasAnyCookie = false; for (let slot = 0; slot <= 4; slot++) { const token = cookieStore.get(sessionCookieName(slot))?.value; if (!token) continue; - hasAnyCookie = true; const session = decryptSession(token); if (session && session.username === username && session.serverUrl === serverUrl) { @@ -70,10 +68,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", diff --git a/package.json b/package.json index 6bf16312..730be362 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.9", + "version": "1.4.10", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 2a2c63ab..4b9de063 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1014,7 +1014,7 @@ export const useAuthStore = create()( scheduleRefresh(expires_in, get().refreshAccessToken, accountId); } } else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) { - const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`); + const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' }); if (res.ok) { const { serverUrl, username, password } = await res.json(); targetClient = new JMAPClient(serverUrl, username, password); @@ -1179,7 +1179,7 @@ export const useAuthStore = create()( throw new Error(`Token refresh failed: ${res.status}`); } } else if (account.authMode === 'basic' && account.rememberMe) { - const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`); + const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' }); if (res.ok) { const { serverUrl, username, password } = await res.json(); const client = new JMAPClient(serverUrl, username, password); @@ -1370,7 +1370,7 @@ export const useAuthStore = create()( if (state.authMode === 'basic') { set({ isLoading: true, isRateLimited: false, rateLimitUntil: null }); try { - const res = await fetch('/api/auth/session'); + const res = await fetch('/api/auth/session', { method: 'PUT' }); if (res.ok) { const data = await res.json(); if (!data.serverUrl || !data.username || !data.password) {