.
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user