diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..bec30f1 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-05-18 - Global Error Handler Swallowing 4xx Status Codes +**Vulnerability:** The backend global error handler (`errorHandler`) defaulted to status code 500 for any caught exception that didn't have a strictly defined `statusCode` property. This caused the `@fastify/rate-limit` plugin's 429 errors (which use the `code` property or throw a specific error shape) to be returned as 500 Internal Server Error, potentially masking rate limiting actions and confusing clients. +**Learning:** Fastify plugins and the core framework may emit errors with varying structures (e.g., `code` as a number vs string). A rigid error handler that assumes a specific shape for `statusCode` can inadvertently downgrade semantic client errors into generic server errors. +**Prevention:** Defensive coding in global error handlers is critical. The handler should check multiple properties (`statusCode`, `code`, `status`) and handle type coercion (string vs number) before falling back to 500. Specifically, handling `code === 429` ensures rate limit events are correctly propagated. diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..852cd74 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { + tsconfig: 'tsconfig.json' + }], + }, + setupFiles: ['/jest.setup.js'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, +}; diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 0000000..2e56a7e --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,37 @@ +// Mock environment variables +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key-must-be-long-enough-for-security'; +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test_db'; +process.env.RATE_LIMIT_MAX_REQUESTS = '5'; // Low limit for testing +process.env.RATE_LIMIT_WINDOW_MS = '60000'; // 1 minute + +// Mock Redis to prevent connection errors +jest.mock('ioredis', () => { + return jest.fn().mockImplementation(() => ({ + on: jest.fn(), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + publish: jest.fn(), + subscribe: jest.fn(), + })); +}); + +// Mock logger to avoid noise +jest.mock('./src/shared/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + auth: jest.fn(), + }, + Logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + auth: jest.fn(), + } +})); diff --git a/backend/package-lock.json b/backend/package-lock.json index c5266d5..b11d600 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@fastify/cors": "^8.4.0", "@fastify/helmet": "^11.1.1", - "@fastify/jwt": "^10.0.0", + "@fastify/jwt": "^8.0.0", "@fastify/multipart": "^8.0.0", "@fastify/rate-limit": "^9.0.1", "@fastify/swagger": "^8.12.0", @@ -781,7 +781,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1536,60 +1535,18 @@ } }, "node_modules/@fastify/jwt": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz", - "integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-8.0.1.tgz", + "integrity": "sha512-295bd7V6bDCnZOu8MAQgM6r7V1KILB+kdEq1q6nbHfXCnML569n7NSo3WzeLDG6IAqDl+Rhzi1vjxwaNHhRCBA==", "license": "MIT", "dependencies": { - "@fastify/error": "^4.2.0", - "@lukeed/ms": "^2.0.2", - "fast-jwt": "^6.0.2", - "fastify-plugin": "^5.0.1", + "@fastify/error": "^3.0.0", + "@lukeed/ms": "^2.0.0", + "fast-jwt": "^4.0.0", + "fastify-plugin": "^4.0.0", "steed": "^1.1.3" } }, - "node_modules/@fastify/jwt/node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/jwt/node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/merge-json-schemas": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", @@ -3181,7 +3138,6 @@ "resolved": "https://registry.npmjs.org/@polkadot/util/-/util-12.6.2.tgz", "integrity": "sha512-l8TubR7CLEY47240uki0TQzFvtnxFIO7uI/0GoWzpYD/O62EIAMRsuY01N4DuwgKq2ZWD59WhzsLYmA5K6ksdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@polkadot/x-bigint": "12.6.2", "@polkadot/x-global": "12.6.2", @@ -3367,7 +3323,6 @@ "resolved": "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-12.6.2.tgz", "integrity": "sha512-Vr8uG7rH2IcNJwtyf5ebdODMcr0XjoCpUbI91Zv6AlKVYOGKZlKLYJHIwpTaKKB+7KPWyQrk4Mlym/rS7v9feg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@polkadot/x-global": "12.6.2", "tslib": "^2.6.2" @@ -3497,7 +3452,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4630,7 +4584,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4850,7 +4803,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -5041,7 +4993,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5541,7 +5492,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -6459,7 +6409,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6860,27 +6809,18 @@ "license": "MIT" }, "node_modules/fast-jwt": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.0.2.tgz", - "integrity": "sha512-dTF4bhYnuXhZYQUaxsHKqAyA5y/L/kQc4fUu0wQ0BSA0dMfcNrcv0aqR2YnVi4f7e1OnzDVU7sDsNdzl1O5EVA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-4.0.5.tgz", + "integrity": "sha512-QnpNdn0955GT7SlT8iMgYfhTsityUWysrQjM+Q7bGFijLp6+TNWzlbSMPvgalbrQGRg4ZaHZgMcns5fYOm5avg==", "license": "Apache-2.0", "dependencies": { - "@lukeed/ms": "^2.0.2", + "@lukeed/ms": "^2.0.1", "asn1.js": "^5.4.1", "ecdsa-sig-formatter": "^1.0.11", - "mnemonist": "^0.40.0" + "mnemonist": "^0.39.5" }, "engines": { - "node": ">=20" - } - }, - "node_modules/fast-jwt/node_modules/mnemonist": { - "version": "0.40.3", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", - "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", - "license": "MIT", - "dependencies": { - "obliterator": "^2.0.4" + "node": ">=16" } }, "node_modules/fast-levenshtein": { @@ -7978,7 +7918,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9945,7 +9884,6 @@ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -10383,7 +10321,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10699,6 +10636,16 @@ "node": ">=8" } }, + "node_modules/smoldot": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-2.0.22.tgz", + "integrity": "sha512-B50vRgTY6v3baYH6uCgL15tfaag5tcS2o/P5q1OiXcKGv1axZDfz2dzzMuIkVpyMR2ug11F6EAtQlmYBQd292g==", + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", + "optional": true, + "dependencies": { + "ws": "^8.8.1" + } + }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -11241,7 +11188,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11369,7 +11315,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12067,7 +12012,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -12178,7 +12122,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index 11cd17a..644d9a6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,7 +38,7 @@ "dependencies": { "@fastify/cors": "^8.4.0", "@fastify/helmet": "^11.1.1", - "@fastify/jwt": "^10.0.0", + "@fastify/jwt": "^8.0.0", "@fastify/multipart": "^8.0.0", "@fastify/rate-limit": "^9.0.1", "@fastify/swagger": "^8.12.0", diff --git a/backend/src/__tests__/rate_limit.test.ts b/backend/src/__tests__/rate_limit.test.ts new file mode 100644 index 0000000..430bea6 --- /dev/null +++ b/backend/src/__tests__/rate_limit.test.ts @@ -0,0 +1,79 @@ +import 'reflect-metadata'; +import { App } from '../app'; + +// Mock database and redis to avoid connection attempts during app initialization +jest.mock('../shared/database', () => ({ + database: { + connect: jest.fn(), + disconnect: jest.fn(), + prisma: { + $connect: jest.fn(), + $disconnect: jest.fn(), + } + }, + prisma: { + user: { + findUnique: jest.fn(), + } + } +})); + +jest.mock('../shared/redis', () => ({ + redisService: { + connect: jest.fn(), + disconnect: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + initializeRedis: jest.fn(), + closeRedis: jest.fn(), +})); + +describe('Rate Limiting Integration Test', () => { + let app: App; + + beforeAll(async () => { + // Instantiate app + app = new App(); + // Initialize (register plugins) + await app.initialize(); + + // Ensure Fastify is ready + await app.server.ready(); + }); + + afterAll(async () => { + await app.stop(); + }); + + it('should return 429 when rate limit is exceeded', async () => { + const limit = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5'); + + // Make requests up to the limit + for (let i = 0; i < limit; i++) { + const response = await app.server.inject({ + method: 'GET', + url: '/health' + }); + expect(response.statusCode).toBe(200); + } + + // The next request should be blocked + const response = await app.server.inject({ + method: 'GET', + url: '/health' + }); + + expect(response.statusCode).toBe(429); + + const body = JSON.parse(response.payload); + // The error handler wraps the response in { success: false, error: { ... } } + expect(body.error.code).toBe(429); + // The message might be "Rate Limit Exceeded" or the custom message + // app.ts sets error: 'Rate Limit Exceeded' in errorResponseBuilder, but errorHandler puts it in body.error.message? + // app.ts: message: `Muitas tentativas...` + // errorHandler: message: error.message + // If rate-limit throws the object from builder, error.message is likely the message from builder. + expect(body.error.message).toContain('Muitas tentativas'); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 22bd2dd..23968a6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -25,7 +25,7 @@ import { projectRoutes } from './modules/projects/project.routes'; import { analyticsRoutes } from './modules/analytics/analytics.routes'; import { amaRoutes } from './modules/ama/ama.routes'; -class App { +export class App { public server: FastifyInstance; constructor() { @@ -38,7 +38,7 @@ class App { this.setupErrorHandling(); } - private async initialize(): Promise { + public async initialize(): Promise { await this.setupMiddlewares(); await this.setupRoutes(); } @@ -64,17 +64,17 @@ class App { }, }); - // Rate Limiting - DESABILITADO PARA DESENVOLVIMENTO - // await this.server.register(rateLimit, { - // max: envConfig.RATE_LIMIT_MAX_REQUESTS, - // timeWindow: envConfig.RATE_LIMIT_WINDOW_MS, - // errorResponseBuilder: (request, context) => ({ - // code: 429, - // error: 'Rate Limit Exceeded', - // message: `Muitas tentativas. Tente novamente em ${Math.round(context.ttl / 1000)} segundos.`, - // expiresIn: context.ttl, - // }), - // }); + // Rate Limiting + await this.server.register(rateLimit, { + max: envConfig.RATE_LIMIT_MAX_REQUESTS, + timeWindow: envConfig.RATE_LIMIT_WINDOW_MS, + errorResponseBuilder: (request, context) => ({ + code: 429, + error: 'Rate Limit Exceeded', + message: `Muitas tentativas. Tente novamente em ${Math.round(context.ttl / 1000)} segundos.`, + expiresIn: context.ttl, + }), + }); // Swagger Documentation if (envConfig.ENABLE_SWAGGER) { diff --git a/backend/src/shared/middleware/error.middleware.ts b/backend/src/shared/middleware/error.middleware.ts index 57b3abf..3614254 100644 --- a/backend/src/shared/middleware/error.middleware.ts +++ b/backend/src/shared/middleware/error.middleware.ts @@ -46,6 +46,8 @@ export const errorHandler = (server: FastifyInstance) => { let statusCode = 500; if (error.statusCode) { statusCode = error.statusCode; + } else if ((error as any).code === 429 || error.code === '429') { + statusCode = 429; } else if (error.code === 'FST_JWT_NO_AUTHORIZATION_IN_HEADER') { statusCode = 401; } else if (error.code === 'FST_JWT_AUTHORIZATION_TOKEN_EXPIRED') {