fix(security): close Critical auth gaps (C1/C2/C3)

C1: replace fragile path.endsWith() guard whitelist with a metadata
    @Public() decorator + Reflector (no more path-shape bypass surface).
C2: CenterApiKeyGuard attaches the authenticated GuildNode; introspect
    & resolve-names now reject when body.guildNodeId != that node
    (stops one node probing/enumerating another guild's identities).
C3: heartbeat/status are self-only (a node can't revoke/hijack another);
    GET /nodes no longer returns apiKeyHash (credential-hash leak).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 17:47:01 +01:00
parent aa9d59a952
commit 6afb935302
5 changed files with 100 additions and 33 deletions

View File

@@ -1,9 +1,11 @@
import { Body, Controller, Get, Headers, Param, Patch, Post, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto.login.dto.js';
import { RefreshDto } from './dto.refresh.dto.js';
import { LogoutDto } from './dto.logout.dto.js';
import { UpdateMeDto } from './dto.update-me.dto.js';
import { Public } from '../common/public.decorator.js';
import type { AuthedGuildNode } from '../common/center-api-key.guard.js';
function bearer(authorization?: string): string {
return authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
@@ -13,21 +15,25 @@ function bearer(authorization?: string): string {
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
login(@Body() body: LoginDto) {
return this.authService.login(body);
}
@Public()
@Post('refresh')
refresh(@Body() body: RefreshDto) {
return this.authService.refresh(body.refreshToken);
}
@Public()
@Post('logout')
logout(@Body() body: LogoutDto) {
return this.authService.logout(body.refreshToken);
}
@Public()
@Get('me')
me(@Headers('authorization') authorization?: string) {
const token = bearer(authorization);
@@ -35,6 +41,7 @@ export class AuthController {
return this.authService.getMe(token);
}
@Public()
@Patch('me')
updateMe(@Headers('authorization') authorization: string | undefined, @Body() body: UpdateMeDto) {
const token = bearer(authorization);
@@ -42,41 +49,64 @@ export class AuthController {
return this.authService.updateMe(token, body.name);
}
@Public()
@Get('me/guilds')
meGuilds(@Headers('authorization') authorization?: string) {
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.listMyGuilds(token);
}
@Public()
@Post('me/guilds/join')
joinGuild(@Headers('authorization') authorization: string | undefined, @Body() body: { guildNodeId?: string }) {
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.joinGuild(token, String(body?.guildNodeId ?? ''));
}
@Public()
@Get('guilds/:guildNodeId/members')
guildMembers(@Headers('authorization') authorization: string | undefined, @Param('guildNodeId') guildNodeId: string) {
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
const token = bearer(authorization);
if (!token) throw new UnauthorizedException('missing bearer token');
return this.authService.listGuildMembers(token, guildNodeId);
}
@Public()
@Post('agent/login')
agentLogin(@Body() body: { apiKey?: string }) {
return this.authService.agentLogin(String(body?.apiKey ?? ''));
}
// Not @Public(): requires a guild node api key. The guard attaches the
// authenticated node; a node may only introspect tokens for its own
// guild (Center C2 — prevents cross-tenant identity probing).
@Post('introspect')
introspect(@Body() body: { token?: string; guildNodeId?: string }) {
return this.authService.introspectGuildToken(body?.token ?? '', body?.guildNodeId ?? '');
introspect(
@Req() req: { guildNode?: AuthedGuildNode },
@Body() body: { token?: string; guildNodeId?: string },
) {
const requested = String(body?.guildNodeId ?? '');
if (!req.guildNode || req.guildNode.nodeId !== requested) {
throw new ForbiddenException('api key not authorized for this guild node');
}
return this.authService.introspectGuildToken(body?.token ?? '', requested);
}
// Not @Public(): same node-ownership rule as introspect (Center C2 —
// prevents one node enumerating another guild's member identities).
@Post('resolve-names')
resolveNames(@Body() body: { guildNodeId?: string; names?: string[] }) {
resolveNames(
@Req() req: { guildNode?: AuthedGuildNode },
@Body() body: { guildNodeId?: string; names?: string[] },
) {
const requested = String(body?.guildNodeId ?? '');
if (!req.guildNode || req.guildNode.nodeId !== requested) {
throw new ForbiddenException('api key not authorized for this guild node');
}
return this.authService.resolveNames(
String(body?.guildNodeId ?? ''),
requested,
Array.isArray(body?.names) ? body.names : [],
);
}