Harden admin ACL on critical write paths.
Local Build and Deploy / deploy (push) Successful in 54s

Gate translations, RCON, and user mutations on SETTINGS_EDIT, RCON_EXECUTE, and USERS_EDIT instead of dashboard/rank checks; redirect the legacy user-edit URL to the guarded canonical page.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 20:07:15 +02:00
1 parent bae543baf6
commit f2427b3483
8 files changed
+127 -284

No files matched your search

+6 -5
View File
@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -28,8 +29,8 @@ function toInt(value: FormDataEntryValue | null, min = 0): number | null {
* user from the session and logs the action. emulator-owned users.id is Int.
*/
export async function updateUser(formData: FormData): Promise<void> {
// Never trust the client: re-check staff inside the action.
const staff = await requireStaff();
// Never trust the client: re-check USERS_EDIT inside the action.
const staff = await requirePermission(PERMS.USERS_EDIT);
const userId = Number(formData.get("id"));
if (!Number.isInteger(userId) || userId <= 0) return;
@@ -95,6 +96,6 @@ export async function updateUser(formData: FormData): Promise<void> {
});
revalidatePath(`/admin/users/${userId}`);
revalidatePath(`/admin/users/${userId}/edit`);
redirect(`/admin/users/${userId}`);
revalidatePath(`/admin/users/edit/${userId}`);
redirect(`/admin/users/show/${userId}`);
}
+21 -16
View File
@@ -1,14 +1,19 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
async function requireRcon(): Promise<void> {
await requirePermission(PERMS.RCON_EXECUTE);
}
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export async function updateCatalog(): Promise<void> {
await requireStaff();
await requireRcon();
try {
await rcon.updateCatalog();
} catch {
@@ -19,7 +24,7 @@ export async function updateCatalog(): Promise<void> {
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export async function updateWordFilter(): Promise<void> {
await requireStaff();
await requireRcon();
try {
await rcon.updateWordFilter();
} catch {
@@ -30,7 +35,7 @@ export async function updateWordFilter(): Promise<void> {
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export async function updateNavigator(): Promise<void> {
await requireStaff();
await requireRcon();
try {
await rcon.send("updatenavigator", null);
} catch {
@@ -41,7 +46,7 @@ export async function updateNavigator(): Promise<void> {
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export async function hotelAlert(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const message = String(formData.get("message") ?? "")
.normalize("NFC")
.trim()
@@ -57,7 +62,7 @@ export async function hotelAlert(formData: FormData): Promise<void> {
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
export async function disconnectUser(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const username = String(formData.get("username") ?? "")
.normalize("NFC")
@@ -73,7 +78,7 @@ export async function disconnectUser(formData: FormData): Promise<void> {
/** Send an alert to a specific user (rcon: alertuser). */
export async function alertUser(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const message = String(formData.get("message") ?? "")
.normalize("NFC")
@@ -90,7 +95,7 @@ export async function alertUser(formData: FormData): Promise<void> {
/** Forward a user to a specific room (rcon: forwarduser). */
export async function forwardUser(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const roomId = Number(formData.get("roomId"));
if (!userId || !roomId) return;
@@ -104,7 +109,7 @@ export async function forwardUser(formData: FormData): Promise<void> {
/** Give credits to a user (rcon: givecredits). */
export async function giveCredits(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const credits = Number(formData.get("credits"));
if (!userId || !credits || credits <= 0) return;
@@ -118,7 +123,7 @@ export async function giveCredits(formData: FormData): Promise<void> {
/** Give duckets to a user (rcon: givepoints type=duckets). */
export async function giveDuckets(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return;
@@ -132,7 +137,7 @@ export async function giveDuckets(formData: FormData): Promise<void> {
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
export async function giveDiamonds(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const amount = Number(formData.get("amount"));
if (!userId || !amount || amount <= 0) return;
@@ -146,7 +151,7 @@ export async function giveDiamonds(formData: FormData): Promise<void> {
/** Give a badge to a user (rcon: givebadge). */
export async function giveBadge(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
@@ -162,7 +167,7 @@ export async function giveBadge(formData: FormData): Promise<void> {
/** Set a user's motto (rcon: setmotto). */
export async function setMotto(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const motto = String(formData.get("motto") ?? "")
.normalize("NFC")
@@ -179,7 +184,7 @@ export async function setMotto(formData: FormData): Promise<void> {
/** Set a user's rank (rcon: setrank). */
export async function setRank(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const rank = Number(formData.get("rank"));
if (!userId || rank < 0 || rank > 10) return;
@@ -193,7 +198,7 @@ export async function setRank(formData: FormData): Promise<void> {
/** Execute a command as a user (rcon: executecommand). */
export async function executeCommand(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const command = String(formData.get("command") ?? "")
.normalize("NFC")
@@ -209,7 +214,7 @@ export async function executeCommand(formData: FormData): Promise<void> {
/** Send a gift to a user (rcon: sendgift). */
export async function sendGift(formData: FormData): Promise<void> {
await requireStaff();
await requireRcon();
const userId = Number(formData.get("userId"));
const itemId = Number(formData.get("itemId"));
const message = String(formData.get("message") ?? "Here is a gift.")
+3 -6
View File
@@ -9,6 +9,7 @@ import {
getClientTranslationFile,
} from "@/lib/client-translation-files";
import { patchJson5 } from "@/lib/json5-patch";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -41,10 +42,8 @@ const saveTranslationsSchema = z.object({
});
export const saveTranslations = adminAction(
{ schema: saveTranslationsSchema },
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const filePath = path.join(
process.cwd(),
"messages",
@@ -68,10 +67,8 @@ const saveClientTranslationsSchema = z.object({
});
export const saveClientTranslations = adminAction(
{ schema: saveClientTranslationsSchema },
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
async (ctx) => {
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
const file = getClientTranslationFile(ctx.data.fileId);
if (!file) throw new ActionError("Unknown file");
if (file.readOnly) throw new ActionError("File is read-only");