diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2c2e770..6dbd1b4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, { diff --git a/apps/api/src/common/controllers/csrf.controller.spec.ts b/apps/api/src/common/controllers/csrf.controller.spec.ts new file mode 100644 index 0000000..2ac72db --- /dev/null +++ b/apps/api/src/common/controllers/csrf.controller.spec.ts @@ -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 + }) + ); + }); + }); +}); diff --git a/apps/api/src/common/controllers/csrf.controller.ts b/apps/api/src/common/controllers/csrf.controller.ts new file mode 100644 index 0000000..779b7b4 --- /dev/null +++ b/apps/api/src/common/controllers/csrf.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/common/decorators/skip-csrf.decorator.ts b/apps/api/src/common/decorators/skip-csrf.decorator.ts new file mode 100644 index 0000000..e83e0c1 --- /dev/null +++ b/apps/api/src/common/decorators/skip-csrf.decorator.ts @@ -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); diff --git a/apps/api/src/common/guards/csrf.guard.spec.ts b/apps/api/src/common/guards/csrf.guard.spec.ts new file mode 100644 index 0000000..9bd5746 --- /dev/null +++ b/apps/api/src/common/guards/csrf.guard.spec.ts @@ -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 = {}, + headers: Record = {}, + 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); + }); + }); +}); diff --git a/apps/api/src/common/guards/csrf.guard.ts b/apps/api/src/common/guards/csrf.guard.ts new file mode 100644 index 0000000..56219e0 --- /dev/null +++ b/apps/api/src/common/guards/csrf.guard.ts @@ -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(SKIP_CSRF_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (skipCsrf) { + return true; + } + + const request = context.switchToHttp().getRequest(); + + // 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 | 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; + } +} diff --git a/apps/api/src/federation/federation.controller.ts b/apps/api/src/federation/federation.controller.ts index 0ea1bcc..172ee6d 100644 --- a/apps/api/src/federation/federation.controller.ts +++ b/apps/api/src/federation/federation.controller.ts @@ -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 diff --git a/apps/web/src/app/(authenticated)/layout.tsx b/apps/web/src/app/(authenticated)/layout.tsx index da355db..ea3f8fd 100644 --- a/apps/web/src/app/(authenticated)/layout.tsx +++ b/apps/web/src/app/(authenticated)/layout.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth/auth-context"; import { Navigation } from "@/components/layout/Navigation"; +import { ChatOverlay } from "@/components/chat"; import type { ReactNode } from "react"; export default function AuthenticatedLayout({ @@ -36,6 +37,7 @@ export default function AuthenticatedLayout({
{children}
+
); } diff --git a/apps/web/src/components/chat/ChatOverlay.test.tsx b/apps/web/src/components/chat/ChatOverlay.test.tsx new file mode 100644 index 0000000..f857179 --- /dev/null +++ b/apps/web/src/components/chat/ChatOverlay.test.tsx @@ -0,0 +1,270 @@ +/** + * @file ChatOverlay.test.tsx + * @description Tests for the ChatOverlay component + */ + +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ChatOverlay } from "./ChatOverlay"; + +// Mock the Chat component +vi.mock("./Chat", () => ({ + Chat: vi.fn(() =>
Chat Component
), +})); + +// Mock the useChatOverlay hook +const mockOpen = vi.fn(); +const mockClose = vi.fn(); +const mockMinimize = vi.fn(); +const mockExpand = vi.fn(); +const mockToggle = vi.fn(); +const mockToggleMinimize = vi.fn(); + +vi.mock("../../hooks/useChatOverlay", () => ({ + useChatOverlay: vi.fn(() => ({ + isOpen: false, + isMinimized: false, + open: mockOpen, + close: mockClose, + minimize: mockMinimize, + expand: mockExpand, + toggle: mockToggle, + toggleMinimize: mockToggleMinimize, + })), +})); + +describe("ChatOverlay", () => { + beforeEach(async () => { + vi.clearAllMocks(); + // Reset mock to default state + const { useChatOverlay } = await import("../../hooks/useChatOverlay"); + vi.mocked(useChatOverlay).mockReturnValue({ + isOpen: false, + isMinimized: false, + open: mockOpen, + close: mockClose, + minimize: mockMinimize, + expand: mockExpand, + toggle: mockToggle, + toggleMinimize: mockToggleMinimize, + }); + }); + + describe("when closed", () => { + it("should render a floating button to open the chat", () => { + render(); + + const openButton = screen.getByRole("button", { name: /open chat/i }); + expect(openButton).toBeDefined(); + }); + + it("should not render the chat component when closed", () => { + render(); + + const chatComponent = screen.queryByTestId("chat-component"); + expect(chatComponent).toBeNull(); + }); + + it("should call open when the floating button is clicked", () => { + render(); + + const openButton = screen.getByRole("button", { name: /open chat/i }); + fireEvent.click(openButton); + + expect(mockOpen).toHaveBeenCalledOnce(); + }); + }); + + describe("when open", () => { + beforeEach(async () => { + const { useChatOverlay } = await import("../../hooks/useChatOverlay"); + vi.mocked(useChatOverlay).mockReturnValue({ + isOpen: true, + isMinimized: false, + open: mockOpen, + close: mockClose, + minimize: mockMinimize, + expand: mockExpand, + toggle: mockToggle, + toggleMinimize: mockToggleMinimize, + }); + }); + + it("should render the chat component", () => { + render(); + + const chatComponent = screen.getByTestId("chat-component"); + expect(chatComponent).toBeDefined(); + }); + + it("should render a close button", () => { + render(); + + const closeButton = screen.getByRole("button", { name: /close chat/i }); + expect(closeButton).toBeDefined(); + }); + + it("should render a minimize button", () => { + render(); + + const minimizeButton = screen.getByRole("button", { name: /minimize chat/i }); + expect(minimizeButton).toBeDefined(); + }); + + it("should call close when the close button is clicked", () => { + render(); + + const closeButton = screen.getByRole("button", { name: /close chat/i }); + fireEvent.click(closeButton); + + expect(mockClose).toHaveBeenCalledOnce(); + }); + + it("should call minimize when the minimize button is clicked", () => { + render(); + + const minimizeButton = screen.getByRole("button", { name: /minimize chat/i }); + fireEvent.click(minimizeButton); + + expect(mockMinimize).toHaveBeenCalledOnce(); + }); + }); + + describe("when minimized", () => { + beforeEach(async () => { + const { useChatOverlay } = await import("../../hooks/useChatOverlay"); + vi.mocked(useChatOverlay).mockReturnValue({ + isOpen: true, + isMinimized: true, + open: mockOpen, + close: mockClose, + minimize: mockMinimize, + expand: mockExpand, + toggle: mockToggle, + toggleMinimize: mockToggleMinimize, + }); + }); + + it("should not render the chat component when minimized", () => { + render(); + + const chatComponent = screen.queryByTestId("chat-component"); + expect(chatComponent).toBeNull(); + }); + + it("should render a minimized header", () => { + render(); + + const header = screen.getByText(/jarvis/i); + expect(header).toBeDefined(); + }); + + it("should call expand when clicking the minimized header", () => { + render(); + + const header = screen.getByText(/jarvis/i); + fireEvent.click(header); + + expect(mockExpand).toHaveBeenCalledOnce(); + }); + }); + + describe("keyboard shortcuts", () => { + it("should toggle chat when Cmd+Shift+J is pressed", () => { + render(); + + fireEvent.keyDown(document, { + key: "j", + code: "KeyJ", + metaKey: true, + shiftKey: true, + }); + + expect(mockToggle).toHaveBeenCalledOnce(); + }); + + it("should toggle chat when Ctrl+Shift+J is pressed", () => { + render(); + + fireEvent.keyDown(document, { + key: "j", + code: "KeyJ", + ctrlKey: true, + shiftKey: true, + }); + + expect(mockToggle).toHaveBeenCalledOnce(); + }); + + it("should minimize chat when Escape is pressed and chat is open", async () => { + const { useChatOverlay } = await import("../../hooks/useChatOverlay"); + vi.mocked(useChatOverlay).mockReturnValue({ + isOpen: true, + isMinimized: false, + open: mockOpen, + close: mockClose, + minimize: mockMinimize, + expand: mockExpand, + toggle: mockToggle, + toggleMinimize: mockToggleMinimize, + }); + + render(); + + fireEvent.keyDown(document, { + key: "Escape", + code: "Escape", + }); + + expect(mockMinimize).toHaveBeenCalledOnce(); + }); + + it("should open chat when Cmd+K is pressed and chat is closed", async () => { + render(); + + // Wait for component to mount + await screen.findByRole("button", { name: /open chat/i }); + + fireEvent.keyDown(document, { + key: "k", + code: "KeyK", + metaKey: true, + }); + + expect(mockOpen).toHaveBeenCalled(); + }); + + it("should open chat when Ctrl+K is pressed and chat is closed", async () => { + render(); + + // Wait for component to mount + await screen.findByRole("button", { name: /open chat/i }); + + fireEvent.keyDown(document, { + key: "k", + code: "KeyK", + ctrlKey: true, + }); + + expect(mockOpen).toHaveBeenCalled(); + }); + }); + + describe("responsive design", () => { + it("should render as a sidebar on desktop", () => { + render(); + + // Check for desktop-specific classes (will be verified in implementation) + // This is a placeholder - actual implementation will have proper responsive classes + expect(true).toBe(true); + }); + + it("should render as a drawer on mobile", () => { + render(); + + // Check for mobile-specific classes (will be verified in implementation) + // This is a placeholder - actual implementation will have proper responsive classes + expect(true).toBe(true); + }); + }); +}); diff --git a/apps/web/src/components/chat/ChatOverlay.tsx b/apps/web/src/components/chat/ChatOverlay.tsx new file mode 100644 index 0000000..d9dbac3 --- /dev/null +++ b/apps/web/src/components/chat/ChatOverlay.tsx @@ -0,0 +1,214 @@ +/** + * @file ChatOverlay.tsx + * @description Persistent chat overlay component that is accessible from any view + */ + +"use client"; + +import { useEffect, useRef } from "react"; +import { useChatOverlay } from "@/hooks/useChatOverlay"; +import { Chat } from "./Chat"; +import type { ChatRef } from "./Chat"; + +export function ChatOverlay(): React.JSX.Element { + const { isOpen, isMinimized, open, close, minimize, expand, toggle } = useChatOverlay(); + + const chatRef = useRef(null); + + // Global keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + // Cmd/Ctrl + Shift + J: Toggle chat panel + if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "j" || e.key === "J")) { + e.preventDefault(); + toggle(); + return; + } + + // Cmd/Ctrl + K: Open chat and focus input + if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) { + e.preventDefault(); + if (!isOpen) { + open(); + } + return; + } + + // Escape: Minimize chat (if open and not minimized) + if (e.key === "Escape" && isOpen && !isMinimized) { + e.preventDefault(); + minimize(); + return; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return (): void => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, isMinimized, open, minimize, toggle]); + + // Render floating button when closed + if (!isOpen) { + return ( + + ); + } + + // Render minimized header when minimized + if (isMinimized) { + return ( +
+ +
+ ); + } + + // Render full chat overlay when open and expanded + return ( + <> + {/* Backdrop for mobile */} +