always validate role policies

there might be a simpler way to do this, but:

* we already use Ajv to validate API arguments
* it was very easy to write
* it should be pretty clear how to add more policies
This commit is contained in:
dakkar 2025-07-24 09:45:11 +01:00 committed by Hazelnoot
parent 68a3b4293c
commit 0289c08a79
4 changed files with 201 additions and 18 deletions

View file

@ -37,6 +37,8 @@ import {
} from '@/global/CacheManagementService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { getCallerId } from '@/misc/attach-caller-id.js';
import Ajv from 'ajv';
import { IdentifiableError } from '@/misc/identifiable-error.js';
export type RolePolicies = {
gtlAvailable: boolean;
@ -122,6 +124,69 @@ export const DEFAULT_POLICIES: RolePolicies = {
// TODO cache sync fixes (and maybe events too?)
const DefaultPolicieSchema = {
type: 'object',
additionalProperties: false,
properties: {
gtlAvailable: { type: 'boolean' },
ltlAvailable: { type: 'boolean' },
btlAvailable: { type: 'boolean' },
canPublicNote: { type: 'boolean' },
scheduleNoteMax: { type: 'integer', minimum: 0 },
mentionLimit: { type: 'integer', minimum: 0 },
canInvite: { type: 'boolean' },
inviteLimit: { type: 'integer', minimum: 0 },
inviteLimitCycle: { type: 'integer', minimum: 0 },
inviteExpirationTime: { type: 'integer', minimum: 0 },
canManageCustomEmojis: { type: 'boolean' },
canManageAvatarDecorations: { type: 'boolean' },
canSearchNotes: { type: 'boolean' },
canUseTranslator: { type: 'boolean' },
canHideAds: { type: 'boolean' },
driveCapacityMb: { type: 'integer', minimum: 0 },
maxFileSizeMb: { type: 'integer', minimum: 0 },
alwaysMarkNsfw: { type: 'boolean' },
canUpdateBioMedia: { type: 'boolean' },
pinLimit: { type: 'integer', minimum: 0 },
antennaLimit: { type: 'integer', minimum: 0 },
wordMuteLimit: { type: 'integer', minimum: 0 },
webhookLimit: { type: 'integer', minimum: 0 },
clipLimit: { type: 'integer', minimum: 0 },
noteEachClipsLimit: { type: 'integer', minimum: 0 },
userListLimit: { type: 'integer', minimum: 0 },
userEachUserListsLimit: { type: 'integer', minimum: 0 },
rateLimitFactor: { type: 'number', minimum: 0.01 },
canImportNotes: { type: 'boolean' },
avatarDecorationLimit: { type: 'integer', minimum: 0 },
canImportAntennas: { type: 'boolean' },
canImportBlocking: { type: 'boolean' },
canImportFollowing: { type: 'boolean' },
canImportMuting: { type: 'boolean' },
canImportUserLists: { type: 'boolean' },
chatAvailability: { enum: [ 'available', 'readonly', 'unavailable' ] },
canTrend: { type: 'boolean' },
},
};
const RoleSchema = {
type: 'object',
additionalProperties: false,
properties: Object.fromEntries(
Object.entries(DefaultPolicieSchema.properties).map( e => {
return [e[0], {
type: 'object',
additionalProperties: false,
properties: {
priority: { type: 'integer', minimum: 0, maximum: 2 },
useDefault: { type: 'boolean' },
value: e[1],
},
}];
}),
),
};
@Injectable()
export class RoleService implements OnApplicationShutdown, OnModuleInit {
private readonly rolesCache: ManagedMemorySingleCache<MiRole[]>;
@ -129,6 +194,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private cacheService: CacheService;
private notificationService: NotificationService;
private defaultPoliciesValidator: Ajv;
private roleValidator: Ajv;
public static AlreadyAssignedError = class extends Error {};
public static NotAssignedError = class extends Error {};
@ -756,6 +823,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async create(values: Partial<MiRole>, moderator?: MiUser): Promise<MiRole> {
this.assertValidRole(values);
const date = this.timeService.date;
const created = await this.rolesRepository.insertOne({
id: this.idService.gen(date.getTime()),
@ -792,6 +861,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async update(role: MiRole, params: Partial<MiRole>, moderator?: MiUser): Promise<void> {
this.assertValidRole(params);
const date = this.timeService.date;
await this.rolesRepository.update(role.id, {
updatedAt: date,
@ -843,4 +914,49 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
@bindThis
public assertValidRole(role: Partial<MiRole>): void {
if (!role.policies) return;
if (!this.roleValidator) {
// this is copied from server/api/endpoint-base.ts
const ajv = new Ajv.default({
useDefault: true,
allErrors: true,
});
this.roleValidator = ajv.compile(RoleSchema);
}
if (this.roleValidator(role.policies)) return;
throw new IdentifiableError(
'39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
'invalid policy values',
false,
this.roleValidator.errors,
);
}
@bindThis
public assertValidDefaultPolicies(policies: object): void {
if (!this.defaultPoliciesValidator) {
// this is copied from server/api/endpoint-base.ts
const ajv = new Ajv.default({
useDefault: true,
allErrors: true,
});
this.defaultPoliciesValidator = ajv.compile(DefaultPolicieSchema);
}
if (this.defaultPoliciesValidator(policies)) return;
throw new IdentifiableError(
'39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
'invalid policy values',
false,
this.defaultPoliciesValidator.errors,
);
}
}

View file

@ -7,6 +7,8 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { RoleService } from '@/core/RoleService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin', 'role'],
@ -20,6 +22,14 @@ export const meta = {
optional: false, nullable: false,
ref: 'Role',
},
errors: {
badValues: {
message: 'Invalid policy values',
code: 'BAD_POLICY_VALUES',
id: '39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
},
},
} as const;
export const paramDef = {
@ -69,7 +79,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const created = await this.roleService.create(ps, me);
return await this.roleEntityService.pack(created, me);
try {
return await this.roleEntityService.pack(created, me);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '39d78ad7-0f00-4bff-b2e2-2e7db889e05d') {
throw new ApiError(
meta.errors.badValues,
e.cause,
);
}
}
throw e;
}
});
}
}

View file

@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { MetaService } from '@/core/MetaService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin', 'role'],
@ -15,6 +18,14 @@ export const meta = {
requireCredential: true,
requireAdmin: true,
kind: 'write:admin:roles',
errors: {
badValues: {
message: 'Invalid policy values',
code: 'BAD_POLICY_VALUES',
id: '39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
},
},
} as const;
export const paramDef = {
@ -35,8 +46,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private metaService: MetaService,
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
try {
this.roleService.assertValidDefaultPolicies(ps.policies);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '39d78ad7-0f00-4bff-b2e2-2e7db889e05d') {
throw new ApiError(
meta.errors.badValues,
e.cause,
);
}
}
throw e;
}
const before = await this.metaService.fetch(true);
await this.metaService.update({

View file

@ -9,6 +9,7 @@ import type { RolesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { RoleService } from '@/core/RoleService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
export const meta = {
tags: ['admin', 'role'],
@ -23,6 +24,12 @@ export const meta = {
code: 'NO_SUCH_ROLE',
id: 'cd23ef55-09ad-428a-ac61-95a45e124b32',
},
badValues: {
message: 'Invalid policy values',
code: 'BAD_POLICY_VALUES',
id: '39d78ad7-0f00-4bff-b2e2-2e7db889e05d',
},
},
} as const;
@ -67,23 +74,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchRole);
}
await this.roleService.update(role, {
name: ps.name,
description: ps.description,
color: ps.color,
iconUrl: ps.iconUrl,
target: ps.target,
condFormula: ps.condFormula,
isPublic: ps.isPublic,
isModerator: ps.isModerator,
isAdministrator: ps.isAdministrator,
isExplorable: ps.isExplorable,
asBadge: ps.asBadge,
preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount,
canEditMembersByModerator: ps.canEditMembersByModerator,
displayOrder: ps.displayOrder,
policies: ps.policies,
}, me);
try {
await this.roleService.update(role, {
name: ps.name,
description: ps.description,
color: ps.color,
iconUrl: ps.iconUrl,
target: ps.target,
condFormula: ps.condFormula,
isPublic: ps.isPublic,
isModerator: ps.isModerator,
isAdministrator: ps.isAdministrator,
isExplorable: ps.isExplorable,
asBadge: ps.asBadge,
preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount,
canEditMembersByModerator: ps.canEditMembersByModerator,
displayOrder: ps.displayOrder,
policies: ps.policies,
}, me);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '39d78ad7-0f00-4bff-b2e2-2e7db889e05d') {
throw new ApiError(
meta.errors.badValues,
e.cause,
);
}
}
throw e;
}
});
}
}