fix(security): close Critical IDOR/authz gaps (C-1/C-2)

C-1: messaging endpoints now enforce channel participation (public
     channels open; private require channel_members). authorUserId is
     forced to the authenticated user (no more author spoofing); edit/
     delete require message-author ownership; history read gated too.
C-2: PUT /commands body strictly validated + size-capped via
     SyncCommandsDto (kills catalog poisoning / DoS). Optional
     FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY restricts the write to the
     plugin when set; never weaker than before when unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 17:47:08 +01:00
parent 3e96de730a
commit e45ad91340
4 changed files with 181 additions and 14 deletions

View File

@@ -1,29 +1,51 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Put,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
import { CommandsService } from './commands.service.js';
import { SyncCommandsDto } from './dto.sync-commands.dto.js';
type AuthedRequest = { userId?: string };
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
@Controller('commands')
export class CommandsController {
constructor(private readonly commands: CommandsService) {}
// Plugin syncs the OpenClaw native-command catalog here (any authenticated
// agent/user; idempotent full replace).
// Guild C-2: catalog write is privileged. When
// FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY is configured (recommended in
// production), the caller must present a matching x-commands-sync-key
// header — this restricts writes to the OpenClaw plugin. When unset, we
// fall back to "any authenticated agent/user" (never weaker than before).
// The body is always strictly validated + size-capped via SyncCommandsDto.
@Put()
sync(
@Req() req: AuthedRequest,
@Body() body: { commands?: unknown[] },
@Body() body: SyncCommandsDto,
@Headers('x-commands-sync-key') syncKey?: string,
) {
if (!req.userId) throw new UnauthorizedException('missing user');
const commands = Array.isArray(body?.commands) ? body.commands : [];
return this.commands.sync(commands);
const configured = process.env.FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY ?? '';
if (configured) {
if (!syncKey || !safeEqual(syncKey, configured)) {
throw new ForbiddenException('invalid commands sync key');
}
} else if (!req.userId) {
throw new UnauthorizedException('missing user');
}
return this.commands.sync(body.commands as unknown[]);
}
// Frontend reads the catalog to drive `/` autocomplete.

View File

@@ -0,0 +1,102 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
// Guild C-2: the slash-command catalog is guild-global and rendered by the
// frontend `/` autocomplete. Without a strict schema + caps a single
// authenticated caller could poison it or blow up the DB / clients.
// The global ValidationPipe runs with { whitelist, forbidNonWhitelisted },
// so any unknown field is rejected.
class CommandChoiceDto {
@IsString()
@MaxLength(200)
value!: string;
@IsString()
@MaxLength(200)
label!: string;
}
class CommandArgDto {
@IsString()
@MaxLength(100)
name!: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsString()
@MaxLength(40)
type?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsBoolean()
captureRemaining?: boolean;
@IsOptional()
@IsBoolean()
preferAutocomplete?: boolean;
// null when there are no choices (plugin sends explicit null).
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => CommandChoiceDto)
choices?: CommandChoiceDto[] | null;
}
class CommandSpecDto {
@IsString()
@MaxLength(100)
name!: string;
@IsOptional()
@IsString()
@MaxLength(100)
nativeName?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsBoolean()
acceptsArgs?: boolean;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => CommandArgDto)
args?: CommandArgDto[];
@IsOptional()
@IsString()
@MaxLength(20)
argsParsing?: string;
}
export class SyncCommandsDto {
@IsArray()
@ArrayMaxSize(200)
@ValidateNested({ each: true })
@Type(() => CommandSpecDto)
commands!: CommandSpecDto[];
}

View File

