merge: always validate role policies (!1176)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1176

Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-11-15 16:43:16 -05:00
commit cfbffefb7f
4 changed files with 209 additions and 19 deletions

View file

@ -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<RolePolicies> = {
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<MiRole['policies']> = {
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<RolePolicies>['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<MiRole[]>;
@ -129,6 +212,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private cacheService: CacheService;
private notificationService: NotificationService;
private defaultPoliciesValidator: ValidateFunction<RolePolicies>;
private roleValidator: ValidateFunction<MiRole['policies']>;
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<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 +887,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 +940,30 @@ 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(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,
);
}
}

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);
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,6 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchRole);
}
try {
await this.roleService.update(role, {
name: ps.name,
description: ps.description,
@ -84,6 +92,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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;
}
});
}
}