fix sanctions and admin role escalation
This commit is contained in:
@@ -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,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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user