diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 6dd768c3c6..97212b4ac7 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { In } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; +import Ajv from 'ajv'; import type { MiMeta, MiRole, @@ -35,8 +36,10 @@ import { type ManagedMemorySingleCache, type ManagedMemoryKVCache, } from '@/global/CacheManagementService.js'; -import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { getCallerId } from '@/misc/attach-caller-id.js'; +import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; +import type { JSONSchemaType, ValidateFunction } from 'ajv'; export type RolePolicies = { gtlAvailable: boolean; @@ -122,6 +125,86 @@ export const DEFAULT_POLICIES: RolePolicies = { // TODO cache sync fixes (and maybe events too?) +const DefaultPoliciesSchema: JSONSchemaType = { + type: 'object', + additionalProperties: false, + required: [], + 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' }, + + // these can be less than 1 MB + // (test/unit/server/api/drive/files/create.ts depends on this) + driveCapacityMb: { type: 'number', minimum: 0 }, + maxFileSizeMb: { type: 'number', 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: { type: 'string', enum: ['available', 'readonly', 'unavailable'] }, + canTrend: { type: 'boolean' }, + canViewFederation: { type: 'boolean' }, + }, +}; + +const RoleSchema: JSONSchemaType = { + type: 'object', + additionalProperties: false, + required: [], + properties: Object.fromEntries( + Object.entries(DefaultPoliciesSchema.properties!).map( + ( + // I picked `canTrend` here, but any policy name is fine, the + // type of their bit of the schema is all the same + [policy, value]: [string, JSONSchemaType['properties']['canTrend']] + ) => [ + policy, + { + type: 'object', + additionalProperties: false, + // we can't require `value` because the MiRole says `value: + // any` which includes undefined, so technically `value` is + // not really required + required: ['priority', 'useDefault'], + properties: { + priority: { type: 'integer', minimum: 0, maximum: 2 }, + useDefault: { type: 'boolean' }, + value, + }, + }, + ], + ), + ), +}; + @Injectable() export class RoleService implements OnApplicationShutdown, OnModuleInit { private readonly rolesCache: ManagedMemorySingleCache; @@ -129,6 +212,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private cacheService: CacheService; private notificationService: NotificationService; + private defaultPoliciesValidator: ValidateFunction; + private roleValidator: ValidateFunction; public static AlreadyAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {}; @@ -167,6 +252,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { // TODO additional cache for final calculation? this.redisForSub.on('message', this.onMessage); + + // this is copied from server/api/endpoint-base.ts + const ajv = new Ajv.default({ + useDefaults: true, + allErrors: true, + }); + this.defaultPoliciesValidator = ajv.compile(DefaultPoliciesSchema); + this.roleValidator = ajv.compile(RoleSchema); } @bindThis @@ -756,6 +849,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async create(values: Partial, moderator?: MiUser): Promise { + this.assertValidRole(values); + const date = this.timeService.date; const created = await this.rolesRepository.insertOne({ id: this.idService.gen(date.getTime()), @@ -792,6 +887,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async update(role: MiRole, params: Partial, moderator?: MiUser): Promise { + this.assertValidRole(params); + const date = this.timeService.date; await this.rolesRepository.update(role.id, { updatedAt: date, @@ -843,4 +940,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); } + + @bindThis + public assertValidRole(role: Partial): void { + if (!role.policies) return; + + 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(policies)) return; + + throw new IdentifiableError( + '39d78ad7-0f00-4bff-b2e2-2e7db889e05d', + 'invalid policy values', + false, + this.defaultPoliciesValidator.errors, + ); + } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index f92f7ebaeb..a61d3b60d5 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -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 { // 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; + } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts index 5cf49670be..2d3cdb8b72 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts @@ -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 { // 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({ diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 175adcb63f..be743b30fa 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -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 { // 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; + } }); } }