chore: upgrade Node.js runtime to v24 across codebase #419

Merged
jason.woltje merged 438 commits from fix/auth-frontend-remediation into main 2026-02-17 01:04:47 +00:00
8 changed files with 572 additions and 1 deletions
Showing only changes of commit ebd842f007 - Show all commits

View File

@@ -5,6 +5,7 @@ import { BullModule } from "@nestjs/bullmq";
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { CsrfController } from "./common/controllers/csrf.controller";
import { PrismaModule } from "./prisma/prisma.module";
import { DatabaseModule } from "./database/database.module";
import { AuthModule } from "./auth/auth.module";
@@ -87,7 +88,7 @@ import { FederationModule } from "./federation/federation.module";
CoordinatorIntegrationModule,
FederationModule,
],
controllers: [AppController],
controllers: [AppController, CsrfController],
providers: [
AppService,
{

View File

@@ -0,0 +1,115 @@
/**
* CSRF Controller Tests
*
* Tests CSRF token generation endpoint.
*/
import { describe, it, expect, vi } from "vitest";
import { CsrfController } from "./csrf.controller";
import { Response } from "express";
describe("CsrfController", () => {
let controller: CsrfController;
controller = new CsrfController();
describe("getCsrfToken", () => {
it("should generate and return a CSRF token", () => {
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
const result = controller.getCsrfToken(mockResponse);
expect(result).toHaveProperty("token");
expect(typeof result.token).toBe("string");
expect(result.token.length).toBe(64); // 32 bytes as hex = 64 characters
});
it("should set CSRF token in httpOnly cookie", () => {
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
const result = controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
result.token,
expect.objectContaining({
httpOnly: true,
sameSite: "strict",
})
);
});
it("should set secure flag in production", () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "production";
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
secure: true,
})
);
process.env.NODE_ENV = originalEnv;
});
it("should not set secure flag in development", () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "development";
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
secure: false,
})
);
process.env.NODE_ENV = originalEnv;
});
it("should generate unique tokens on each call", () => {
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
const result1 = controller.getCsrfToken(mockResponse);
const result2 = controller.getCsrfToken(mockResponse);
expect(result1.token).not.toBe(result2.token);
});
it("should set cookie with 24 hour expiry", () => {
const mockResponse = {
cookie: vi.fn(),
} as unknown as Response;
controller.getCsrfToken(mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
});
});
});

View File

@@ -0,0 +1,35 @@
/**
* CSRF Controller
*
* Provides CSRF token generation endpoint for client applications.
*/
import { Controller, Get, Res } from "@nestjs/common";
import { Response } from "express";
import * as crypto from "crypto";
import { SkipCsrf } from "../decorators/skip-csrf.decorator";
@Controller("api/v1/csrf")
export class CsrfController {
/**
* Generate and set CSRF token
* Returns token to client and sets it in httpOnly cookie
*/
@Get("token")
@SkipCsrf() // This endpoint itself doesn't need CSRF protection
getCsrfToken(@Res({ passthrough: true }) response: Response): { token: string } {
// Generate cryptographically secure random token
const token = crypto.randomBytes(32).toString("hex");
// Set token in httpOnly cookie
response.cookie("csrf-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 24 * 60 * 60 * 1000, // 24 hours
});
// Return token to client (so it can include in X-CSRF-Token header)
return { token };
}
}

View File

@@ -0,0 +1,20 @@
/**
* Skip CSRF Decorator
*
* Marks an endpoint to skip CSRF protection.
* Use for endpoints that have alternative authentication (e.g., signature verification).
*
* @example
* ```typescript
* @Post('incoming/connect')
* @SkipCsrf()
* async handleIncomingConnection() {
* // Signature-based authentication, no CSRF needed
* }
* ```
*/
import { SetMetadata } from "@nestjs/common";
import { SKIP_CSRF_KEY } from "../guards/csrf.guard";
export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true);

View File

