Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2026-02-02 - Fastify Rate Limit Error Handling
**Vulnerability:** API endpoints were vulnerable to DoS/brute force because rate limiting was disabled.
**Learning:** `@fastify/rate-limit` errors (code 429) are not automatically mapped to HTTP 429 in a custom global error handler if the handler relies on `error.statusCode` which might be missing on the error object thrown by the plugin. It requires explicit checks for `error.code === 429` (numeric) or ensuring the plugin configuration sets the statusCode on the error object.
**Prevention:** Always verify that security middleware errors (like 429, 401, 403) are correctly intercepted and returned with the proper status code by the global error handler.
6 changes: 6 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFiles: ['./jest.setup.js'],
};
20 changes: 20 additions & 0 deletions backend/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret-key-at-least-32-chars-long-12345';
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test_db';
// Add other required vars to prevent env.config.ts from throwing
process.env.LUNES_RPC_URL = 'http://localhost:9933';
process.env.CONTRACT_ADDRESS = '0x123';
process.env.PRIVATE_KEY = '0x123';
process.env.AWS_ACCESS_KEY_ID = 'test';
process.env.AWS_SECRET_ACCESS_KEY = 'test';
process.env.AWS_BUCKET_NAME = 'test';
process.env.AWS_REGION = 'us-east-1';
process.env.SMTP_USER = 'test';
process.env.SMTP_PASS = 'test';
process.env.KYC_API_KEY = 'test';
process.env.PRICE_ORACLE_API = 'test';
process.env.REDIS_PASSWORD = 'test';

// Rate limit settings for testing
process.env.RATE_LIMIT_WINDOW_MS = '1000'; // 1 second
process.env.RATE_LIMIT_MAX_REQUESTS = '5'; // 5 requests
105 changes: 24 additions & 81 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"dependencies": {
"@fastify/cors": "^8.4.0",
"@fastify/helmet": "^11.1.1",
"@fastify/jwt": "^10.0.0",
"@fastify/jwt": "8.0.1",
"@fastify/multipart": "^8.0.0",
"@fastify/rate-limit": "^9.0.1",
"@fastify/swagger": "^8.12.0",
Expand Down
56 changes: 56 additions & 0 deletions backend/src/__tests__/rate_limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { app } from "../app";

describe("Rate Limit Security", () => {
beforeAll(async () => {
// Mock environment variables are set in jest.setup.js
// RATE_LIMIT_MAX_REQUESTS = 5
// RATE_LIMIT_WINDOW_MS = 1000

// Initialize app but don't listen
// Note: app.start() calls listen, app.initialize() is private in App class.
// However, app.server is exposed.
// We might need to access the underlying instance or force initialization if it's not done in constructor.
// Looking at app.ts, initialize() is called in start().
// We can't easily call private methods.
// But we can verify if rate limit plugin is registered or just try to inject.
// If initialize() is not called, plugins aren't registered.

// We need to modify App class to allow testing or expose initialize.
// Or we can try to cast app to any to call initialize.
await (app as any).initialize();
});

afterAll(async () => {
await app.stop();
});

it("should allow requests within limit", async () => {
const response = await app.server.inject({
method: "GET",
url: "/health",
});
expect(response.statusCode).toBe(200);
});

it("should block excessive requests", async () => {
// Send 10 requests, limit is 5
const requests = [];
for (let i = 0; i < 10; i++) {
requests.push(
app.server.inject({
method: "GET",
url: "/health", // Use health check as it's lightweight
}),
);
}

const responses = await Promise.all(requests);
const success = responses.filter((r) => r.statusCode === 200);
const blocked = responses.filter((r) => r.statusCode === 429);

console.log(`Success: ${success.length}, Blocked: ${blocked.length}`);

// If rate limiting is working, we should see some 429s
expect(blocked.length).toBeGreaterThan(0);
});
});
22 changes: 11 additions & 11 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading