.
This commit is contained in:
+1
-1
@@ -12,5 +12,5 @@ HOSTED_DOMAINS=example.com
|
|||||||
SESSION_COOKIE_NAME=mail_admin_session
|
SESSION_COOKIE_NAME=mail_admin_session
|
||||||
SESSION_TTL_MINUTES=480
|
SESSION_TTL_MINUTES=480
|
||||||
RESET_TOKEN_TTL_MINUTES=60
|
RESET_TOKEN_TTL_MINUTES=60
|
||||||
APP_ADMIN_EMAIL=panel-admin@local
|
APP_ADMIN_EMAIL=admin@local
|
||||||
APP_ADMIN_ACCESS_KEY=change-this-access-key
|
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.
|
- Bun keeps the runtime, package manager, and SQLite support simple.
|
||||||
- ElysiaJS is a good fit for a small internal API without extra framework weight.
|
- 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.
|
- Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
@@ -67,9 +67,9 @@ The backend is intentionally flat:
|
|||||||
- `apps/api/src/docker/`
|
- `apps/api/src/docker/`
|
||||||
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
|
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
|
||||||
- `apps/api/src/users/service.ts`
|
- `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`
|
- `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/`
|
- `apps/api/src/lib/`
|
||||||
Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities.
|
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`
|
- `POST /api/auth/logout`
|
||||||
- `GET /api/users`
|
- `GET /api/users`
|
||||||
- `POST /api/users`
|
- `POST /api/users`
|
||||||
- `PATCH /api/users/:email/password`
|
|
||||||
- `DELETE /api/users/:email`
|
- `DELETE /api/users/:email`
|
||||||
- `POST /api/users/:email/password-reset`
|
- `POST /api/users/:email/password-reset`
|
||||||
- `POST /api/password-resets/inspect`
|
- `POST /api/password-resets/inspect`
|
||||||
@@ -99,9 +98,9 @@ The dashboard includes:
|
|||||||
- mailbox listing
|
- mailbox listing
|
||||||
- simple search
|
- simple search
|
||||||
- create user modal
|
- create user modal
|
||||||
- edit password modal
|
- temporary link generation for password changes
|
||||||
- delete action
|
- 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.
|
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:
|
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.
|
2. The backend generates a random opaque token.
|
||||||
3. Only the SHA-256 hash of that token is stored in SQLite.
|
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.
|
5. The reset page validates the token before showing the password form.
|
||||||
6. On submit, the token is claimed for one use only.
|
6. On submit, the token is claimed for one use only.
|
||||||
7. The backend updates the mailbox password through Docker Mailserver.
|
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.
|
- Admin authentication uses an Argon2id-hashed bootstrap account from environment variables.
|
||||||
- Panel login uses a single access key instead of an email/password pair.
|
- 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`.
|
- 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.
|
- 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.
|
- Admin-only routes require a valid session cookie.
|
||||||
- Validation is applied to logins, emails, passwords, and reset payloads.
|
- Validation is applied to logins, emails, passwords, and reset payloads.
|
||||||
- Allowed origins are restricted by `APP_ORIGINS`.
|
- 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.
|
- Docker Mailserver access is treated as privileged infrastructure access.
|
||||||
|
|
||||||
## 8. Run locally
|
## 8. Run locally
|
||||||
|
|||||||
+20
-8
@@ -3,7 +3,9 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se
|
|||||||
import { cleanupDatabase } from "./lib/db";
|
import { cleanupDatabase } from "./lib/db";
|
||||||
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
||||||
import { config } from "./lib/config";
|
import { config } from "./lib/config";
|
||||||
|
import { HttpError } from "./lib/errors";
|
||||||
import { serveFrontendBuild } from "./lib/frontend";
|
import { serveFrontendBuild } from "./lib/frontend";
|
||||||
|
import { clearAccessKeyLoginRateLimit, enforceAggressiveLoginRateLimit, recordFailedAccessKeyLogin } from "./lib/security";
|
||||||
import {
|
import {
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
@@ -65,15 +67,25 @@ app.get(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
route(async ({ request }) => {
|
route(async ({ request }) => {
|
||||||
const body = await parseJson(request, loginSchema);
|
enforceAggressiveLoginRateLimit(request);
|
||||||
const result = await loginAdmin(body.accessKey);
|
|
||||||
|
|
||||||
return jsonResponse(
|
try {
|
||||||
request,
|
const body = await parseJson(request, loginSchema);
|
||||||
200,
|
const result = await loginAdmin(body.accessKey);
|
||||||
{ session: result.session },
|
clearAccessKeyLoginRateLimit(request);
|
||||||
{ "set-cookie": result.cookie }
|
|
||||||
);
|
return jsonResponse(
|
||||||
|
request,
|
||||||
|
200,
|
||||||
|
{ 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 {
|
export class HttpError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
|
readonly headers?: HeadersInit;
|
||||||
|
|
||||||
constructor(status: number, message: string) {
|
constructor(status: number, message: string, headers?: HeadersInit) {
|
||||||
super(message);
|
super(message);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.headers = headers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const errorResponse = (request: Request, error: unknown): Response => {
|
|||||||
error: {
|
error: {
|
||||||
message: error.message
|
message: error.message
|
||||||
}
|
}
|
||||||
});
|
}, error.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ZodError) {
|
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 { 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 =>
|
export const hashToken = (value: string): string =>
|
||||||
createHash("sha256").update(value).digest("hex");
|
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 =>
|
export const sanitizeHeaderValue = (value: string): string =>
|
||||||
value.replace(/[\r\n]+/g, " ").trim();
|
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