d
This commit is contained in:
@@ -25,7 +25,6 @@ const envSchema = z.object({
|
||||
MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
|
||||
HOSTED_DOMAINS: z.string().default("example.com"),
|
||||
FORWARDING_ALLOWED_DOMAINS: z.string().optional(),
|
||||
SESSION_TTL_MINUTES: z.coerce.number().int().min(5).max(1440).default(15),
|
||||
GLOBAL_RATE_LIMIT: z.coerce.number().int().min(10).max(5000).default(240),
|
||||
ROUTE_RATE_LIMIT: z.coerce.number().int().min(5).max(1000).default(90),
|
||||
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(10).max(3600).default(60),
|
||||
@@ -36,7 +35,8 @@ const envSchema = z.object({
|
||||
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
|
||||
APP_ADMIN_EMAIL: z.string().email().optional(),
|
||||
APP_ADMIN_PASSWORD: z.string().min(12).max(256).optional(),
|
||||
TOTP_ENCRYPTION_KEY: z.string().optional()
|
||||
TOTP_ENCRYPTION_KEY: z.string().optional(),
|
||||
ALLOW_INSECURE_LOGIN: z.enum(["true", "false"]).default("false")
|
||||
});
|
||||
|
||||
const raw = envSchema.parse(normalizedEnv);
|
||||
@@ -58,9 +58,6 @@ export const config = {
|
||||
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS,
|
||||
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"),
|
||||
forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS),
|
||||
sessionTtlMs: raw.SESSION_TTL_MINUTES * 60_000,
|
||||
cookieName: raw.NODE_ENV === "production" ? "__Host-webmail_session" : "webmail_session",
|
||||
secureCookies: raw.NODE_ENV === "production",
|
||||
globalRateLimit: raw.GLOBAL_RATE_LIMIT,
|
||||
routeRateLimit: raw.ROUTE_RATE_LIMIT,
|
||||
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000,
|
||||
@@ -71,5 +68,6 @@ export const config = {
|
||||
trustProxy: raw.TRUST_PROXY === "true",
|
||||
adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null,
|
||||
adminPassword: raw.APP_ADMIN_PASSWORD ?? null,
|
||||
totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null
|
||||
totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null,
|
||||
allowInsecureLogin: raw.ALLOW_INSECURE_LOGIN === "true"
|
||||
} as const;
|
||||
|
||||
@@ -15,18 +15,6 @@ export type ProfileRow = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type SessionRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
csrf_token: string;
|
||||
expires_at: number;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
user_agent_hash: string;
|
||||
ip_hash: string;
|
||||
};
|
||||
|
||||
type AdminRow = {
|
||||
email: string;
|
||||
password_hash: string;
|
||||
@@ -66,18 +54,6 @@ db.exec(`
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
|
||||
csrf_token TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
user_agent_hash TEXT NOT NULL,
|
||||
ip_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS forwarding_rules (
|
||||
email TEXT PRIMARY KEY,
|
||||
target_email TEXT,
|
||||
@@ -124,18 +100,6 @@ const upsertAdminStatement = db.query(`
|
||||
VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at
|
||||
`);
|
||||
const insertSessionStatement = db.query(`
|
||||
INSERT INTO sessions (id, email, role, csrf_token, expires_at, created_at, last_seen_at, user_agent_hash, ip_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8)
|
||||
`);
|
||||
const getSessionStatement = db.query("SELECT * FROM sessions WHERE id = ?1");
|
||||
const touchSessionStatement = db.query(`
|
||||
UPDATE sessions
|
||||
SET expires_at = ?1, last_seen_at = ?2, ip_hash = ?3
|
||||
WHERE id = ?4
|
||||
`);
|
||||
const deleteSessionStatement = db.query("DELETE FROM sessions WHERE id = ?1");
|
||||
const deleteExpiredSessionsStatement = db.query("DELETE FROM sessions WHERE expires_at <= ?1");
|
||||
const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1");
|
||||
const upsertForwardingStatement = db.query(`
|
||||
INSERT INTO forwarding_rules (email, target_email, enabled, updated_at)
|
||||
@@ -173,33 +137,6 @@ export const upsertAdmin = (email: string, passwordHash: string): void => {
|
||||
ensureProfile(email, "admin");
|
||||
};
|
||||
|
||||
export const createSession = (session: SessionRow): void => {
|
||||
insertSessionStatement.run(
|
||||
session.id,
|
||||
session.email,
|
||||
session.role,
|
||||
session.csrf_token,
|
||||
session.expires_at,
|
||||
session.created_at,
|
||||
session.user_agent_hash,
|
||||
session.ip_hash
|
||||
);
|
||||
};
|
||||
|
||||
export const getSession = (id: string): SessionRow | null => (getSessionStatement.get(id) as SessionRow | null) ?? null;
|
||||
|
||||
export const touchSession = (id: string, expiresAt: number, ipHash: string): void => {
|
||||
touchSessionStatement.run(expiresAt, Date.now(), ipHash, id);
|
||||
};
|
||||
|
||||
export const deleteSession = (id: string): void => {
|
||||
deleteSessionStatement.run(id);
|
||||
};
|
||||
|
||||
export const deleteExpiredSessions = (): void => {
|
||||
deleteExpiredSessionsStatement.run(Date.now());
|
||||
};
|
||||
|
||||
export const getForwardingRule = (email: string): ForwardingRow | null =>
|
||||
(getForwardingStatement.get(email) as ForwardingRow | null) ?? null;
|
||||
|
||||
@@ -225,4 +162,3 @@ export const deleteAvatarFile = async (avatarPath: string | null): Promise<void>
|
||||
// Best-effort cleanup of replaced avatars.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -36,61 +36,6 @@ export const globalLimiter = new SlidingWindowLimiter();
|
||||
export const routeLimiter = new SlidingWindowLimiter();
|
||||
export const loginLimiter = new SlidingWindowLimiter();
|
||||
|
||||
export const parseCookies = (cookieHeader: string | null): Record<string, string> => {
|
||||
if (!cookieHeader) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cookieHeader
|
||||
.split(";")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.reduce<Record<string, string>>((accumulator, entry) => {
|
||||
const separatorIndex = entry.indexOf("=");
|
||||
if (separatorIndex === -1) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const key = entry.slice(0, separatorIndex).trim();
|
||||
const value = entry.slice(separatorIndex + 1).trim();
|
||||
accumulator[key] = decodeURIComponent(value);
|
||||
return accumulator;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const serializeSessionCookie = (value: string): string => {
|
||||
const pieces = [
|
||||
`${config.cookieName}=${encodeURIComponent(value)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
`Max-Age=${Math.floor(config.sessionTtlMs / 1000)}`
|
||||
];
|
||||
|
||||
if (config.secureCookies) {
|
||||
pieces.push("Secure");
|
||||
}
|
||||
|
||||
return pieces.join("; ");
|
||||
};
|
||||
|
||||
export const serializeExpiredCookie = (): string => {
|
||||
const pieces = [
|
||||
`${config.cookieName}=`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
"Max-Age=0"
|
||||
];
|
||||
|
||||
if (config.secureCookies) {
|
||||
pieces.push("Secure");
|
||||
}
|
||||
|
||||
return pieces.join("; ");
|
||||
};
|
||||
|
||||
export const getClientIp = (request: Request): string => {
|
||||
if (config.trustProxy) {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
@@ -118,13 +63,6 @@ export const assertAllowedOrigin = (request: Request): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export const assertCsrf = (request: Request, expectedToken: string): void => {
|
||||
const csrfToken = request.headers.get("x-csrf-token");
|
||||
if (!csrfToken || csrfToken !== expectedToken) {
|
||||
throw new HttpError(403, "CSRF validation failed.");
|
||||
}
|
||||
};
|
||||
|
||||
export const buildResponseHeaders = (request: Request, init?: HeadersInit): Headers => {
|
||||
const headers = new Headers(init);
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
@@ -153,13 +91,11 @@ export const buildResponseHeaders = (request: Request, init?: HeadersInit): Head
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && config.allowedOrigins.has(origin.toLowerCase())) {
|
||||
headers.set("Access-Control-Allow-Origin", origin);
|
||||
headers.set("Access-Control-Allow-Credentials", "true");
|
||||
headers.set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token");
|
||||
headers.set("Access-Control-Allow-Headers", "Content-Type, X-User-Email");
|
||||
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
|
||||
headers.append("Vary", "Origin");
|
||||
}
|
||||
|
||||
headers.append("Vary", "Cookie");
|
||||
return headers;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user