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[];
}