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,43 +1,36 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { GuildNode } from '../entities/guild-node.entity.js';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator.js';
|
||||
|
||||
// The authenticated guild node, attached to the request by this guard so
|
||||
// downstream handlers can enforce node-ownership (a node may only act on
|
||||
// its own nodeId / introspect tokens for its own guild).
|
||||
export type AuthedGuildNode = { id: string; nodeId: string };
|
||||
|
||||
@Injectable()
|
||||
export class CenterApiKeyGuard implements CanActivate {
|
||||
constructor(
|
||||
@InjectRepository(GuildNode)
|
||||
private readonly nodeRepo: Repository<GuildNode>,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
path?: string;
|
||||
method?: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
guildNode?: AuthedGuildNode;
|
||||
}>();
|
||||
|
||||
const path = req.path ?? '';
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
|
||||
const noApiKeyRequired =
|
||||
path === '/healthz' ||
|
||||
path.endsWith('/healthz') ||
|
||||
(method === 'POST' && (path === '/auth/login' || path.endsWith('/auth/login'))) ||
|
||||
(method === 'POST' && (path === '/auth/agent/login' || path.endsWith('/auth/agent/login'))) ||
|
||||
(method === 'POST' && (path === '/auth/refresh' || path.endsWith('/auth/refresh'))) ||
|
||||
(method === 'POST' && (path === '/auth/logout' || path.endsWith('/auth/logout'))) ||
|
||||
(method === 'GET' && (path === '/auth/me' || path.endsWith('/auth/me'))) ||
|
||||
(method === 'PATCH' && (path === '/auth/me' || path.endsWith('/auth/me'))) ||
|
||||
(method === 'GET' && (path === '/auth/me/guilds' || path.endsWith('/auth/me/guilds'))) ||
|
||||
(method === 'POST' && (path === '/auth/me/guilds/join' || path.endsWith('/auth/me/guilds/join'))) ||
|
||||
(method === 'GET' && (path.includes('/auth/guilds/') && path.endsWith('/members')));
|
||||
|
||||
if (noApiKeyRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const received = req.headers['x-api-key'];
|
||||
const apiKey = Array.isArray(received) ? received[0] : received;
|
||||
if (!apiKey) throw new UnauthorizedException('missing api key');
|
||||
@@ -46,7 +39,12 @@ export class CenterApiKeyGuard implements CanActivate {
|
||||
for (const node of nodes) {
|
||||
if (!node.apiKeyHash) continue;
|
||||
const ok = await bcrypt.compare(apiKey, node.apiKeyHash);
|
||||
if (ok) return true;
|
||||
if (ok) {
|
||||
// Identify which node this key belongs to so handlers can enforce
|
||||
// that a node only acts within its own scope (Center C2/C3).
|
||||
req.guildNode = { id: node.id, nodeId: node.nodeId };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('invalid api key');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Public } from './public.decorator.js';
|
||||
|
||||
@Public()
|
||||
@Controller('healthz')
|
||||
export class HealthController {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
8
src/common/public.decorator.ts
Normal file
8
src/common/public.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
// Routes annotated with @Public() skip the global CenterApiKeyGuard
|
||||
// (api-key + node-identity) check. This is metadata-driven on purpose:
|
||||
// the previous string-matching whitelist (path.endsWith(...)) was a
|
||||
// bypass surface. Only the route's own decorator opens it.
|
||||
export const IS_PUBLIC_KEY = 'fabric:isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
Reference in New Issue
Block a user