- fix(pagination): SQL injection em cursor pagination - validação de nomes de campos - fix(transaction): substituir Math.random() por crypto.randomBytes() para jitter - fix(monitoring): memory leak - adicionar .unref() ao setInterval - docs: adicionar relatório completo de bugs (BUG-REPORT-2026-01-31.md) - chore: actualizar versão para 1.2.4
1335 lines
33 KiB
Markdown
1335 lines
33 KiB
Markdown
# Plano de Melhorias de Segurança
|
|
## MCP Outline PostgreSQL v1.2.2 → v1.3.0
|
|
|
|
**Data:** 2026-01-31
|
|
**Objectivo:** Implementar melhorias de segurança identificadas na auditoria
|
|
**Score Actual:** 8.5/10
|
|
**Score Alvo:** 9.5/10
|
|
**Esforço Total Estimado:** 10-15 dias
|
|
|
|
---
|
|
|
|
## Visão Geral
|
|
|
|
Este plano implementa as recomendações da auditoria de segurança, priorizadas em 4 níveis:
|
|
- **P0 (Crítico):** Implementar ANTES de produção
|
|
- **P1 (Alto):** Implementar em 1-2 semanas
|
|
- **P2 (Médio):** Implementar em 1 mês
|
|
- **P3 (Baixo):** Backlog
|
|
|
|
---
|
|
|
|
## Fase 1: P0 - Crítico (5-8 dias)
|
|
|
|
### Tarefa 1.1: Sistema de Autenticação/Autorização
|
|
|
|
**Objectivo:** Implementar contexto de utilizador e verificação de permissões em todas as tools.
|
|
|
|
**Esforço:** 3-5 dias
|
|
**Prioridade:** P0 - Crítico
|
|
|
|
#### Ficheiros a Modificar
|
|
|
|
**Novos Ficheiros:**
|
|
- [NEW] [auth-context.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/utils/auth-context.ts)
|
|
- [NEW] [permissions.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/utils/permissions.ts)
|
|
|
|
**Ficheiros a Modificar:**
|
|
- [MODIFY] [types/tools.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/types/tools.ts)
|
|
- [MODIFY] [index.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/index.ts)
|
|
- [MODIFY] Todos os 33 módulos em [tools/](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/tools/)
|
|
|
|
#### Passos de Implementação
|
|
|
|
**1.1.1 - Criar Interface de Contexto**
|
|
|
|
Criar `src/utils/auth-context.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* MCP Outline PostgreSQL - Authentication Context
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
|
|
export interface MCPContext {
|
|
userId: string;
|
|
role: 'admin' | 'member' | 'viewer';
|
|
teamId: string;
|
|
email: string;
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* Extract context from MCP request metadata
|
|
*/
|
|
export function extractContext(metadata?: Record<string, unknown>): MCPContext {
|
|
if (!metadata || !metadata.userId) {
|
|
throw new Error('Authentication required: No user context provided');
|
|
}
|
|
|
|
return {
|
|
userId: String(metadata.userId),
|
|
role: (metadata.role as 'admin' | 'member' | 'viewer') || 'member',
|
|
teamId: String(metadata.teamId || ''),
|
|
email: String(metadata.email || ''),
|
|
name: String(metadata.name || 'Unknown')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate context has required role
|
|
*/
|
|
export function requireRole(context: MCPContext, requiredRole: 'admin' | 'member' | 'viewer'): void {
|
|
const roleHierarchy = { admin: 3, member: 2, viewer: 1 };
|
|
|
|
if (roleHierarchy[context.role] < roleHierarchy[requiredRole]) {
|
|
throw new Error(`Unauthorized: ${requiredRole} role required`);
|
|
}
|
|
}
|
|
```
|
|
|
|
**1.1.2 - Criar Sistema de Permissões**
|
|
|
|
Criar `src/utils/permissions.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* MCP Outline PostgreSQL - Permissions System
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import { MCPContext } from './auth-context.js';
|
|
|
|
export type Resource = 'document' | 'collection' | 'user' | 'team';
|
|
export type Action = 'read' | 'write' | 'delete' | 'admin';
|
|
|
|
/**
|
|
* Check if user has permission for action on resource
|
|
*/
|
|
export async function checkPermission(
|
|
pgClient: Pool,
|
|
context: MCPContext,
|
|
resource: Resource,
|
|
resourceId: string,
|
|
action: Action
|
|
): Promise<boolean> {
|
|
// Admins have all permissions
|
|
if (context.role === 'admin') {
|
|
return true;
|
|
}
|
|
|
|
// Viewers can only read
|
|
if (context.role === 'viewer' && action !== 'read') {
|
|
return false;
|
|
}
|
|
|
|
// Check resource-specific permissions
|
|
switch (resource) {
|
|
case 'document':
|
|
return await checkDocumentPermission(pgClient, context, resourceId, action);
|
|
case 'collection':
|
|
return await checkCollectionPermission(pgClient, context, resourceId, action);
|
|
case 'user':
|
|
return await checkUserPermission(pgClient, context, resourceId, action);
|
|
case 'team':
|
|
return context.role === 'admin';
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function checkDocumentPermission(
|
|
pgClient: Pool,
|
|
context: MCPContext,
|
|
documentId: string,
|
|
action: Action
|
|
): Promise<boolean> {
|
|
// Check if user is creator
|
|
const result = await pgClient.query(
|
|
`SELECT "createdById", "collectionId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL`,
|
|
[documentId]
|
|
);
|
|
|
|
if (result.rows.length === 0) return false;
|
|
|
|
const doc = result.rows[0];
|
|
|
|
// Creator can do anything except admin actions
|
|
if (doc.createdById === context.userId && action !== 'admin') {
|
|
return true;
|
|
}
|
|
|
|
// Check collection permissions
|
|
return await checkCollectionPermission(pgClient, context, doc.collectionId, action);
|
|
}
|
|
|
|
async function checkCollectionPermission(
|
|
pgClient: Pool,
|
|
context: MCPContext,
|
|
collectionId: string,
|
|
action: Action
|
|
): Promise<boolean> {
|
|
const result = await pgClient.query(
|
|
`SELECT permission FROM collection_users
|
|
WHERE "userId" = $1 AND "collectionId" = $2`,
|
|
[context.userId, collectionId]
|
|
);
|
|
|
|
if (result.rows.length === 0) return false;
|
|
|
|
const permission = result.rows[0].permission;
|
|
|
|
// Map permissions to actions
|
|
const permissionMap: Record<string, Action[]> = {
|
|
admin: ['read', 'write', 'delete', 'admin'],
|
|
read_write: ['read', 'write'],
|
|
read: ['read']
|
|
};
|
|
|
|
return permissionMap[permission]?.includes(action) || false;
|
|
}
|
|
|
|
async function checkUserPermission(
|
|
pgClient: Pool,
|
|
context: MCPContext,
|
|
userId: string,
|
|
action: Action
|
|
): Promise<boolean> {
|
|
// Users can read/write their own profile
|
|
if (userId === context.userId && action !== 'delete' && action !== 'admin') {
|
|
return true;
|
|
}
|
|
|
|
// Only admins can modify other users
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Require permission or throw error
|
|
*/
|
|
export async function requirePermission(
|
|
pgClient: Pool,
|
|
context: MCPContext,
|
|
resource: Resource,
|
|
resourceId: string,
|
|
action: Action
|
|
): Promise<void> {
|
|
const hasPermission = await checkPermission(pgClient, context, resourceId, action);
|
|
|
|
if (!hasPermission) {
|
|
throw new Error(`Unauthorized: ${action} permission required for ${resource} ${resourceId}`);
|
|
}
|
|
}
|
|
```
|
|
|
|
**1.1.3 - Actualizar Interface BaseTool**
|
|
|
|
Modificar `src/types/tools.ts`:
|
|
|
|
```typescript
|
|
import { MCPContext } from '../utils/auth-context.js';
|
|
|
|
export interface BaseTool<T = any> {
|
|
name: string;
|
|
description: string;
|
|
inputSchema: {
|
|
type: 'object';
|
|
properties: Record<string, any>;
|
|
required?: string[];
|
|
};
|
|
// Adicionar contexto ao handler
|
|
handler: (args: T, pgClient: any, context: MCPContext) => Promise<ToolResponse>;
|
|
}
|
|
```
|
|
|
|
**1.1.4 - Actualizar Exemplo de Tool**
|
|
|
|
Modificar `src/tools/documents.ts` (exemplo):
|
|
|
|
```typescript
|
|
import { requireRole } from '../utils/auth-context.js';
|
|
import { requirePermission } from '../utils/permissions.js';
|
|
|
|
const deleteDocument: BaseTool<{ id: string; permanent?: boolean }> = {
|
|
name: 'delete_document',
|
|
description: 'Eliminar documento (soft delete por default, permanente se especificado).',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string', description: 'UUID do documento' },
|
|
permanent: { type: 'boolean', description: 'Eliminação permanente (irreversível, default: false)' }
|
|
},
|
|
required: ['id']
|
|
},
|
|
handler: async (args, pgClient, context): Promise<ToolResponse> => {
|
|
try {
|
|
if (!isValidUUID(args.id)) {
|
|
throw new Error('id inválido (deve ser UUID)');
|
|
}
|
|
|
|
// ✅ NOVO: Verificar permissões
|
|
await requirePermission(
|
|
pgClient,
|
|
context,
|
|
'document',
|
|
args.id,
|
|
args.permanent ? 'admin' : 'delete'
|
|
);
|
|
|
|
let query: string;
|
|
|
|
if (args.permanent) {
|
|
// ✅ NOVO: Apenas admins podem fazer delete permanente
|
|
requireRole(context, 'admin');
|
|
query = `DELETE FROM documents WHERE id = $1 RETURNING id`;
|
|
} else {
|
|
query = `UPDATE documents
|
|
SET "deletedAt" = NOW(), "deletedById" = $2
|
|
WHERE id = $1 AND "deletedAt" IS NULL
|
|
RETURNING id, "deletedAt"`;
|
|
}
|
|
|
|
const result = await pgClient.query(
|
|
query,
|
|
args.permanent ? [args.id] : [args.id, context.userId] // ✅ NOVO: Usar userId real
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({ error: 'Documento não encontrado ou já eliminado' }, null, 2)
|
|
}]
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: true,
|
|
message: args.permanent ? 'Documento eliminado permanentemente' : 'Documento eliminado (soft delete)',
|
|
id: result.rows[0].id
|
|
}, null, 2)
|
|
}]
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
**1.1.5 - Actualizar MCP Entry Point**
|
|
|
|
Modificar `src/index.ts`:
|
|
|
|
```typescript
|
|
import { extractContext } from './utils/auth-context.js';
|
|
|
|
// No handler de cada tool call
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
try {
|
|
// ✅ NOVO: Extrair contexto de autenticação
|
|
const context = extractContext(request.params._meta);
|
|
|
|
const tool = allTools.find(t => t.name === request.params.name);
|
|
if (!tool) {
|
|
throw new Error(`Tool not found: ${request.params.name}`);
|
|
}
|
|
|
|
// ✅ NOVO: Passar contexto ao handler
|
|
const result = await tool.handler(request.params.arguments || {}, pgClient, context);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
});
|
|
```
|
|
|
|
#### Testes de Verificação
|
|
|
|
```bash
|
|
# 1. Testar autenticação obrigatória
|
|
# Deve falhar sem contexto
|
|
curl -X POST http://localhost:3000/mcp \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"method": "tools/call", "params": {"name": "delete_document", "arguments": {"id": "..."}}}'
|
|
|
|
# 2. Testar verificação de permissões
|
|
# Deve falhar se utilizador não tiver permissão
|
|
curl -X POST http://localhost:3000/mcp \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"method": "tools/call", "params": {"name": "delete_document", "arguments": {"id": "..."}, "_meta": {"userId": "...", "role": "viewer"}}}'
|
|
|
|
# 3. Testar operação autorizada
|
|
# Deve ter sucesso
|
|
curl -X POST http://localhost:3000/mcp \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"method": "tools/call", "params": {"name": "delete_document", "arguments": {"id": "..."}, "_meta": {"userId": "...", "role": "admin"}}}'
|
|
```
|
|
|
|
---
|
|
|
|
### Tarefa 1.2: Implementar Audit Log
|
|
|
|
**Objectivo:** Registar todas as operações sensíveis numa tabela de auditoria.
|
|
|
|
**Esforço:** 2-3 dias
|
|
**Prioridade:** P0 - Crítico
|
|
|
|
#### Ficheiros a Criar/Modificar
|
|
|
|
**Novos Ficheiros:**
|
|
- [NEW] [migrations/001_create_audit_log.sql](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/migrations/001_create_audit_log.sql)
|
|
- [NEW] [audit-log.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/utils/audit-log.ts)
|
|
|
|
**Ficheiros a Modificar:**
|
|
- [MODIFY] Todos os tools que fazem operações de escrita
|
|
|
|
#### Passos de Implementação
|
|
|
|
**1.2.1 - Criar Tabela de Audit Log**
|
|
|
|
Criar `migrations/001_create_audit_log.sql`:
|
|
|
|
```sql
|
|
-- Audit Log Table
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
user_id UUID REFERENCES users(id),
|
|
user_email VARCHAR(255),
|
|
user_role VARCHAR(50),
|
|
action VARCHAR(100) NOT NULL,
|
|
resource VARCHAR(50) NOT NULL,
|
|
resource_id UUID,
|
|
result VARCHAR(20) NOT NULL,
|
|
details JSONB,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
duration_ms INTEGER
|
|
);
|
|
|
|
-- Indexes for performance
|
|
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
|
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
|
|
CREATE INDEX idx_audit_log_resource ON audit_log(resource, resource_id);
|
|
CREATE INDEX idx_audit_log_action ON audit_log(action);
|
|
CREATE INDEX idx_audit_log_result ON audit_log(result);
|
|
|
|
-- Partition by month (optional, for large volumes)
|
|
-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
|
|
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
|
|
|
COMMENT ON TABLE audit_log IS 'Audit trail of all sensitive operations';
|
|
COMMENT ON COLUMN audit_log.action IS 'Operation performed (e.g., delete_document, update_user)';
|
|
COMMENT ON COLUMN audit_log.result IS 'Operation result: success, failure, unauthorized';
|
|
COMMENT ON COLUMN audit_log.details IS 'Additional context (before/after values, error messages)';
|
|
```
|
|
|
|
**1.2.2 - Criar Módulo de Audit Log**
|
|
|
|
Criar `src/utils/audit-log.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* MCP Outline PostgreSQL - Audit Log
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import { MCPContext } from './auth-context.js';
|
|
import { logger } from './logger.js';
|
|
|
|
export interface AuditLogEntry {
|
|
userId: string;
|
|
userEmail: string;
|
|
userRole: string;
|
|
action: string;
|
|
resource: string;
|
|
resourceId?: string;
|
|
result: 'success' | 'failure' | 'unauthorized';
|
|
details?: Record<string, unknown>;
|
|
ipAddress?: string;
|
|
userAgent?: string;
|
|
durationMs?: number;
|
|
}
|
|
|
|
/**
|
|
* Log operation to audit_log table
|
|
*/
|
|
export async function logAudit(
|
|
pgClient: Pool,
|
|
context: MCPContext,
|
|
entry: Omit<AuditLogEntry, 'userId' | 'userEmail' | 'userRole'>
|
|
): Promise<void> {
|
|
try {
|
|
await pgClient.query(
|
|
`INSERT INTO audit_log (
|
|
user_id, user_email, user_role, action, resource, resource_id,
|
|
result, details, ip_address, user_agent, duration_ms
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
[
|
|
context.userId,
|
|
context.email,
|
|
context.role,
|
|
entry.action,
|
|
entry.resource,
|
|
entry.resourceId || null,
|
|
entry.result,
|
|
entry.details ? JSON.stringify(entry.details) : null,
|
|
entry.ipAddress || null,
|
|
entry.userAgent || null,
|
|
entry.durationMs || null
|
|
]
|
|
);
|
|
} catch (error) {
|
|
// Don't fail operation if audit log fails, but log error
|
|
logger.error('Failed to write audit log', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
entry
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to wrap operation with audit logging
|
|
*/
|
|
export async function withAudit<T>(
|
|
pgClient: Pool,
|
|
context: MCPContext,
|
|
action: string,
|
|
resource: string,
|
|
resourceId: string | undefined,
|
|
operation: () => Promise<T>
|
|
): Promise<T> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const result = await operation();
|
|
|
|
await logAudit(pgClient, context, {
|
|
action,
|
|
resource,
|
|
resourceId,
|
|
result: 'success',
|
|
durationMs: Date.now() - startTime
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
await logAudit(pgClient, context, {
|
|
action,
|
|
resource,
|
|
resourceId,
|
|
result: error instanceof Error && error.message.includes('Unauthorized') ? 'unauthorized' : 'failure',
|
|
details: {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
},
|
|
durationMs: Date.now() - startTime
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
**1.2.3 - Integrar Audit Log nas Tools**
|
|
|
|
Modificar `src/tools/documents.ts` (exemplo):
|
|
|
|
```typescript
|
|
import { withAudit } from '../utils/audit-log.js';
|
|
|
|
const deleteDocument: BaseTool<{ id: string; permanent?: boolean }> = {
|
|
// ... schema ...
|
|
handler: async (args, pgClient, context): Promise<ToolResponse> => {
|
|
return await withAudit(
|
|
pgClient,
|
|
context,
|
|
args.permanent ? 'delete_document_permanent' : 'delete_document',
|
|
'document',
|
|
args.id,
|
|
async () => {
|
|
// Lógica existente aqui
|
|
// ...
|
|
}
|
|
);
|
|
}
|
|
};
|
|
```
|
|
|
|
**1.2.4 - Executar Migration**
|
|
|
|
```bash
|
|
# Executar migration
|
|
psql $DATABASE_URL -f migrations/001_create_audit_log.sql
|
|
|
|
# Verificar tabela criada
|
|
psql $DATABASE_URL -c "\d audit_log"
|
|
```
|
|
|
|
#### Testes de Verificação
|
|
|
|
```sql
|
|
-- 1. Verificar logs de operações
|
|
SELECT
|
|
timestamp,
|
|
user_email,
|
|
action,
|
|
resource,
|
|
result,
|
|
duration_ms
|
|
FROM audit_log
|
|
ORDER BY timestamp DESC
|
|
LIMIT 10;
|
|
|
|
-- 2. Verificar operações falhadas
|
|
SELECT
|
|
timestamp,
|
|
user_email,
|
|
action,
|
|
details->>'error' as error_message
|
|
FROM audit_log
|
|
WHERE result = 'failure'
|
|
ORDER BY timestamp DESC;
|
|
|
|
-- 3. Verificar tentativas não autorizadas
|
|
SELECT
|
|
timestamp,
|
|
user_email,
|
|
user_role,
|
|
action,
|
|
resource
|
|
FROM audit_log
|
|
WHERE result = 'unauthorized'
|
|
ORDER BY timestamp DESC;
|
|
|
|
-- 4. Estatísticas por utilizador
|
|
SELECT
|
|
user_email,
|
|
COUNT(*) as total_operations,
|
|
COUNT(*) FILTER (WHERE result = 'success') as successful,
|
|
COUNT(*) FILTER (WHERE result = 'failure') as failed,
|
|
COUNT(*) FILTER (WHERE result = 'unauthorized') as unauthorized
|
|
FROM audit_log
|
|
GROUP BY user_email
|
|
ORDER BY total_operations DESC;
|
|
```
|
|
|
|
---
|
|
|
|
## Fase 2: P1 - Alto (3-4 dias)
|
|
|
|
### Tarefa 2.1: Activar Query Logging
|
|
|
|
**Objectivo:** Registar todas as queries de escrita para debugging e auditoria.
|
|
|
|
**Esforço:** 1 dia
|
|
**Prioridade:** P1 - Alto
|
|
|
|
#### Ficheiros a Modificar
|
|
|
|
- [MODIFY] [logger.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/utils/logger.ts)
|
|
- [MODIFY] [pg-client.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/pg-client.ts)
|
|
- [MODIFY] [.env.example](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/.env.example)
|
|
|
|
#### Passos de Implementação
|
|
|
|
**2.1.1 - Melhorar Logger**
|
|
|
|
Modificar `src/utils/logger.ts`:
|
|
|
|
```typescript
|
|
export function logQuery(
|
|
sql: string,
|
|
params?: any[],
|
|
duration?: number,
|
|
userId?: string
|
|
): void {
|
|
// ✅ NOVO: Activar em produção para queries de escrita
|
|
const isWriteQuery = /^(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i.test(sql.trim());
|
|
|
|
if (process.env.ENABLE_AUDIT_LOG === 'true' || (process.env.NODE_ENV === 'production' && isWriteQuery)) {
|
|
logger.info('SQL', {
|
|
sql: sql.substring(0, 200), // Aumentar limite
|
|
params: params ? params.length : 0,
|
|
duration,
|
|
userId,
|
|
type: isWriteQuery ? 'WRITE' : 'READ'
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**2.1.2 - Actualizar PgClient**
|
|
|
|
Modificar `src/pg-client.ts`:
|
|
|
|
```typescript
|
|
async query<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<T[]> {
|
|
const start = Date.now();
|
|
try {
|
|
const result = await this.pool.query<T>(sql, params);
|
|
const duration = Date.now() - start;
|
|
|
|
// ✅ NOVO: Log com mais detalhes
|
|
logQuery(sql, params, duration);
|
|
|
|
// ✅ NOVO: Alertar queries lentas
|
|
if (duration > 1000) {
|
|
logger.warn('Slow query detected', {
|
|
sql: sql.substring(0, 200),
|
|
duration,
|
|
rowCount: result.rowCount
|
|
});
|
|
}
|
|
|
|
return result.rows;
|
|
} catch (error) {
|
|
const duration = Date.now() - start;
|
|
logger.error('Query failed', {
|
|
sql: sql.substring(0, 200),
|
|
duration,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
**2.1.3 - Actualizar .env.example**
|
|
|
|
```bash
|
|
# Logging
|
|
LOG_LEVEL=info # error, warn, info, debug
|
|
ENABLE_AUDIT_LOG=true
|
|
SLOW_QUERY_THRESHOLD_MS=1000
|
|
```
|
|
|
|
#### Testes de Verificação
|
|
|
|
```bash
|
|
# 1. Verificar logs de queries
|
|
tail -f logs/app.log | grep SQL
|
|
|
|
# 2. Verificar queries lentas
|
|
tail -f logs/app.log | grep "Slow query"
|
|
|
|
# 3. Verificar queries de escrita
|
|
tail -f logs/app.log | grep "WRITE"
|
|
```
|
|
|
|
---
|
|
|
|
### Tarefa 2.2: Melhorar Gestão de Erros
|
|
|
|
**Objectivo:** Sanitizar mensagens de erro para não expor detalhes internos.
|
|
|
|
**Esforço:** 2 dias
|
|
**Prioridade:** P1 - Alto
|
|
|
|
#### Ficheiros a Criar/Modificar
|
|
|
|
**Novos Ficheiros:**
|
|
- [NEW] [error-handler.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/utils/error-handler.ts)
|
|
|
|
**Ficheiros a Modificar:**
|
|
- [MODIFY] Todos os tools (handlers)
|
|
|
|
#### Passos de Implementação
|
|
|
|
**2.2.1 - Criar Error Handler**
|
|
|
|
Criar `src/utils/error-handler.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* MCP Outline PostgreSQL - Error Handler
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
|
|
import { logger } from './logger.js';
|
|
|
|
export class AppError extends Error {
|
|
constructor(
|
|
public message: string,
|
|
public statusCode: number = 500,
|
|
public code: string = 'INTERNAL_ERROR',
|
|
public details?: Record<string, unknown>
|
|
) {
|
|
super(message);
|
|
this.name = 'AppError';
|
|
}
|
|
}
|
|
|
|
export class ValidationError extends AppError {
|
|
constructor(message: string, details?: Record<string, unknown>) {
|
|
super(message, 400, 'VALIDATION_ERROR', details);
|
|
this.name = 'ValidationError';
|
|
}
|
|
}
|
|
|
|
export class UnauthorizedError extends AppError {
|
|
constructor(message: string = 'Unauthorized') {
|
|
super(message, 401, 'UNAUTHORIZED');
|
|
this.name = 'UnauthorizedError';
|
|
}
|
|
}
|
|
|
|
export class NotFoundError extends AppError {
|
|
constructor(resource: string, id?: string) {
|
|
super(
|
|
id ? `${resource} with id ${id} not found` : `${resource} not found`,
|
|
404,
|
|
'NOT_FOUND'
|
|
);
|
|
this.name = 'NotFoundError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize error for client response
|
|
*/
|
|
export function sanitizeError(error: unknown): { error: string; code?: string; details?: Record<string, unknown> } {
|
|
if (error instanceof AppError) {
|
|
return {
|
|
error: error.message,
|
|
code: error.code,
|
|
...(process.env.NODE_ENV !== 'production' && error.details && { details: error.details })
|
|
};
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
// Log full error internally
|
|
logger.error('Unhandled error', {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack
|
|
});
|
|
|
|
// Return generic message to client in production
|
|
if (process.env.NODE_ENV === 'production') {
|
|
return {
|
|
error: 'An internal error occurred',
|
|
code: 'INTERNAL_ERROR'
|
|
};
|
|
} else {
|
|
return {
|
|
error: error.message,
|
|
code: 'INTERNAL_ERROR'
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
error: 'An unknown error occurred',
|
|
code: 'UNKNOWN_ERROR'
|
|
};
|
|
}
|
|
```
|
|
|
|
**2.2.2 - Actualizar Tools**
|
|
|
|
Modificar `src/tools/documents.ts` (exemplo):
|
|
|
|
```typescript
|
|
import { sanitizeError, NotFoundError, ValidationError } from '../utils/error-handler.js';
|
|
|
|
const deleteDocument: BaseTool<{ id: string; permanent?: boolean }> = {
|
|
// ... schema ...
|
|
handler: async (args, pgClient, context): Promise<ToolResponse> => {
|
|
try {
|
|
if (!isValidUUID(args.id)) {
|
|
throw new ValidationError('Invalid document ID format');
|
|
}
|
|
|
|
// ... lógica ...
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new NotFoundError('Document', args.id);
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
success: true,
|
|
message: args.permanent ? 'Document permanently deleted' : 'Document deleted (soft delete)',
|
|
id: result.rows[0].id
|
|
}, null, 2)
|
|
}]
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify(sanitizeError(error), null, 2)
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
#### Testes de Verificação
|
|
|
|
```bash
|
|
# 1. Testar erro de validação
|
|
# Deve retornar mensagem limpa
|
|
curl -X POST http://localhost:3000/mcp \
|
|
-d '{"method": "tools/call", "params": {"name": "delete_document", "arguments": {"id": "invalid"}}}'
|
|
|
|
# 2. Testar erro de não encontrado
|
|
# Deve retornar mensagem limpa
|
|
curl -X POST http://localhost:3000/mcp \
|
|
-d '{"method": "tools/call", "params": {"name": "delete_document", "arguments": {"id": "00000000-0000-0000-0000-000000000000"}}}'
|
|
|
|
# 3. Verificar que detalhes internos não são expostos em produção
|
|
NODE_ENV=production npm start
|
|
```
|
|
|
|
---
|
|
|
|
## Fase 3: P2 - Médio (2-3 dias)
|
|
|
|
### Tarefa 3.1: Rate Limiting Distribuído
|
|
|
|
**Objectivo:** Migrar rate limiting para PostgreSQL para suportar múltiplas instâncias.
|
|
|
|
**Esforço:** 2-3 dias
|
|
**Prioridade:** P2 - Médio
|
|
|
|
#### Ficheiros a Criar/Modificar
|
|
|
|
**Novos Ficheiros:**
|
|
- [NEW] [migrations/002_create_rate_limit.sql](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/migrations/002_create_rate_limit.sql)
|
|
|
|
**Ficheiros a Modificar:**
|
|
- [MODIFY] [security.ts](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/src/utils/security.ts)
|
|
|
|
#### Passos de Implementação
|
|
|
|
**3.1.1 - Criar Tabela de Rate Limiting**
|
|
|
|
Criar `migrations/002_create_rate_limit.sql`:
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS rate_limit (
|
|
key VARCHAR(255) PRIMARY KEY,
|
|
count INTEGER NOT NULL DEFAULT 1,
|
|
reset_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_rate_limit_reset_at ON rate_limit(reset_at);
|
|
|
|
COMMENT ON TABLE rate_limit IS 'Distributed rate limiting store';
|
|
```
|
|
|
|
**3.1.2 - Actualizar Security.ts**
|
|
|
|
Modificar `src/utils/security.ts`:
|
|
|
|
```typescript
|
|
import { Pool } from 'pg';
|
|
|
|
/**
|
|
* Check rate limit using PostgreSQL (distributed)
|
|
*/
|
|
export async function checkRateLimitDistributed(
|
|
pgClient: Pool,
|
|
type: string,
|
|
clientId: string
|
|
): Promise<boolean> {
|
|
const key = `${type}:${clientId}`;
|
|
const now = new Date();
|
|
const resetAt = new Date(now.getTime() + RATE_LIMIT_WINDOW);
|
|
|
|
try {
|
|
// Upsert rate limit entry
|
|
const result = await pgClient.query(`
|
|
INSERT INTO rate_limit (key, count, reset_at)
|
|
VALUES ($1, 1, $2)
|
|
ON CONFLICT (key) DO UPDATE
|
|
SET
|
|
count = CASE
|
|
WHEN rate_limit.reset_at < $3 THEN 1
|
|
ELSE rate_limit.count + 1
|
|
END,
|
|
reset_at = CASE
|
|
WHEN rate_limit.reset_at < $3 THEN $2
|
|
ELSE rate_limit.reset_at
|
|
END,
|
|
updated_at = NOW()
|
|
RETURNING count, reset_at
|
|
`, [key, resetAt, now]);
|
|
|
|
const { count } = result.rows[0];
|
|
|
|
return count <= RATE_LIMIT_MAX;
|
|
} catch (error) {
|
|
logger.error('Rate limit check failed', { error });
|
|
// Fail open - allow request if rate limit check fails
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired rate limit entries (run periodically)
|
|
*/
|
|
export async function cleanupRateLimitDistributed(pgClient: Pool): Promise<void> {
|
|
try {
|
|
await pgClient.query(`
|
|
DELETE FROM rate_limit
|
|
WHERE reset_at < NOW() - INTERVAL '1 hour'
|
|
`);
|
|
} catch (error) {
|
|
logger.error('Rate limit cleanup failed', { error });
|
|
}
|
|
}
|
|
```
|
|
|
|
**3.1.3 - Executar Migration**
|
|
|
|
```bash
|
|
psql $DATABASE_URL -f migrations/002_create_rate_limit.sql
|
|
```
|
|
|
|
#### Testes de Verificação
|
|
|
|
```sql
|
|
-- Verificar entradas de rate limiting
|
|
SELECT * FROM rate_limit ORDER BY updated_at DESC LIMIT 10;
|
|
|
|
-- Verificar cleanup
|
|
SELECT COUNT(*) FROM rate_limit WHERE reset_at < NOW();
|
|
```
|
|
|
|
---
|
|
|
|
### Tarefa 3.2: Melhorar Validações
|
|
|
|
**Objectivo:** Usar biblioteca robusta para validação de emails e adicionar validações de comprimento.
|
|
|
|
**Esforço:** 1-2 dias
|
|
**Prioridade:** P2 - Médio
|
|
|
|
#### Passos de Implementação
|
|
|
|
**3.2.1 - Instalar Dependência**
|
|
|
|
```bash
|
|
npm install validator
|
|
npm install --save-dev @types/validator
|
|
```
|
|
|
|
**3.2.2 - Actualizar Security.ts**
|
|
|
|
Modificar `src/utils/security.ts`:
|
|
|
|
```typescript
|
|
import validator from 'validator';
|
|
|
|
/**
|
|
* Validate email format (improved)
|
|
*/
|
|
export function isValidEmail(email: string): boolean {
|
|
return validator.isEmail(email, {
|
|
allow_utf8_local_part: false,
|
|
require_tld: true
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate and sanitize string input
|
|
*/
|
|
export function validateString(
|
|
input: string,
|
|
fieldName: string,
|
|
minLength: number = 1,
|
|
maxLength: number = 1000
|
|
): string {
|
|
if (typeof input !== 'string') {
|
|
throw new ValidationError(`${fieldName} must be a string`);
|
|
}
|
|
|
|
const sanitized = sanitizeInput(input);
|
|
|
|
if (sanitized.length < minLength) {
|
|
throw new ValidationError(`${fieldName} must be at least ${minLength} characters`);
|
|
}
|
|
|
|
if (sanitized.length > maxLength) {
|
|
throw new ValidationError(`${fieldName} must be at most ${maxLength} characters`);
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* Enhanced sanitizeInput
|
|
*/
|
|
export function sanitizeInput(input: string): string {
|
|
if (typeof input !== 'string') return input;
|
|
|
|
// Remove null bytes
|
|
let sanitized = input.replace(/\0/g, '');
|
|
|
|
// Trim whitespace
|
|
sanitized = sanitized.trim();
|
|
|
|
// Remove control characters (except newlines and tabs)
|
|
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
|
|
return sanitized;
|
|
}
|
|
```
|
|
|
|
**3.2.3 - Actualizar Tools**
|
|
|
|
```typescript
|
|
// Exemplo em users.ts
|
|
const createUser: BaseTool<CreateUserArgs> = {
|
|
handler: async (args, pgClient, context) => {
|
|
const name = validateString(args.name, 'name', 2, 100);
|
|
const email = validateString(args.email, 'email', 5, 255);
|
|
|
|
if (!isValidEmail(email)) {
|
|
throw new ValidationError('Invalid email format');
|
|
}
|
|
|
|
// ... resto da lógica
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Fase 4: P3 - Baixo (1-2 dias)
|
|
|
|
### Tarefa 4.1: Automatizar Updates de Dependências
|
|
|
|
**Objectivo:** Configurar Renovate para automatizar verificação de updates.
|
|
|
|
**Esforço:** 1 dia
|
|
**Prioridade:** P3 - Baixo
|
|
|
|
#### Ficheiros a Criar
|
|
|
|
**Novos Ficheiros:**
|
|
- [NEW] [renovate.json](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/renovate.json)
|
|
|
|
#### Passos de Implementação
|
|
|
|
**4.1.1 - Criar Configuração Renovate**
|
|
|
|
Criar `renovate.json`:
|
|
|
|
```json
|
|
{
|
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
"extends": ["config:base"],
|
|
"packageRules": [
|
|
{
|
|
"matchUpdateTypes": ["minor", "patch"],
|
|
"automerge": true
|
|
},
|
|
{
|
|
"matchUpdateTypes": ["major"],
|
|
"automerge": false,
|
|
"labels": ["dependencies", "major-update"]
|
|
},
|
|
{
|
|
"matchPackagePatterns": ["*"],
|
|
"matchUpdateTypes": ["patch"],
|
|
"schedule": ["before 3am on Monday"]
|
|
}
|
|
],
|
|
"vulnerabilityAlerts": {
|
|
"enabled": true,
|
|
"labels": ["security"]
|
|
}
|
|
}
|
|
```
|
|
|
|
**4.1.2 - Activar Renovate**
|
|
|
|
1. Ir a https://github.com/apps/renovate
|
|
2. Instalar no repositório
|
|
3. Verificar primeiro PR de configuração
|
|
|
|
---
|
|
|
|
### Tarefa 4.2: Documentação de Segurança
|
|
|
|
**Objectivo:** Criar guia de deployment seguro.
|
|
|
|
**Esforço:** 2 dias
|
|
**Prioridade:** P3 - Baixo
|
|
|
|
#### Ficheiros a Criar
|
|
|
|
**Novos Ficheiros:**
|
|
- [NEW] [SECURITY.md](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/SECURITY.md)
|
|
- [NEW] [docs/deployment-guide.md](file:///home/ealmeida/mcp-servers/mcp-outline-postgresql/docs/deployment-guide.md)
|
|
|
|
#### Conteúdo de SECURITY.md
|
|
|
|
```markdown
|
|
# Security Policy
|
|
|
|
## Reporting a Vulnerability
|
|
|
|
If you discover a security vulnerability, please email security@descomplicar.pt.
|
|
|
|
## Supported Versions
|
|
|
|
| Version | Supported |
|
|
| ------- | ------------------ |
|
|
| 1.3.x | :white_check_mark: |
|
|
| 1.2.x | :white_check_mark: |
|
|
| < 1.2 | :x: |
|
|
|
|
## Security Features
|
|
|
|
- SQL Injection Prevention: All queries use parameterized statements
|
|
- Authentication & Authorization: Role-based access control
|
|
- Audit Logging: All sensitive operations logged
|
|
- Rate Limiting: Distributed rate limiting via PostgreSQL
|
|
- Input Validation: Comprehensive validation of all inputs
|
|
|
|
## Security Best Practices
|
|
|
|
1. **Environment Variables**: Never commit `.env` files
|
|
2. **Database Access**: Use least-privilege PostgreSQL user
|
|
3. **Network**: Run MCP server in private network only
|
|
4. **Logging**: Enable audit logging in production
|
|
5. **Updates**: Keep dependencies up to date
|
|
```
|
|
|
|
---
|
|
|
|
## Plano de Verificação Final
|
|
|
|
### Checklist de Implementação
|
|
|
|
**Fase 1 - P0:**
|
|
- [ ] Sistema de autenticação/autorização implementado
|
|
- [ ] Contexto de utilizador em todas as tools
|
|
- [ ] Verificação de permissões funcional
|
|
- [ ] Tabela `audit_log` criada
|
|
- [ ] Audit logging em operações sensíveis
|
|
- [ ] Testes de autenticação passam
|
|
- [ ] Testes de audit log passam
|
|
|
|
**Fase 2 - P1:**
|
|
- [ ] Query logging activado
|
|
- [ ] Queries lentas detectadas
|
|
- [ ] Error handler implementado
|
|
- [ ] Mensagens de erro sanitizadas
|
|
- [ ] Testes de error handling passam
|
|
|
|
**Fase 3 - P2:**
|
|
- [ ] Rate limiting distribuído implementado
|
|
- [ ] Validações melhoradas
|
|
- [ ] Biblioteca `validator` integrada
|
|
- [ ] Testes de rate limiting passam
|
|
|
|
**Fase 4 - P3:**
|
|
- [ ] Renovate configurado
|
|
- [ ] Documentação de segurança criada
|
|
- [ ] Guia de deployment criado
|
|
|
|
### Testes de Segurança Finais
|
|
|
|
```bash
|
|
# 1. Testar autenticação
|
|
npm run test:auth
|
|
|
|
# 2. Testar autorização
|
|
npm run test:permissions
|
|
|
|
# 3. Testar audit log
|
|
npm run test:audit
|
|
|
|
# 4. Testar rate limiting
|
|
npm run test:rate-limit
|
|
|
|
# 5. Testar validações
|
|
npm run test:validation
|
|
|
|
# 6. Verificar dependências
|
|
npm audit
|
|
|
|
# 7. Build de produção
|
|
npm run build
|
|
|
|
# 8. Smoke tests
|
|
npm run test:smoke
|
|
```
|
|
|
|
### Métricas de Sucesso
|
|
|
|
| Métrica | Antes (v1.2.2) | Depois (v1.3.0) | Alvo |
|
|
|---------|----------------|-----------------|------|
|
|
| Score de Segurança | 8.5/10 | 9.5/10 | ≥ 9.0 |
|
|
| Vulnerabilidades P0 | 0 | 0 | 0 |
|
|
| Vulnerabilidades P1 | 3 | 0 | 0 |
|
|
| Cobertura de Audit Log | 0% | 100% | 100% |
|
|
| Operações com Autenticação | 0% | 100% | 100% |
|
|
| Testes de Segurança | 0 | 50+ | ≥ 30 |
|
|
|
|
---
|
|
|
|
## Cronograma
|
|
|
|
| Semana | Tarefas | Esforço |
|
|
|--------|---------|---------|
|
|
| 1 | Tarefa 1.1 (Autenticação) | 3-5 dias |
|
|
| 1-2 | Tarefa 1.2 (Audit Log) | 2-3 dias |
|
|
| 2 | Tarefa 2.1 (Query Logging) | 1 dia |
|
|
| 2 | Tarefa 2.2 (Error Handling) | 2 dias |
|
|
| 3 | Tarefa 3.1 (Rate Limiting) | 2-3 dias |
|
|
| 3 | Tarefa 3.2 (Validações) | 1-2 dias |
|
|
| 4 | Tarefa 4.1 (Renovate) | 1 dia |
|
|
| 4 | Tarefa 4.2 (Documentação) | 2 dias |
|
|
| **Total** | **8 tarefas** | **10-15 dias** |
|
|
|
|
---
|
|
|
|
## Próximos Passos
|
|
|
|
1. **Aprovar este plano** ✅
|
|
2. **Criar branch `feature/security-improvements`**
|
|
3. **Implementar Fase 1 (P0)**
|
|
4. **Code review + testes**
|
|
5. **Implementar Fase 2 (P1)**
|
|
6. **Code review + testes**
|
|
7. **Implementar Fase 3 (P2)**
|
|
8. **Implementar Fase 4 (P3)**
|
|
9. **Testes finais de segurança**
|
|
10. **Merge para `main` e release v1.3.0**
|
|
|
|
---
|
|
|
|
**Plano criado por:** Antigravity AI
|
|
**Data:** 2026-01-31
|
|
**Versão:** 1.0
|