This commit is contained in:
Debian
2026-03-30 14:32:24 +00:00
parent 87198824b6
commit 59310b0b9b
6 changed files with 161 additions and 21 deletions
+1 -1
View File
@@ -12,5 +12,5 @@ HOSTED_DOMAINS=example.com
SESSION_COOKIE_NAME=mail_admin_session
SESSION_TTL_MINUTES=480
RESET_TOKEN_TTL_MINUTES=60
APP_ADMIN_EMAIL=panel-admin@local
APP_ADMIN_EMAIL=admin@local
APP_ADMIN_ACCESS_KEY=change-this-access-key
+10 -9
View File
@@ -16,7 +16,7 @@ Why this backend:
- Bun keeps the runtime, package manager, and SQLite support simple.
- ElysiaJS is a good fit for a small internal API without extra framework weight.
- SQLite is enough for admin sessions and password reset tokens, and keeps deployment straightforward.
- SQLite is enough for admin sessions and a small amount of panel metadata, and keeps deployment straightforward.
- Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers.
### Frontend
@@ -67,9 +67,9 @@ The backend is intentionally flat:
- `apps/api/src/docker/`
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
- `apps/api/src/users/service.ts`
Contains the mailbox user operations and password reset flow.
Contains the mailbox user operations and the one-time link flow for password changes.
- `apps/api/src/mail/service.ts`
Sends reset emails through `sendmail` inside the Docker Mailserver container.
Sends setup emails for newly created mailboxes through `sendmail` inside the Docker Mailserver container.
- `apps/api/src/lib/`
Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities.
@@ -80,7 +80,6 @@ The backend is intentionally flat:
- `POST /api/auth/logout`
- `GET /api/users`
- `POST /api/users`
- `PATCH /api/users/:email/password`
- `DELETE /api/users/:email`
- `POST /api/users/:email/password-reset`
- `POST /api/password-resets/inspect`
@@ -99,9 +98,9 @@ The dashboard includes:
- mailbox listing
- simple search
- create user modal
- edit password modal
- temporary link generation for password changes
- delete action
- send reset action
- no direct password change from the admin panel
The create flow intentionally generates a temporary password on the server and immediately emails a one-time reset link to the user. That means the admin panel never needs to expose an initial mailbox password in the UI.
@@ -109,10 +108,10 @@ The create flow intentionally generates a temporary password on the server and i
The reset flow is built to stay secure without becoming heavy:
1. An admin triggers a reset for an existing mailbox.
1. An admin generates a one-time link for an existing mailbox.
2. The backend generates a random opaque token.
3. Only the SHA-256 hash of that token is stored in SQLite.
4. The user receives an email with the one-time reset link.
4. The admin shares the one-time link manually with the user.
5. The reset page validates the token before showing the password form.
6. On submit, the token is claimed for one use only.
7. The backend updates the mailbox password through Docker Mailserver.
@@ -140,12 +139,14 @@ Security choices here:
- Admin authentication uses an Argon2id-hashed bootstrap account from environment variables.
- Panel login uses a single access key instead of an email/password pair.
- Access key login is protected by an intentionally aggressive in-memory rate limit with escalating lockouts.
- Sessions are server-side, opaque, and stored in an `HttpOnly` cookie with `SameSite=Strict`.
- Password reset tokens are random, one-time, time-limited, and stored only as hashes.
- The admin panel never changes mailbox passwords directly; it only generates one-time links.
- Admin-only routes require a valid session cookie.
- Validation is applied to logins, emails, passwords, and reset payloads.
- Allowed origins are restricted by `APP_ORIGINS`.
- Reset emails are sent from the backend only. No secrets are exposed to the frontend.
- Setup emails for new accounts are sent from the backend only. No secrets are exposed to the frontend.
- Docker Mailserver access is treated as privileged infrastructure access.
## 8. Run locally
+12
View File
@@ -3,7 +3,9 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se
import { cleanupDatabase } from "./lib/db";
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
import { config } from "./lib/config";
import { HttpError } from "./lib/errors";
import { serveFrontendBuild } from "./lib/frontend";
import { clearAccessKeyLoginRateLimit, enforceAggressiveLoginRateLimit, recordFailedAccessKeyLogin } from "./lib/security";
import {
createUserSchema,
loginSchema,
@@ -65,8 +67,12 @@ app.get(
app.post(
"/api/auth/login",
route(async ({ request }) => {
enforceAggressiveLoginRateLimit(request);
try {
const body = await parseJson(request, loginSchema);
const result = await loginAdmin(body.accessKey);
clearAccessKeyLoginRateLimit(request);
return jsonResponse(
request,
@@ -74,6 +80,12 @@ app.post(
{ session: result.session },
{ "set-cookie": result.cookie }
);
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
recordFailedAccessKeyLogin(request);
}
throw error;
}
})
);
+3 -1
View File
@@ -1,8 +1,10 @@
export class HttpError extends Error {
readonly status: number;
readonly headers?: HeadersInit;
constructor(status: number, message: string) {
constructor(status: number, message: string, headers?: HeadersInit) {
super(message);
this.status = status;
this.headers = headers;
}
}
+1 -2
View File
@@ -96,7 +96,7 @@ export const errorResponse = (request: Request, error: unknown): Response => {
error: {
message: error.message
}
});
}, error.headers);
}
if (error instanceof ZodError) {
@@ -115,4 +115,3 @@ export const errorResponse = (request: Request, error: unknown): Response => {
}
});
};
+126
View File
@@ -1,4 +1,102 @@
import { createHash, randomBytes } from "node:crypto";
import { HttpError } from "./errors";
type LoginRateLimitState = {
blockedUntil: number;
lastSeenAt: number;
recentAttempts: number[];
failedAttempts: number[];
};
const loginRateLimitStates = new Map<string, LoginRateLimitState>();
const LOGIN_BURST_WINDOW_MS = 15 * 60_000;
const LOGIN_BURST_LIMIT = 1;
const LOGIN_FAILURE_TRACK_MS = 365 * 24 * 60 * 60_000;
const LOGIN_STATE_TTL_MS = 400 * 24 * 60 * 60_000;
const getRetryAfterSeconds = (blockedUntil: number): number =>
Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000));
const getClientIp = (request: Request): string => {
const forwardedFor = request.headers.get("x-forwarded-for");
if (forwardedFor) {
const first = forwardedFor.split(",")[0]?.trim();
if (first) {
return first;
}
}
const realIp = request.headers.get("x-real-ip")?.trim();
if (realIp) {
return realIp;
}
return "unknown";
};
const getLoginRateLimitKey = (request: Request): string =>
getClientIp(request);
const pruneLoginRateLimitState = (state: LoginRateLimitState, now: number): void => {
state.recentAttempts = state.recentAttempts.filter((timestamp) => now - timestamp <= LOGIN_BURST_WINDOW_MS);
state.failedAttempts = state.failedAttempts.filter((timestamp) => now - timestamp <= LOGIN_FAILURE_TRACK_MS);
};
const getLoginRateLimitState = (key: string, now: number): LoginRateLimitState => {
for (const [stateKey, state] of loginRateLimitStates.entries()) {
if (now - state.lastSeenAt > LOGIN_STATE_TTL_MS && now >= state.blockedUntil) {
loginRateLimitStates.delete(stateKey);
}
}
const existing = loginRateLimitStates.get(key);
if (existing) {
existing.lastSeenAt = now;
pruneLoginRateLimitState(existing, now);
return existing;
}
const nextState: LoginRateLimitState = {
blockedUntil: 0,
lastSeenAt: now,
recentAttempts: [],
failedAttempts: []
};
loginRateLimitStates.set(key, nextState);
return nextState;
};
const createRateLimitError = (blockedUntil: number): HttpError =>
new HttpError(
429,
"Too many access key login attempts. Login is locked for this client.",
{
"Retry-After": String(getRetryAfterSeconds(blockedUntil))
}
);
const applyLoginFailurePenalty = (state: LoginRateLimitState, now: number): void => {
const failureCount = state.failedAttempts.length;
let blockMs = 0;
if (failureCount >= 5) {
blockMs = 365 * 24 * 60 * 60_000;
} else if (failureCount >= 4) {
blockMs = 180 * 24 * 60 * 60_000;
} else if (failureCount >= 3) {
blockMs = 30 * 24 * 60 * 60_000;
} else if (failureCount >= 2) {
blockMs = 7 * 24 * 60 * 60_000;
} else if (failureCount >= 1) {
blockMs = 24 * 60 * 60_000;
}
if (blockMs > 0) {
state.blockedUntil = Math.max(state.blockedUntil, now + blockMs);
}
};
export const hashToken = (value: string): string =>
createHash("sha256").update(value).digest("hex");
@@ -54,3 +152,31 @@ export const readCookie = (request: Request, name: string): string | null => {
export const sanitizeHeaderValue = (value: string): string =>
value.replace(/[\r\n]+/g, " ").trim();
export const enforceAggressiveLoginRateLimit = (request: Request): void => {
const now = Date.now();
const key = getLoginRateLimitKey(request);
const state = getLoginRateLimitState(key, now);
if (now < state.blockedUntil) {
throw createRateLimitError(state.blockedUntil);
}
state.recentAttempts.push(now);
if (state.recentAttempts.length > LOGIN_BURST_LIMIT) {
state.blockedUntil = now + 24 * 60 * 60_000;
throw createRateLimitError(state.blockedUntil);
}
};
export const recordFailedAccessKeyLogin = (request: Request): void => {
const now = Date.now();
const key = getLoginRateLimitKey(request);
const state = getLoginRateLimitState(key, now);
state.failedAttempts.push(now);
applyLoginFailurePenalty(state, now);
};
export const clearAccessKeyLoginRateLimit = (request: Request): void => {
loginRateLimitStates.delete(getLoginRateLimitKey(request));
};