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:
@@ -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 : [],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user