@@ -3,6 +3,7 @@ import {
ConflictException,
Controller,
Delete,
ForbiddenException,
Get,
Headers,
NotFoundException,
@@ -10,11 +11,13 @@ import {
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { CreateMessageDto } from './dto.create-message.dto.js';
import { Channel } from '../entities/channel.entity.js';
import { ChannelMember } from '../entities/channel-member.entity.js';
import { Message } from '../entities/message.entity.js';
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
import { WakeMapping } from '../entities/wake-mapping.entity.js';
@@ -36,6 +39,8 @@ export class MessagingController {
private readonly dataSource: DataSource,
@InjectRepository(Channel)
private readonly channelRepo: Repository<Channel>,
@InjectRepository(ChannelMember)
private readonly memberRepo: Repository<ChannelMember>,
@InjectRepository(Message)
private readonly messageRepo: Repository<Message>,
@InjectRepository(IdempotencyRecord)
@@ -86,6 +91,19 @@ export class MessagingController {
};
}
// Channel-participant gate (Guild C-1): public channels are readable/
// writable by any authenticated user; private channels require explicit
// channel_members membership. Returns the channel so callers can reuse it.
private async assertParticipant(channelId: string, userId: string): Promise<Channel> {
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) throw new NotFoundException('channel not found');
if (channel.isPublic) return channel;
if (!userId) throw new ForbiddenException('not a channel member');
const member = await this.memberRepo.findOne({ where: { channelId, userId } });
if (!member) throw new ForbiddenException('not a channel member');
return channel;
}
// Persists one message (allocates a seq under a channel row lock) and
// returns its view. Used for normal messages and for guild /ack messages.
private async persistMessage(
@@ -136,20 +154,25 @@ export class MessagingController {
async create(
@Param('id') channelId: string,
@Body() body: CreateMessageDto,
@Req() req: { userId?: string },
@Headers('idempotency-key') idempotencyKey?: string,
) {
const scope = `POST:/channels/${channelId}/messages`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
if (existed) return existed;
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) throw new NotFoundException('channel not found');
// Guild C-1: caller must be a participant of the channel, and the
// author is always the authenticated user — body.authorUserId is
// ignored so a caller can never post as someone else.
const userId = String(req.userId ?? '');
if (!userId) throw new ForbiddenException('missing user');
const channel = await this.assertParticipant(channelId, userId);
if (channel.closed) {
throw new ConflictException({ error: 'channel_closed', message: 'channel is closed' });
}
const xType = channel.xType ?? 'general';
const isRotating = xType === 'discuss' || xType === 'work';
const authorUserId = String(body.authorUserId ?? 'anonymous');
const authorUserId = userId;
// ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via
// Center before anything else persists/parses the content
@@ -223,14 +246,23 @@ export class MessagingController {
@Param('id') channelId: string,
@Param('messageId') messageId: string,
@Body() body: { content?: string },
@Req() req: { userId?: string },
@Headers('idempotency-key') idempotencyKey?: string,
) {
const scope = `PATCH:/channels/${channelId}/messages/${messageId}`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
if (existed) return existed;
// Guild C-1: participant + author-ownership.
const userId = String(req.userId ?? '');
if (!userId) throw new ForbiddenException('missing user');
await this.assertParticipant(channelId, userId);
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
if (!item) return { status: 'not_found' };
if (item.authorUserId !== userId) {
throw new ForbiddenException('not the message author');
}
const now = Date.now();
const createdAt = new Date(item.createdAt).getTime();
@@ -259,14 +291,23 @@ export class MessagingController {
async remove(
@Param('id') channelId: string,
@Param('messageId') messageId: string,
@Req() req: { userId?: string },
@Headers('idempotency-key') idempotencyKey?: string,
) {
const scope = `DELETE:/channels/${channelId}/messages/${messageId}`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
if (existed) return existed;
// Guild C-1: participant + author-ownership.
const userId = String(req.userId ?? '');
if (!userId) throw new ForbiddenException('missing user');
await this.assertParticipant(channelId, userId);
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
if (!item) return { status: 'not_found' };
if (item.authorUserId !== userId) {
throw new ForbiddenException('not the message author');
}
item.isDeleted = true;
item.deletedAt = new Date();
@@ -304,10 +345,14 @@ export class MessagingController {
@Get()
async listBySeq(
@Param('id') channelId: string,
@Req() req: { userId?: string },
@Query('seq_from') seqFrom?: string,
@Query('seq_to') seqTo?: string,
@Query('limit') limit?: string,
) {
// Guild C-1: only participants may read channel history.
const userId = String(req.userId ?? '');
if (!userId) throw new ForbiddenException('missing user');
const from = seqFrom ? Number(seqFrom) : 1;
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
const safeLimit = clampLimit(limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
@@ -327,10 +372,7 @@ export class MessagingController {
};
}
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) {
throw new NotFoundException('channel not found');
}
const channel = await this.assertParticipant(channelId, userId);
const qb = this.messageRepo
.createQueryBuilder('m')

View File

@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MessagingController } from './messaging.controller.js';
import { Channel } from '../entities/channel.entity.js';
import { ChannelMember } from '../entities/channel-member.entity.js';
import { Message } from '../entities/message.entity.js';
import { IdempotencyRecord } from '../entities/idempotency-record.entity.js';
import { WakeMapping } from '../entities/wake-mapping.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord, WakeMapping])],
imports: [TypeOrmModule.forFeature([Channel, ChannelMember, Message, IdempotencyRecord, WakeMapping])],
controllers: [MessagingController],
})
export class MessagingModule {}