169 lines
6.5 KiB
Markdown
169 lines
6.5 KiB
Markdown
# Secure Webmail for docker-mailserver
|
|
|
|
A full-stack webmail application built with Bun, ElysiaJS, React, and Vite. The app is designed for public-internet exposure and treats docker-mailserver as an untrusted integration boundary that must be wrapped carefully.
|
|
|
|
## Stack
|
|
|
|
- Backend: Bun + ElysiaJS
|
|
- Frontend: React + Vite running on Bun
|
|
- Storage: Bun SQLite for app metadata, sessions, user preferences, and encrypted TOTP secrets
|
|
- Mail integration: Docker CLI with fixed argument arrays only
|
|
|
|
## Security-first design
|
|
|
|
### Critical risks and mitigations
|
|
|
|
| Risk | Mitigation |
|
|
| --- | --- |
|
|
| Command injection through Docker CLI | All Docker calls use `Bun.spawn()` with explicit argument arrays. No shell string concatenation, no `sh -c`, no raw user input in command strings. |
|
|
| Session theft or token leakage | Server-side opaque sessions in `httpOnly`, `SameSite=Strict` cookies. The frontend never stores session identifiers in `localStorage`. |
|
|
| CSRF | Every state-changing route requires an in-memory CSRF token in `X-CSRF-Token` plus the authenticated cookie. |
|
|
| XSS from HTML email | HTML email is sanitized on the backend, dangerous tags are removed, remote images are not allowed, and the frontend renders sanitized HTML only in a sandboxed iframe. |
|
|
| Brute-force login | Dedicated login rate limiter plus a global limiter. |
|
|
| Unsafe file uploads | Avatar uploads are size-limited, magic-byte checked, renamed with random tokens, stored outside the public root, and served through an authenticated endpoint. |
|
|
| Forwarding abuse | Forwarding targets are strictly validated, self-forwarding is blocked, and allowed target domains are policy-controlled. |
|
|
| Secret exposure | No passwords or TOTP secrets are logged. TOTP secrets are encrypted at rest with AES-GCM via `TOTP_ENCRYPTION_KEY`. |
|
|
| Information leakage | API errors return generic messages for internal failures and use explicit HTTP status codes for validation and auth issues. |
|
|
|
|
### Why server-side sessions instead of JWTs
|
|
|
|
This project uses secure server-side sessions instead of bearer tokens or long-lived JWTs. That keeps revocation simple, reduces token exposure risk in the browser, and avoids putting sensitive auth state in JavaScript storage.
|
|
|
|
## Features
|
|
|
|
- Mailbox login against docker-mailserver via `doveadm auth test`
|
|
- Optional seeded local admin account with Argon2id-hashed password
|
|
- Inbox listing and message preview from Maildir inside the mail container
|
|
- Plain-text compose flow using `sendmail` inside the container
|
|
- Safe HTML email preview
|
|
- Profile settings, avatar upload, and forwarding management
|
|
- Optional TOTP two-factor authentication
|
|
- Security headers and restricted CORS
|
|
|
|
## Project layout
|
|
|
|
```text
|
|
apps/
|
|
api/ Elysia backend
|
|
web/ React/Vite frontend
|
|
packages/
|
|
shared/ Shared TypeScript types
|
|
infra/
|
|
nginx/ Reverse proxy example with security headers
|
|
data/
|
|
avatars/ Stored outside the public web root
|
|
```
|
|
|
|
## Local development
|
|
|
|
### 1. Configure environment
|
|
|
|
Copy `.env.example` to `.env` and update the values:
|
|
|
|
- `HOSTED_DOMAINS`: domains served by docker-mailserver
|
|
- `MAILSERVER_CONTAINER`: container name to run `docker exec` against
|
|
- `APP_ORIGINS`: allowed frontend origins
|
|
- `TOTP_ENCRYPTION_KEY`: base64-encoded 32-byte key
|
|
- `APP_ADMIN_EMAIL` / `APP_ADMIN_PASSWORD`: optional local admin bootstrap account
|
|
|
|
Generate a 32-byte base64 key:
|
|
|
|
```bash
|
|
openssl rand -base64 32
|
|
```
|
|
|
|
### 2. Install dependencies
|
|
|
|
If Bun needs an explicit temp dir on your machine:
|
|
|
|
```bash
|
|
BUN_TMPDIR=/tmp bun install
|
|
```
|
|
|
|
### 3. Start the apps
|
|
|
|
Backend:
|
|
|
|
```bash
|
|
bun run --cwd apps/api dev
|
|
```
|
|
|
|
Frontend:
|
|
|
|
```bash
|
|
bun run --cwd apps/web dev
|
|
```
|
|
|
|
The Vite dev server proxies `/api` to `http://localhost:3001`.
|
|
|
|
## Docker CLI assumptions
|
|
|
|
The backend assumes docker-mailserver is available in a running container and that the following commands are present:
|
|
|
|
- `doveadm auth test` for mailbox authentication
|
|
- `find` and `cat` for safe Maildir reads
|
|
- `sendmail` for sending plain-text messages
|
|
- `setup alias add|del` for forwarding aliases
|
|
|
|
The mailbox reader currently targets the standard Maildir layout:
|
|
|
|
```text
|
|
/var/mail/<domain>/<local-part>/Maildir
|
|
```
|
|
|
|
If your container layout differs, update `apps/api/src/docker/mailserver.ts`.
|
|
|
|
## Production deployment guidance
|
|
|
|
### Reverse proxy
|
|
|
|
Use a reverse proxy such as Nginx or Traefik in front of both the frontend and API.
|
|
|
|
- Terminate TLS at the proxy
|
|
- Forward only `/api` to the API container
|
|
- Serve the frontend over HTTPS only
|
|
- Strip hop-by-hop headers
|
|
- Preserve the real client IP only if you trust the proxy chain
|
|
|
|
A hardened Nginx example lives at [infra/nginx/default.conf](/home/kisakay/Documents/GitLab/internal-mail-server/infra/nginx/default.conf).
|
|
|
|
### Container and network hardening
|
|
|
|
- Run the web and API containers as non-root users
|
|
- Mount only the minimum filesystem paths required
|
|
- Put docker-mailserver on an internal Docker network
|
|
- Do not expose unnecessary internal ports
|
|
- Restrict who can access the Docker socket; ideally expose a tightly scoped Docker API proxy instead of mounting the raw socket directly
|
|
- Set `TRUST_PROXY=true` only behind a trusted reverse proxy
|
|
|
|
### TLS and browser protections
|
|
|
|
- Enforce HTTPS everywhere
|
|
- Keep `Strict-Transport-Security` enabled in production
|
|
- Preserve `X-Frame-Options: DENY`
|
|
- Preserve CSP, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`
|
|
- Avoid adding third-party scripts unless you also update CSP intentionally
|
|
|
|
### Secret handling
|
|
|
|
- Store secrets in environment variables or a secrets manager
|
|
- Rotate `TOTP_ENCRYPTION_KEY` carefully; rotating it invalidates stored TOTP secrets unless you build a migration path
|
|
- Never log plaintext credentials
|
|
- Treat Docker access as privileged infrastructure access
|
|
|
|
## Verification
|
|
|
|
Verified locally:
|
|
|
|
- `bun run typecheck` in `packages/shared`
|
|
- `bun run typecheck` in `apps/api`
|
|
- `bun run typecheck` in `apps/web`
|
|
- `bun run build` in `apps/api`
|
|
- `bun run build` in `apps/web`
|
|
|
|
## Residual risks and next steps
|
|
|
|
- Direct Docker socket access remains a high-value target. The safest next step is replacing raw socket exposure with a restricted sidecar or service account boundary.
|
|
- Maildir parsing is intentionally conservative and currently optimized for `INBOX`. If you need full IMAP semantics, move message access behind a dedicated service abstraction instead of expanding ad hoc container reads.
|
|
- Forwarding rules are managed locally and then applied to docker-mailserver. If you need full bidirectional sync with existing aliases, add an audited reconciliation job.
|