docs: update documentation with new features and security analysis
- Add BACKEND_SECURITY.md with security analysis and hardening guide - Add FRONTEND_TESTING_STRATEGY.md with test coverage strategy - Update API.md with new endpoints (candidates, tickets, credentials) - Update AGENTS.md documentation index
This commit is contained in:
parent
a1ee608611
commit
fd9af24e22
3 changed files with 348 additions and 0 deletions
|
|
@ -98,6 +98,9 @@ When asked to learn or modify parts of the system, consult these files first:
|
||||||
* [API.md](API.md) - Endpoint contracts
|
* [API.md](API.md) - Endpoint contracts
|
||||||
* [API_SECURITY.md](API_SECURITY.md) - Auth, RBAC
|
* [API_SECURITY.md](API_SECURITY.md) - Auth, RBAC
|
||||||
* [APPSEC_STRATEGY.md](APPSEC_STRATEGY.md) - Application Security and Testing
|
* [APPSEC_STRATEGY.md](APPSEC_STRATEGY.md) - Application Security and Testing
|
||||||
|
* [BACKEND_SECURITY.md](BACKEND_SECURITY.md) - Backend Security Analysis & Hardening
|
||||||
* [DATABASE.md](DATABASE.md) - Schema and ERD
|
* [DATABASE.md](DATABASE.md) - Schema and ERD
|
||||||
* [DEVOPS.md](DEVOPS.md) - Cloudflare, Traefik, Containers
|
* [DEVOPS.md](DEVOPS.md) - Cloudflare, Traefik, Containers
|
||||||
|
* [FRONTEND_TESTING_STRATEGY.md](FRONTEND_TESTING_STRATEGY.md) - Frontend Test Coverage Strategy
|
||||||
* [TASKS.md](TASKS.md) / [TEST_USERS.md](TEST_USERS.md)
|
* [TASKS.md](TASKS.md) / [TEST_USERS.md](TEST_USERS.md)
|
||||||
|
|
||||||
|
|
|
||||||
18
docs/API.md
18
docs/API.md
|
|
@ -46,6 +46,24 @@ View detailed interactive Swagger Docs by visiting `/swagger/index.html`.
|
||||||
* `GET /applications`: Lists received applications (for recruiters) or submitted applications (for candidates).
|
* `GET /applications`: Lists received applications (for recruiters) or submitted applications (for candidates).
|
||||||
* `POST /applications/{id}/status`: Updates application status (`reviewing`, `accepted`, `rejected`).
|
* `POST /applications/{id}/status`: Updates application status (`reviewing`, `accepted`, `rejected`).
|
||||||
|
|
||||||
|
### Candidates (Admin)
|
||||||
|
* `GET /candidates`: Lists all candidates with pagination (`?page=1&perPage=10`). Returns stats and pagination metadata.
|
||||||
|
|
||||||
|
### Tickets & Support (Backend)
|
||||||
|
* `POST /support/tickets`: Creates a support ticket. Requires `{ subject, category, priority, message }`. Categories: `bug`, `feature`, `support`, `billing`, `other`.
|
||||||
|
* `GET /support/tickets`: Lists user's tickets.
|
||||||
|
* `GET /support/tickets/{id}`: Get ticket details with messages.
|
||||||
|
* `POST /support/tickets/{id}/messages`: Add message to ticket.
|
||||||
|
* `POST /support/tickets/{id}/close`: Close ticket.
|
||||||
|
|
||||||
|
### Settings & Credentials
|
||||||
|
* `GET /settings/{key}`: Get settings by key (e.g., `theme`).
|
||||||
|
* `POST /settings/{key}`: Save settings.
|
||||||
|
* `GET /credentials`: List configured external services.
|
||||||
|
* `POST /credentials/{service}`: Save credentials for a service.
|
||||||
|
* `DELETE /credentials/{service}`: Delete credentials.
|
||||||
|
* **Supported Services:** `stripe`, `storage` (S3), `cloudflare_config`, `cpanel`, `lavinmq`, `appwrite`, `fcm_service_account`, `smtp`.
|
||||||
|
|
||||||
### Miscellaneous
|
### Miscellaneous
|
||||||
* `GET /health`: Basic ping endpoint.
|
* `GET /health`: Basic ping endpoint.
|
||||||
* `POST /storage/upload-url`: Requests a presigned URL (S3/Cloudflare R2) to upload logos or resumes directly from the browser.
|
* `POST /storage/upload-url`: Requests a presigned URL (S3/Cloudflare R2) to upload logos or resumes directly from the browser.
|
||||||
|
|
|
||||||
327
docs/BACKEND_SECURITY.md
Normal file
327
docs/BACKEND_SECURITY.md
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
# 🔒 Backend Security Analysis - GoHorseJobs
|
||||||
|
|
||||||
|
> **Last Updated:** 2026-02-25
|
||||||
|
> **Analyzed by:** Security Review
|
||||||
|
> **Target:** Go Backend (Go 1.24, net/http, PostgreSQL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Stack Tecnológica Identificada
|
||||||
|
|
||||||
|
| Componente | Tecnologia | Status |
|
||||||
|
|------------|------------|--------|
|
||||||
|
| **Framework/Roteador** | `net/http` nativo | ✅ |
|
||||||
|
| **Banco de Dados** | PostgreSQL + `lib/pq` (SQL nativo) | ✅ |
|
||||||
|
| **Autenticação** | JWT HS256 + Cookies HttpOnly | ⚠️ Requer atenção |
|
||||||
|
| **Validação** | Manual (sem biblioteca estruturada) | ⚠️ Requer atenção |
|
||||||
|
| **Secrets** | `os.Getenv()` | ✅ |
|
||||||
|
| **Logging** | `fmt.Println` / `log.Printf` | ⚠️ Não estruturado |
|
||||||
|
| **Criptografia** | RSA-OAEP + bcrypt + AES-256-GCM | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Vulnerabilidades Identificadas
|
||||||
|
|
||||||
|
### 1. JWT Secret Hardcoded (CRÍTICO)
|
||||||
|
|
||||||
|
**Arquivo:** `internal/utils/jwt.go:10`
|
||||||
|
|
||||||
|
```go
|
||||||
|
var jwtSecret = []byte("your-secret-key-change-this-in-production") // TODO: Move to env var
|
||||||
|
```
|
||||||
|
|
||||||
|
**Risco:** Secret hardcoded no código-fonte. Se o repositório for exposto, todos os tokens podem ser forjados.
|
||||||
|
|
||||||
|
**Status:** ❌ Não corrigido
|
||||||
|
|
||||||
|
**Correção Recomendada:**
|
||||||
|
```go
|
||||||
|
var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if len(jwtSecret) < 32 {
|
||||||
|
panic("JWT_SECRET must be at least 32 characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Logging de Dados Sensíveis (ALTO)
|
||||||
|
|
||||||
|
**Arquivo:** `internal/api/middleware/auth_middleware.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
fmt.Printf("[AUTH DEBUG] Token from Header (first 20 chars): '%s...'\n", token[:min(20, len(token))])
|
||||||
|
fmt.Printf("[AUTH DEBUG] Token VALID! Claims: sub=%v, tenant=%v, roles=%v\n", ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Risco:** Tokens JWT e claims logados em produção. Pode vazar informações sensíveis.
|
||||||
|
|
||||||
|
**Status:** ❌ Não corrigido
|
||||||
|
|
||||||
|
**Correção Recomendada:**
|
||||||
|
```go
|
||||||
|
import "log/slog"
|
||||||
|
|
||||||
|
// Usar níveis de log
|
||||||
|
slog.Debug("Token validated", "sub", claims["sub"], "roles", claims["roles"])
|
||||||
|
|
||||||
|
// Em produção, nunca logar tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Falta de JTI para Revogação (MÉDIO)
|
||||||
|
|
||||||
|
O JWT não inclui `jti` (JWT ID), impossibilitando revogação individual de tokens.
|
||||||
|
|
||||||
|
**Status:** ❌ Não implementado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Guia de Hardening
|
||||||
|
|
||||||
|
### 1️⃣ Prevenção de SQL Injection
|
||||||
|
|
||||||
|
**Status Atual:** ✅ Mitigado
|
||||||
|
|
||||||
|
O código usa placeholders corretamente:
|
||||||
|
```go
|
||||||
|
query := `SELECT id, full_name, email FROM users WHERE role = $1 LIMIT $2`
|
||||||
|
rows, err := s.DB.QueryContext(ctx, query, role, limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boas Práticas:**
|
||||||
|
- ❌ NUNCA usar `fmt.Sprintf` para construir queries
|
||||||
|
- ✅ Sempre usar `$1, $2, ...` placeholders
|
||||||
|
- ✅ Validar input antes de passar para queries
|
||||||
|
|
||||||
|
**Implementação Recomendada:**
|
||||||
|
```go
|
||||||
|
// Wrapper para validação de queries
|
||||||
|
func validateQuery(query string) error {
|
||||||
|
dangerousPatterns := []string{
|
||||||
|
`'; DROP`, `'; DELETE`, `'; UPDATE`, `'; INSERT`,
|
||||||
|
`OR 1=1`, `OR '1'='1`, `UNION SELECT`,
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(query)
|
||||||
|
for _, pattern := range dangerousPatterns {
|
||||||
|
if strings.Contains(lower, strings.ToLower(pattern)) {
|
||||||
|
return fmt.Errorf("potential SQL injection detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ Segurança em Goroutines
|
||||||
|
|
||||||
|
**Código Atual:**
|
||||||
|
```go
|
||||||
|
// internal/services/application_service.go:61
|
||||||
|
go func() {
|
||||||
|
// Processamento assíncrono sem recover
|
||||||
|
}()
|
||||||
|
|
||||||
|
// internal/api/handlers/seeder_handler.go:45
|
||||||
|
go func() {
|
||||||
|
// Seeder em background sem context
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Riscos:**
|
||||||
|
1. **Goroutine Leak**: Goroutines presas consomem memória
|
||||||
|
2. **Race Condition**: Acesso concorrente a recursos compartilhados
|
||||||
|
3. **Panic não tratado**: Crash do servidor
|
||||||
|
|
||||||
|
**Implementação Segura:**
|
||||||
|
```go
|
||||||
|
func (s *Service) ProcessAsync(ctx context.Context, data Data) error {
|
||||||
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
slog.Error("Panic in goroutine", "error", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
return s.doWork(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return g.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting para goroutines
|
||||||
|
type GoroutinePool struct {
|
||||||
|
sem chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPool(max int) *GoroutinePool {
|
||||||
|
return &GoroutinePool{sem: make(chan struct{}, max)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GoroutinePool) Go(fn func()) {
|
||||||
|
p.sem <- struct{}{}
|
||||||
|
go func() {
|
||||||
|
defer func() { <-p.sem }()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ Validação de JWT - Hardening
|
||||||
|
|
||||||
|
**Implementação Recomendada:**
|
||||||
|
```go
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SecureClaims struct {
|
||||||
|
UserID string `json:"sub"`
|
||||||
|
TenantID string `json:"tenant,omitempty"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
TokenID string `json:"jti"` // Para revogação
|
||||||
|
TokenType string `json:"type"` // "access" ou "refresh"
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JWTService) ValidateToken(tokenString string) (*SecureClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &SecureClaims{},
|
||||||
|
func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// ✅ Verificar algoritmo explicitamente
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return s.config.SecretKey, nil
|
||||||
|
},
|
||||||
|
jwt.WithValidMethods([]string{"HS256"}),
|
||||||
|
jwt.WithIssuer(s.config.Issuer),
|
||||||
|
jwt.WithAudience(s.config.Audience...),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*SecureClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Verificar se foi revogado (Redis lookup)
|
||||||
|
if s.isRevoked(claims.TokenID) {
|
||||||
|
return nil, errors.New("token revoked")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ Proteção Contra Replay e CSRF
|
||||||
|
|
||||||
|
**CSRF (implementação recomendada):**
|
||||||
|
```go
|
||||||
|
func (h *Handler) WithCSRF(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
|
||||||
|
csrfToken := r.Header.Get("X-CSRF-Token")
|
||||||
|
cookieToken, err := r.Cookie("csrf_token")
|
||||||
|
|
||||||
|
if err != nil || csrfToken == "" || cookieToken.Value == "" {
|
||||||
|
http.Error(w, "CSRF token missing", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(cookieToken.Value)) != 1 {
|
||||||
|
http.Error(w, "CSRF token invalid", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replay Attack Protection:**
|
||||||
|
```go
|
||||||
|
type TokenStore struct {
|
||||||
|
redis *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TokenStore) IsReplayed(jti string, exp time.Time) bool {
|
||||||
|
key := fmt.Sprintf("used_token:%s", jti)
|
||||||
|
|
||||||
|
// Verificar se já foi usado
|
||||||
|
if s.redis.Exists(context.Background(), key).Val() > 0 {
|
||||||
|
return true // Replay detectado
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar como usado até a expiração
|
||||||
|
ttl := time.Until(exp)
|
||||||
|
if ttl > 0 {
|
||||||
|
s.redis.Set(context.Background(), key, "1", ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Resumo de Riscos
|
||||||
|
|
||||||
|
| Vulnerabilidade | Severidade | Status |
|
||||||
|
|-----------------|------------|--------|
|
||||||
|
| JWT Secret Hardcoded | **CRÍTICO** | ❌ Pendente |
|
||||||
|
| Logging de Tokens | **ALTO** | ❌ Pendente |
|
||||||
|
| Falta de JTI/Revogação | **MÉDIO** | ❌ Pendente |
|
||||||
|
| Goroutine Leak | **MÉDIO** | ❌ Pendente |
|
||||||
|
| Falta de CSRF Token | **MÉDIO** | ❌ Pendente |
|
||||||
|
| SQL Injection | **BAIXO** | ✅ Mitigado |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Plano de Ação Prioritário
|
||||||
|
|
||||||
|
| Prioridade | Ação | Esforço |
|
||||||
|
|------------|------|---------|
|
||||||
|
| **P0** | Mover `jwtSecret` hardcoded para `JWT_SECRET` env var | 1h |
|
||||||
|
| **P0** | Remover logs de tokens em produção | 30min |
|
||||||
|
| **P1** | Implementar logging estruturado (slog/zap) | 4h |
|
||||||
|
| **P1** | Adicionar `jti` aos tokens e implementar revogação | 8h |
|
||||||
|
| **P2** | Implementar CSRF tokens para operações sensíveis | 4h |
|
||||||
|
| **P2** | Adicionar middleware de rate limiting | 4h |
|
||||||
|
| **P3** | Migrar para RS256 (JWT assimétrico) para rotação de chaves | 16h |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Referências
|
||||||
|
|
||||||
|
- [OWASP Go Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Go_Security_Cheat_Sheet.html)
|
||||||
|
- [JWT Best Practices](https://auth0.com/blog/jwt-authentication-best-practices/)
|
||||||
|
- [Go Concurrency Patterns](https://go.dev/blog/pipelines)
|
||||||
|
- [CWE-798: Hard-coded Credentials](https://cwe.mitre.org/data/definitions/798.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Nota:** Este documento deve ser revisado trimestralmente ou após mudanças significativas na arquitetura de segurança.
|
||||||
Loading…
Reference in a new issue