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,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');

View File

@@ -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) {}

View 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);