Add EpicNext CMS foundation layer and fix critical security gaps
- Create src/lib/foundation/ (860 LOC, 9 files): typed action wrappers, DbService with health checks, CSRF validation, safe redirects, AsyncLocalStorage request tracing, branded types, reusable Zod schemas - Migrate moderation.ts and user-settings.ts to foundation patterns - Fix abuse-guard.ts: bound in-memory Maps with LRU eviction (was unbounded) - Fix access-guard.ts: separate try/catch per check, log degradation instead of blanket fail-open - Replace raw redirect() calls with safeRedirect() in guard.ts and permissions.ts to prevent open-redirect attacks - Add CSRF validation to api-handler.ts for mutating methods - Add canonicalizeFormData() utility for FormData input sanitization
This commit is contained in:
1 parent
8bf1aa2fa7
commit
f6ad030c5b
16 files changed
+1012
-96
No files matched your search
@@ -0,0 +1,182 @@
|
||||
import type { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { getRequestId, runWithStore, createStore, setContextUserId } from "./request-context";
|
||||
import { NotFoundError, UnauthorizedError, ForbiddenError, ValidationError, RateLimitError, DatabaseError } from "./errors";
|
||||
import type { ActionResult, ActionSuccess, ActionFailure, AppSession, AdminActionContext, IpAddress, RequestId } from "./types";
|
||||
import { extractClientIpAsync } from "./security";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function ok<T = Record<string, unknown>>(data?: T): ActionSuccess<T> {
|
||||
return { ok: true, data: (data ?? {}) as T } as unknown as ActionSuccess<T>;
|
||||
}
|
||||
|
||||
function fail(error: string, fieldErrors?: Record<string, string[]>): ActionFailure {
|
||||
return { ok: false, error, fieldErrors };
|
||||
}
|
||||
|
||||
export { ok as actionOk, fail as actionError };
|
||||
|
||||
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
||||
permission?: string;
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
}
|
||||
|
||||
type ActionHandler<TSchema extends z.ZodType | undefined> = (
|
||||
ctx: AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>),
|
||||
) => Promise<ActionResult>;
|
||||
|
||||
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AdminOpts<TSchema>,
|
||||
handler: ActionHandler<TSchema>,
|
||||
) {
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const apiCtx = await getApiAdminContext();
|
||||
if (!apiCtx) return fail("Unauthorized");
|
||||
|
||||
setContextUserId(Number(apiCtx.session.user.id) as never);
|
||||
|
||||
if (opts.permission) {
|
||||
if (!canAccess(apiCtx.permissions, opts.permission, apiCtx.session.user.rank)) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.denied",
|
||||
userId: Number(apiCtx.session.user.id),
|
||||
username: apiCtx.session.user.name ?? undefined,
|
||||
rank: apiCtx.session.user.rank,
|
||||
permission: opts.permission,
|
||||
source: "adminAction",
|
||||
reason: "Permission check denied",
|
||||
});
|
||||
return fail("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs);
|
||||
if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail("Validation failed", parsed.error.flatten().fieldErrors as Record<string, string[]>);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session: apiCtx.session,
|
||||
permissions: apiCtx.permissions,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
...(opts.schema ? { data: data as z.infer<NonNullable<TSchema>> } : {}),
|
||||
} as AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthOpts<TSchema extends z.ZodType | undefined> {
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
}
|
||||
|
||||
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AuthOpts<TSchema>,
|
||||
handler: (ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>)) => Promise<ActionResult>,
|
||||
) {
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) return fail("Unauthorized");
|
||||
|
||||
setContextUserId(Number(session.user.id) as never);
|
||||
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs);
|
||||
if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail("Validation failed", parsed.error.flatten().fieldErrors as Record<string, string[]>);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session: session as unknown as AppSession,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
} as { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
|
||||
if (opts.schema) {
|
||||
(ctx as Record<string, unknown>).data = data as z.infer<NonNullable<TSchema>>;
|
||||
}
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleActionError(error: unknown): ActionFailure {
|
||||
if (error instanceof ValidationError) {
|
||||
return fail(error.message, error.fieldErrors);
|
||||
}
|
||||
if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof RateLimitError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return fail("A database error occurred");
|
||||
}
|
||||
if (error instanceof Error && error.name === "ZodError") {
|
||||
return fail("Validation failed");
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" || error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return fail("Not found");
|
||||
}
|
||||
|
||||
console.error("[Action error]", error instanceof Error ? { message: error.message, name: error.name } : error);
|
||||
return fail("Internal server error");
|
||||
}
|
||||
Reference in new issue
Block a user