@@ -0,0 +1,140 @@
/**
* CSRF Guard Tests
*
* Tests CSRF protection using double-submit cookie pattern.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { CsrfGuard } from "./csrf.guard";
describe("CsrfGuard", () => {
let guard: CsrfGuard;
let reflector: Reflector;
beforeEach(() => {
reflector = new Reflector();
guard = new CsrfGuard(reflector);
});
const createContext = (
method: string,
cookies: Record<string, string> = {},
headers: Record<string, string> = {},
skipCsrf = false
): ExecutionContext => {
const request = {
method,
cookies,
headers,
path: "/api/test",
};
return {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
getClass: () => ({}),
getAllAndOverride: vi.fn().mockReturnValue(skipCsrf),
} as unknown as ExecutionContext;
};
describe("Safe HTTP methods", () => {
it("should allow GET requests without CSRF token", () => {
const context = createContext("GET");
expect(guard.canActivate(context)).toBe(true);
});
it("should allow HEAD requests without CSRF token", () => {
const context = createContext("HEAD");
expect(guard.canActivate(context)).toBe(true);
});
it("should allow OPTIONS requests without CSRF token", () => {
const context = createContext("OPTIONS");
expect(guard.canActivate(context)).toBe(true);
});
});
describe("Endpoints marked to skip CSRF", () => {
it("should allow POST requests when @SkipCsrf() is applied", () => {
vi.spyOn(reflector, "getAllAndOverride").mockReturnValue(true);
const context = createContext("POST");
expect(guard.canActivate(context)).toBe(true);
});
});
describe("State-changing methods requiring CSRF", () => {
it("should reject POST without CSRF token", () => {
const context = createContext("POST");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
});
it("should reject PUT without CSRF token", () => {
const context = createContext("PUT");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
it("should reject PATCH without CSRF token", () => {
const context = createContext("PATCH");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
it("should reject DELETE without CSRF token", () => {
const context = createContext("DELETE");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
it("should reject when only cookie token is present", () => {
const context = createContext("POST", { "csrf-token": "abc123" });
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
});
it("should reject when only header token is present", () => {
const context = createContext("POST", {}, { "x-csrf-token": "abc123" });
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
});
it("should reject when tokens do not match", () => {
const context = createContext(
"POST",
{ "csrf-token": "abc123" },
{ "x-csrf-token": "xyz789" }
);
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context)).toThrow("CSRF token mismatch");
});
it("should allow when tokens match", () => {
const context = createContext(
"POST",
{ "csrf-token": "abc123" },
{ "x-csrf-token": "abc123" }
);
expect(guard.canActivate(context)).toBe(true);
});
it("should allow PATCH when tokens match", () => {
const context = createContext(
"PATCH",
{ "csrf-token": "token123" },
{ "x-csrf-token": "token123" }
);
expect(guard.canActivate(context)).toBe(true);
});
it("should allow DELETE when tokens match", () => {
const context = createContext(
"DELETE",
{ "csrf-token": "delete-token" },
{ "x-csrf-token": "delete-token" }
);
expect(guard.canActivate(context)).toBe(true);
});
});
});

View File

@@ -0,0 +1,83 @@
/**
* CSRF Guard
*
* Implements CSRF protection using double-submit cookie pattern.
* Validates that CSRF token in cookie matches token in header.
*
* Usage:
* - Apply to controllers handling state-changing operations
* - Use @SkipCsrf() decorator to exempt specific endpoints
* - Safe methods (GET, HEAD, OPTIONS) are automatically exempted
*/
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
Logger,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
export const SKIP_CSRF_KEY = "skipCsrf";
@Injectable()
export class CsrfGuard implements CanActivate {
private readonly logger = new Logger(CsrfGuard.name);
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Check if endpoint is marked to skip CSRF
const skipCsrf = this.reflector.getAllAndOverride<boolean>(SKIP_CSRF_KEY, [
context.getHandler(),
context.getClass(),
]);
if (skipCsrf) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
// Exempt safe HTTP methods (GET, HEAD, OPTIONS)
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) {
return true;
}
// Get CSRF token from cookie and header
const cookies = request.cookies as Record<string, string> | undefined;
const cookieToken = cookies?.["csrf-token"];
const headerToken = request.headers["x-csrf-token"] as string | undefined;
// Validate tokens exist and match
if (!cookieToken || !headerToken) {
this.logger.warn({
event: "CSRF_TOKEN_MISSING",
method: request.method,
path: request.path,
hasCookie: !!cookieToken,
hasHeader: !!headerToken,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token missing");
}
if (cookieToken !== headerToken) {
this.logger.warn({
event: "CSRF_TOKEN_MISMATCH",
method: request.method,
path: request.path,
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("CSRF token mismatch");
}
return true;
}
}

View File

@@ -12,6 +12,8 @@ import { FederationAuditService } from "./audit.service";
import { ConnectionService } from "./connection.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import { CsrfGuard } from "../common/guards/csrf.guard";
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
import type { PublicInstanceIdentity } from "./types/instance.types";
import type { ConnectionDetails } from "./types/connection.types";
import type { AuthenticatedRequest } from "../common/types/user.types";
@@ -25,6 +27,7 @@ import {
import { FederationConnectionStatus } from "@prisma/client";
@Controller("api/v1/federation")
@UseGuards(CsrfGuard)
export class FederationController {
private readonly logger = new Logger(FederationController.name);
@@ -38,6 +41,7 @@ export class FederationController {
* Get this instance's public identity
* No authentication required - this is public information for federation
* Rate limit: "long" tier (200 req/hour) - public endpoint
* CSRF exempt: GET method (safe)
*/
@Get("instance")
@Throttle({ long: { limit: 200, ttl: 3600000 } })
@@ -207,8 +211,10 @@ export class FederationController {
* Handle incoming connection request from remote instance
* Public endpoint - no authentication required (signature-based verification)
* Rate limit: "short" tier (3 req/sec) - CRITICAL DoS protection (Issue #272)
* CSRF exempt: Uses signature-based authentication instead
*/
@Post("incoming/connect")
@SkipCsrf()
@Throttle({ short: { limit: 3, ttl: 1000 } })
async handleIncomingConnection(
@Body() dto: IncomingConnectionRequestDto

View File

@@ -0,0 +1,171 @@
# Issue #278: Implement CSRF protection on state-changing endpoints
## Objective
Implement CSRF protection for all state-changing endpoints (POST, PATCH, DELETE) to prevent CSRF attacks.
## Security Impact
**Vulnerable Endpoints:**
- Connection initiation (`POST /api/v1/federation/connections/initiate`)
- Connection acceptance (`POST /api/v1/federation/connections/:id/accept`)
- Agent spawn (`POST /api/v1/agents/spawn`)
- Identity linking (POST endpoints in auth module)
## Modern CSRF Protection Approaches
### Option 1: SameSite Cookie Attribute (Simplest)
- Set `SameSite=Strict` or `SameSite=Lax` on session cookies
- No code changes required if already using sessions
- Modern browser support
- **Limitation**: Doesn't protect against subdomain attacks
### Option 2: Double Submit Cookie Pattern
- Generate CSRF token, store in cookie and send in header
- Validate that cookie and header match
- No server-side session storage required
- Works well with stateless apps
### Option 3: Synchronizer Token Pattern
- Generate CSRF token per session
- Store in session, validate on each request
- Requires session storage
- Most secure but complex
## Recommended Approach
**Use Double Submit Cookie Pattern:**
1. Generate CSRF token on first authenticated request
2. Set token in httpOnly cookie
3. Client includes token in X-CSRF-Token header
4. Server validates cookie matches header
**Exempt signature-based endpoints:**
- Federation incoming connections (already signature-verified)
- Any public endpoints that don't require authentication
## Implementation Plan
### 1. Create CSRF Guard
```typescript
// src/common/guards/csrf.guard.ts
@Injectable()
export class CsrfGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// Exempt GET, HEAD, OPTIONS (safe methods)
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) {
return true;
}
// Get token from cookie and header
const cookieToken = request.cookies["csrf-token"];
const headerToken = request.headers["x-csrf-token"];
// Validate tokens match
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
throw new ForbiddenException("Invalid CSRF token");
}
return true;
}
}
```
### 2. CSRF Token Generation Endpoint
```typescript
@Get('csrf-token')
getCsrfToken(@Res() response: Response): { token: string } {
const token = crypto.randomBytes(32).toString('hex');
response.cookie('csrf-token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
return { token };
}
```
### 3. Apply Guard Globally (with exemptions)
```typescript
// main.ts
app.useGlobalGuards(new CsrfGuard());
```
Or per-controller/route:
```typescript
@UseGuards(CsrfGuard)
@Controller("api/v1/federation")
export class FederationController {
// Endpoints automatically protected
}
```
### 4. Exempt Signature-Based Endpoints
```typescript
@Post('incoming/connect')
@SkipCsrf() // Custom decorator
async handleIncomingConnection() {
// Signature verification provides authentication
}
```
## Alternative: Check Existing Protection
Before implementing, verify if CSRF protection already exists:
1. Check if session cookies use SameSite attribute
2. Check for existing CSRF middleware
3. Check authentication middleware configuration
## Testing Requirements
1. Test CSRF token generation endpoint
2. Test protected endpoint rejects missing token
3. Test protected endpoint rejects mismatched token
4. Test protected endpoint accepts valid token
5. Test exempted endpoints work without token
6. Test safe methods (GET) work without token
## Progress
- [ ] Create scratchpad
- [ ] Check for existing CSRF protection
- [ ] Decide on implementation approach
- [ ] Create CSRF guard
- [ ] Create token generation endpoint
- [ ] Apply guard to controllers
- [ ] Add exemptions for signature-based endpoints
- [ ] Add tests
- [ ] Update frontend to include tokens
- [ ] Run quality gates
- [ ] Commit and push
- [ ] Close issue
## Notes
**Important Considerations:**
1. Don't break existing API consumers
2. Ensure frontend can get and use tokens
3. Document token usage for API clients
4. Consider backward compatibility
**Scope Decision:**
Given this is backend-focused and the frontend integration is complex, consider:
- Implementing SameSite cookie protection (simpler, immediate benefit)
- OR implementing CSRF guard with proper exemptions
- Document that frontend integration is required for full protection