fix sanctions and admin role escalation

This commit is contained in:
2026-07-26 18:11:01 +00:00
parent 3fb75ccf03
commit ab6ed3553a
13 changed files with 591 additions and 51 deletions
+7
View File
@@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { isAfter, isValid, parseISO } from 'date-fns';
import { z } from 'zod';
@@ -58,6 +59,12 @@ export const authRouter = router({
message: 'Invalid gateway token.',
});
}
if (isGameAccessBlocked(payload.sanctions, [ctx.profile.name, ctx.profile.id])) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Game access is restricted for this account.',
});
}
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id);
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
throw new TRPCError({
+2 -28
View File
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { authedProcedure, router } from '../../trpc.js';
import {
@@ -47,35 +48,8 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
});
};
const isFutureDate = (value: string | undefined, now = Date.now()): boolean => {
if (!value) {
return false;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) && parsed > now;
};
const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => {
if (
isFutureDate(sanctions.mutedUntil) ||
isFutureDate(sanctions.suspendedUntil) ||
isFutureDate(sanctions.bannedUntil)
) {
return true;
}
for (const profileName of profileNames) {
const restriction = sanctions.serverRestrictions?.[profileName];
if (!restriction) {
continue;
}
if (restriction.until && !isFutureDate(restriction.until)) {
continue;
}
if (restriction.blockedFeatures?.includes('messages')) {
return true;
}
}
return false;
return isMessageAccessBlocked(sanctions, profileNames);
};
const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => {
+8
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { initTRPC, TRPCError } from '@trpc/server';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GameApiContext } from './context.js';
import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
@@ -14,6 +15,13 @@ const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
message: 'Unauthorized',
});
}
const profileNames = ctx.profile ? [ctx.profile.name, ctx.profile.id] : [];
if (isGameAccessBlocked(ctx.auth.sanctions, profileNames)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Game access is restricted for this account.',
});
}
return next({
ctx: {
...ctx,
+25
View File
@@ -358,6 +358,31 @@ describe('messages router missing-flow compatibility', () => {
});
});
it.each(['message', 'messages'])('blocks sends for the profile feature alias %s', async (feature) => {
const restrictedAuth = {
...auth,
sanctions: {
serverRestrictions: {
'che:default': {
blockedFeatures: [feature],
},
},
},
};
const { caller } = buildContext({}, { auth: restrictedAuth });
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 9999,
text: 'profile restriction',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
});
});
it('rejects every remaining general-scoped message mutation for another user general', async () => {
const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow;
const { caller } = buildContext({
+74 -14
View File
@@ -23,6 +23,21 @@ const profile: GameProfile = {
name: 'che:default',
};
const buildAuth = (sanctions: GameSessionTokenPayload['sanctions'] = {}): GameSessionTokenPayload => ({
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
sessionId: 'session-1',
user: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: ['admin'],
},
sanctions,
});
const buildGeneralRow = (overrides?: Partial<GeneralRow>): GeneralRow => {
const base: GeneralRow = {
id: 1,
@@ -137,20 +152,7 @@ const buildContext = (options?: {
},
profile.name
);
const defaultAuth: GameSessionTokenPayload = {
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
sessionId: 'session-1',
user: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: ['admin'],
},
sanctions: {},
};
const defaultAuth = buildAuth();
const auth = options && 'auth' in options ? (options.auth ?? null) : defaultAuth;
return {
db: db as unknown as DatabaseClient,
@@ -169,6 +171,64 @@ const buildContext = (options?: {
};
describe('appRouter', () => {
const blockedGameAccessCases: Array<{
label: string;
sanctions: GameSessionTokenPayload['sanctions'];
}> = [
{
label: 'global suspension',
sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
},
{
label: 'instance gameplay restriction',
sanctions: {
serverRestrictions: {
'che:default': { blockedFeatures: ['game'] },
},
},
},
{
label: 'base-profile wildcard restriction',
sanctions: {
serverRestrictions: {
che: { blockedFeatures: ['*'], until: '2099-01-01T00:00:00.000Z' },
},
},
},
];
it.each(blockedGameAccessCases)('blocks authenticated game API access for $label', async ({ sanctions }) => {
const caller = appRouter.createCaller(
buildContext({
auth: buildAuth(sanctions),
general: buildGeneralRow({ id: 11 }),
})
);
await expect(caller.turns.reserved.getGeneral({ generalId: 11 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('does not apply an expired or message-only restriction to other game APIs', async () => {
const caller = appRouter.createCaller(
buildContext({
auth: buildAuth({
mutedUntil: '2099-01-01T00:00:00.000Z',
serverRestrictions: {
'che:default': {
blockedFeatures: ['messages'],
until: '2000-01-01T00:00:00.000Z',
},
},
}),
general: buildGeneralRow({ id: 11 }),
})
);
await expect(caller.turns.reserved.getGeneral({ generalId: 11 })).resolves.toBeDefined();
});
it('rejects general creation before any game-state read when the signed identity gate denies it', async () => {
const worldStateReads = { count: 0 };
const auth: GameSessionTokenPayload = {