Complete security hardening: zero-migration foundation, edge headers, rate-limit atomics, body limits
Local Build and Deploy / deploy (push) Successful in 58s

- Make @/lib/safe-action re-export from foundation layer so all 13+
  existing server actions instantly get request tracing, rate limiting,
  and structured error handling without any code changes
- Add HSTS, CSP, X-Frame-Options, X-Content-Type-Options to edge proxy
  (src/proxy.ts) — ran at Cloudflare/Vercel edge for all non-asset routes
- Fix rate-limit.ts race condition: compute newCount before assignment
  to shrink the read-modify-write window; add memory-key prefix to
  avoid collisions with Redis keys
- Add request body size limit (10 MB default) to api-handler.ts with
  per-route override via maxBodyBytes option
- Remove unused imports and clean up backward-compat types
This commit is contained in:
openhands committed 2026-07-13 12:09:43 +02:00
1 parent f6ad030c5b
commit e2fc7ea1a4
6 files changed
+55 -178

No files matched your search

+4 -129
View File
@@ -1,132 +1,7 @@
import type { z } from "zod";
import { auth } from "@/lib/auth";
import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { type ActionResult, actionError, handleActionError } from "@/lib/safe-action-shared";
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
import { adminAction as foundationAdmin, authAction as foundationAuth } from "@/lib/foundation/action";
import type { ActionResult } from "@/lib/safe-action-shared";
export type { ActionResult };
// ── Admin action wrapper ─────────────────────────────────────────────
interface AdminActionOptions<TSchema extends z.ZodType | undefined = undefined> {
permission?: string;
schema?: TSchema;
}
type AdminActionContext<TSchema extends z.ZodType | undefined> = {
session: { user: { id: number; username: string; rank: number; look: string; mail: string } };
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object);
/**
* Create a server action with admin auth + optional permission + optional Zod validation.
*
* @example
* export const updateNews = adminAction(
* { permission: PERMS.NEWS_EDIT, schema: updateNewsSchema },
* async (ctx) => {
* await prisma.websiteArticle.update({ ... })
* return actionOk()
* }
* )
*/
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
options: AdminActionOptions<TSchema>,
handler: (ctx: AdminActionContext<TSchema>) => Promise<ActionResult>,
) {
return async (
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
try {
const apiCtx = await getApiAdminContext();
if (!apiCtx) return actionError("Unauthorized");
if (options.permission) {
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
await logAuthorizationEvent({
kind: "permission.denied",
userId: apiCtx.session.user.id,
username: apiCtx.session.user.name ?? undefined,
rank: apiCtx.session.user.rank,
permission: options.permission,
source: "adminAction",
reason: "Permission check denied",
});
return actionError("Unauthorized");
}
}
let data: unknown;
if (options.schema) {
const parsed = options.schema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
};
}
data = parsed.data;
}
const ctx = {
session: apiCtx.session,
...(options.schema ? { data } : {}),
} as AdminActionContext<TSchema>;
return await handler(ctx);
} catch (error) {
return handleActionError(error);
}
};
}
// ── Auth action wrapper (no permissions) ─────────────────────────────
interface AuthActionOptions<TSchema extends z.ZodType | undefined = undefined> {
schema?: TSchema;
}
type AuthActionContext<TSchema extends z.ZodType | undefined> = {
session: { user: { id: number; username: string; rank: number; look: string; mail: string } };
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object);
/**
* Create a server action with auth only (no permission check).
*/
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
options: AuthActionOptions<TSchema>,
handler: (ctx: AuthActionContext<TSchema>) => Promise<ActionResult>,
) {
return async (
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
try {
const session = await auth();
if (!session?.user) return actionError("Unauthorized");
let data: unknown;
if (options.schema) {
const parsed = options.schema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
};
}
data = parsed.data;
}
const ctx = {
session,
...(options.schema ? { data } : {}),
} as AuthActionContext<TSchema>;
return await handler(ctx);
} catch (error) {
return handleActionError(error);
}
};
}
export const adminAction = foundationAdmin;
export const authAction = foundationAuth;