diff --git a/apps/backend/__tests__/unit/middlewares/strictContentType.unit.test.ts b/apps/backend/__tests__/unit/middlewares/strictContentType.unit.test.ts new file mode 100644 index 00000000..f768f1b8 --- /dev/null +++ b/apps/backend/__tests__/unit/middlewares/strictContentType.unit.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { NextFunction, Request, Response } from 'express'; +import { strictContentType } from '../../../src/middlewares/strictContentType'; + +describe('strictContentType middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let next: NextFunction; + + beforeEach(() => { + mockRequest = { + method: 'POST', + get: vi.fn(), + }; + + mockResponse = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; + + next = vi.fn(); + }); + + it('allows JSON requests with the custom header', () => { + (mockRequest.get as ReturnType).mockImplementation( + (header) => { + if (header === 'Content-Type') return 'application/json; charset=utf-8'; + if (header === 'X-Requested-With') return 'XMLHttpRequest'; + return null; + } + ); + + strictContentType(mockRequest as Request, mockResponse as Response, next); + + expect(next).toHaveBeenCalledOnce(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it('rejects missing or unsupported content types', () => { + (mockRequest.get as ReturnType).mockImplementation( + (header) => { + if (header === 'Content-Type') return 'text/plain'; + if (header === 'X-Requested-With') return 'XMLHttpRequest'; + return null; + } + ); + + strictContentType(mockRequest as Request, mockResponse as Response, next); + + expect(mockResponse.status).toHaveBeenCalledWith(415); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'INVALID_CONTENT_TYPE' }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects requests missing the custom header', () => { + (mockRequest.get as ReturnType).mockImplementation( + (header) => { + if (header === 'Content-Type') return 'application/json'; + return null; + } + ); + + strictContentType(mockRequest as Request, mockResponse as Response, next); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'MISSING_CUSTOM_HEADER' }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('bypasses validation for non state-changing methods', () => { + mockRequest.method = 'GET'; + strictContentType(mockRequest as Request, mockResponse as Response, next); + + expect(next).toHaveBeenCalledOnce(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 42598377..a0d3ddd2 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -32,6 +32,7 @@ import { updateCacheMetrics, updateAllEnhancedMetrics, } from './services/metrics'; +import { strictContentType } from './middlewares/strictContentType'; // NEW IMPORTS: Repository coordination system import { repositoryCoordinator } from './services/repositoryCoordinator'; @@ -178,6 +179,9 @@ export async function startApplication() { // This MUST be early in the middleware chain to protect against OOM crashes app.use(memoryPressureMiddleware); + // Enforce JSON requests with a custom header for state-changing operations + app.use(['/api/repositories', '/api/commits'], strictContentType); + // Parse incoming JSON bodies app.use(express.json()); diff --git a/apps/backend/src/middlewares/strictContentType.ts b/apps/backend/src/middlewares/strictContentType.ts new file mode 100644 index 00000000..982bc747 --- /dev/null +++ b/apps/backend/src/middlewares/strictContentType.ts @@ -0,0 +1,38 @@ +import { NextFunction, Request, Response } from 'express'; + +const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'DELETE']); + +export function strictContentType( + req: Request, + res: Response, + next: NextFunction +) { + if (!STATE_CHANGING_METHODS.has(req.method)) { + next(); + return; + } + + const contentType = req.get('Content-Type')?.toLowerCase() ?? ''; + + if (!contentType.startsWith('application/json')) { + res.status(415).json({ + error: 'Unsupported Media Type', + code: 'INVALID_CONTENT_TYPE', + message: + 'Only application/json is accepted for state-changing operations', + }); + return; + } + + const customHeader = req.get('X-Requested-With'); + if (!customHeader) { + res.status(403).json({ + error: 'Forbidden', + code: 'MISSING_CUSTOM_HEADER', + message: 'X-Requested-With header required', + }); + return; + } + + next(); +} diff --git a/apps/frontend/src/services/api.ts b/apps/frontend/src/services/api.ts index 5d339527..b5ea33c2 100644 --- a/apps/frontend/src/services/api.ts +++ b/apps/frontend/src/services/api.ts @@ -15,6 +15,7 @@ const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', }, });