Auditoria de Segurança
- Data: 2026-03-19
- Autor: v0xn0x (assistido por Claude Code)
- Escopo: código-fonte, configuração Docker, dependências Composer/npm
- Stack: Laravel 11.47.0 · PHP 8.3.20 · PostgreSQL 16 · Redis 7.4.7 · Docker Compose
Resumo Executivo
| Severidade | Qtd |
|---|---|
| 🔴 CRITICAL | 1 |
| 🟠 HIGH | 4 |
| 🟡 MEDIUM | 5 |
| 🔵 LOW | 4 |
| Total | 14 |
Os três riscos mais graves compartilham a mesma raiz: conveniência de desenvolvimento tratada como configuração aceitável. Credenciais reais no .env, sessões em arquivo sem criptografia, Redis e PostgreSQL abertos no host, tudo funcionava, nada era seguro.
O projeto tem maturidade moderada. Existem boas práticas bem implementadas (CSP nonce-based, 2FA, sanitização, UUIDs, rate limiting, RLS no PostgreSQL), mas a camada de infraestrutura e sessão ficou para trás.
Tabela de Priorização
| # | Finding | Severidade | Esforço | Status |
|---|---|---|---|---|
| 1 | [SEV-001] Credenciais reais no .env | 🔴 CRITICAL | Baixo | ✅ Corrigido (2026-03-20) |
| 2 | [SEV-002] Session driver/cookies inseguros | 🟠 HIGH | Baixo | ✅ Corrigido (2026-03-20) |
| 3 | [SEV-003] Redis sem autenticação | 🟠 HIGH | Baixo | ✅ Corrigido (2026-03-20) |
| 4 | [SEV-004] PostgreSQL exposto ao host | 🟠 HIGH | Baixo | ✅ Corrigido (2026-03-20) |
| 5 | [SEV-005] Xdebug no Docker image | 🟠 HIGH | Médio | ✅ Corrigido (2026-03-20) |
| 6 | [SEV-006] Header injection no Content-Disposition | 🟡 MEDIUM | Baixo | ✅ Corrigido (2026-03-21) |
| 7 | [SEV-007] Stored XSS residual via {!! !!} | 🟡 MEDIUM | Alto | ⚠️ Risco aceito |
| 8 | [SEV-008] Checkout sem validação de pagamento | 🟡 MEDIUM | Alto | ⚠️ Risco aceito |
| 9 | [SEV-009] DoS via full scan no checkout | 🟡 MEDIUM | Médio | ✅ Corrigido (2026-03-21) |
| 10 | [SEV-010] HSTS sem efeito (falta redirect HTTP) | 🟡 MEDIUM | Baixo | ✅ Corrigido (2026-03-21) |
| 11 | [SEV-011] Permissions-Policy ausente | 🔵 LOW | Baixo | ✅ Corrigido (2026-03-21) |
| 12 | [SEV-012] CVE-2026-33347 no league/commonmark | 🔵 LOW | Baixo | ✅ Corrigido (2026-03-21) |
| 13 | [SEV-013] XSS no Summernote (dep. transitiva) | 🔵 LOW | Baixo | ⚠️ Risco aceito |
| 14 | [SEV-014] role no $fillable (mass assignment) | 🔵 LOW | Baixo | ✅ Mitigado (2026-03-21) |
Findings Detalhados
SEV-001 — Credenciais reais no .env
| Campo | Valor |
|---|---|
| Severidade | 🔴 CRITICAL — CVSS 8.6 |
| Categoria | OWASP A07 — Security Misconfiguration |
| Arquivo | src/.env:55-58, 71 |
O .env continha app password do Gmail e API key do Bugsnag em texto puro. O diretório src/ é montado via volume Docker (./src:/var/www/html), o que significa que qualquer processo com acesso ao filesystem do host lê essas credenciais - malware, backup não criptografado, outro usuário da máquina.
O .gitignore estava correto (o .env nunca foi commitado), mas isso protege o repositório, não o host.
Evidência:
MAIL_USERNAME=j***@gmail.com
MAIL_PASSWORD="**** **** **** ****"
BUGSNAG_API_KEY=********************************
O que foi feito (2026-03-20):
MAIL_MAILERalterado desmtpparalog— e-mails vão para o log do Laravel em vez de serem enviadosMAIL_HOST→127.0.0.1,MAIL_PORT→2525,MAIL_USERNAMEeMAIL_PASSWORD→nullBUGSNAG_API_KEYlimpa.env.exampleatualizado com os defaults seguros
Ação manual pendente: revogar o app password do Gmail e rotacionar a API key do Bugsnag. Ambos já foram expostos no filesystem local — o fato de não terem sido commitados não garante que não foram lidos.
Ref: OWASP Secrets Management Cheat Sheet
SEV-002 — Sessões inseguras
| Campo | Valor |
|---|---|
| Severidade | 🟠 HIGH — CVSS 7.4 |
| Categoria | OWASP A07 — Security Misconfiguration |
| Arquivo | src/.env:33, src/config/session.php:21,50,172 |
Session driver file, SESSION_SECURE_COOKIE não definido (fallback null), SESSION_ENCRYPT em false. Dados de sessão em texto puro no filesystem do container; cookies transmissíveis via HTTP. O Redis já estava no Docker stack, mas não era usado para sessão.
Evidência:
SESSION_DRIVER=file
# SESSION_SECURE_COOKIE → não definido
# SESSION_ENCRYPT → não definido
O que foi feito (2026-03-20):
SESSION_DRIVER→redisSESSION_ENCRYPT=trueno.envSESSION_SECURE_COOKIE=falseno.env(dev local usa HTTP)- Defaults do
config/session.phpinvertidos:encryptagora defaulttrue,secureagora defaulttrue. Se alguém esquecer de configurar em produção, o sistema se protege sozinho — o caminho inseguro agora exige opt-in explícito .env.exampleatualizado
Ref: OWASP Session Management Cheat Sheet
SEV-003 — Redis aberto e sem senha
| Campo | Valor |
|---|---|
| Severidade | 🟠 HIGH — CVSS 7.2 |
| Categoria | OWASP A07 — Security Misconfiguration |
| Arquivo | docker-compose.yml:83-84, src/.env:50 |
Redis na porta 6379 do host, sem --requirepass. Qualquer processo local podia ler e manipular cache, sessões e dados de fila. Depois da correção do SEV-002 (sessões no Redis), esse finding se tornou ainda mais crítico — sequestro de sessão direto, sem interceptar tráfego.
Evidência:
1
2
3
4
redis:
ports:
- "${REDIS_PORT:-6379}:6379"
command: redis-server --appendonly yes # sem --requirepass
O que foi feito (2026-03-20):
--requirepass "${REDIS_PASSWORD}"adicionado ao comando Redis- Port mapping removido — Redis acessível apenas pela rede Docker interna
REDIS_PASSWORDconfigurado no.envraiz (Docker Compose) e nosrc/.env(Laravel)- Healthcheck atualizado:
redis-cli -a $REDIS_PASSWORD ping - Verificação: acesso sem senha retorna
NOAUTH Authentication required.; Laravel conecta normalmente
Ref: Redis Security
SEV-004 — PostgreSQL exposto ao host com senha fraca
| Campo | Valor |
|---|---|
| Severidade | 🟠 HIGH — CVSS 6.8 |
| Categoria | OWASP A07 — Security Misconfiguration |
| Arquivo | docker-compose.yml:61-62, src/.env:31 |
PostgreSQL na porta 5432 do host com a senha secret. O usuário tem CREATEDB (necessário para multi-tenancy), o que amplia o impacto: acesso a esse usuário permite criar bancos arbitrários, não só ler os existentes.
Evidência:
1
2
3
4
5
postgres:
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD:-secret}
ports:
- "${DB_PORT:-5432}:5432"
O que foi feito (2026-03-20):
- Port mapping alterado para
127.0.0.1:${DB_PORT:-5432}:5432— PostgreSQL acessível apenas via localhost - Senha substituída por string forte de 32 caracteres
- Como o volume já estava inicializado,
POSTGRES_PASSWORDno Docker Compose não teve efeito. A senha foi alterada manualmente viaALTER USER ... WITH PASSWORDdireto no banco. Esse detalhe não aparece na documentação superficial do PostgreSQL no Docker — a variável de ambiente só funciona noinitdb - Verificação: Laravel conecta normalmente; porta escuta apenas em
127.0.0.1:5433
Ref: OWASP Database Security Cheat Sheet
SEV-005 — Xdebug na imagem Docker
| Campo | Valor |
|---|---|
| Severidade | 🟠 HIGH — CVSS 6.5 |
| Categoria | OWASP A05 — Security Misconfiguration |
| Arquivo | docker/php/Dockerfile:48-49, docker/php/xdebug.ini |
Xdebug instalado e habilitado incondicionalmente (mode=debug,develop). Mesmo com start_with_request=trigger, basta enviar o cookie XDEBUG_TRIGGER para ativar o debugger remoto — expondo variáveis de ambiente, stack traces, e potencialmente permitindo execução de código.
A mesma imagem usada em dev seria usada em produção. O Xdebug estava lá “temporariamente” — mas não havia nada que impedisse o deploy assim.
Evidência:
1
2
RUN pecl install xdebug \
&& docker-php-ext-enable xdebug
O que foi feito (2026-03-20):
- Build arg
INSTALL_XDEBUGcom defaultfalse. Xdebug só é instalado quando explicitamente solicitado xdebug.inisó é copiado paraconf.dquandoINSTALL_XDEBUG=true- No
docker-compose.yml: containerapprecebeINSTALL_XDEBUG=${INSTALL_XDEBUG:-true};queueeschedulermantêm o defaultfalse— não há razão para debugger nesses containers - Verificação:
apptem Xdebug ativo;queueeschedulernão
SEV-006 — Header injection via Content-Disposition
| Campo | Valor |
|---|---|
| Severidade | 🟡 MEDIUM — CVSS 5.3 |
| Categoria | OWASP A03 — Injection |
| Arquivo | src/app/Http/Controllers/Tenant/AttachmentController.php:77 |
O original_name do upload era concatenado diretamente no header Content-Disposition. Um nome de arquivo com aspas ou caracteres de controle permitiria HTTP Response Splitting — potencialmente levando a XSS ou cache poisoning.
Evidência:
1
->header('Content-Disposition', 'inline; filename="'.$attachment->original_name.'"')
O que foi feito (2026-03-21):
Substituída a concatenação manual por HeaderUtils::makeDisposition() do Symfony, que sanitiza o filename e adiciona fallback ASCII conforme RFC 6266:
1
2
3
4
5
6
use Symfony\Component\HttpFoundation\HeaderUtils;
$disposition = HeaderUtils::makeDisposition('inline', $attachment->original_name);
return response($content, 200)
->header('Content-Type', $attachment->mime_type)
->header('Content-Disposition', $disposition);
Ref: OWASP HTTP Headers Cheat Sheet
SEV-007 — Stored XSS residual no PoC ({!! !!})
| Campo | Valor |
|---|---|
| Severidade | 🟡 MEDIUM — CVSS 5.4 |
| Categoria | OWASP A03 — Injection (XSS) |
| Arquivo | src/resources/views/tenant/vulnerabilities/show.blade.php:134 |
| Status | ⚠️ Risco aceito |
A view renderiza HTML do Proof of Concept com {!! $pocHtml !!} — sem escape. O PoC é gerado pelo Tiptap/ProseMirror como HTML formatado, então usar `` quebraria a formatação.
Defesas existentes:
O ContentSanitizer (705 linhas) aplica whitelist de node types (L16-29), marks (L34-42) e atributos por tipo (L47-61). URLs são validadas para http/https (L274-303). O HTML é gerado programaticamente a partir do JSON sanitizado — não por concatenação de strings. Adicionalmente, a CSP nonce-based bloqueia execução de scripts inline sem o nonce correto.
Cenário de exploração: atacante modifica poc_html diretamente no banco via SQL, bypassando o ContentSanitizer. Mas se ele tem acesso direto ao PostgreSQL, XSS é o menor dos problemas — ele já pode ler dados de todos os tenants, criar admins, extrair credenciais.
Decisão: risco aceito. Documentado em docs/security/accepted-risks.md (ACCEPTED-002, commit fd3377a).
Gatilho de revisão: refatoração do ContentSanitizer, aceitação de HTML raw de nova fonte (API import), ou relaxamento da CSP.
Ref: OWASP XSS Prevention Cheat Sheet
SEV-008 — Checkout sem validação de pagamento
| Campo | Valor |
|---|---|
| Severidade | 🟡 MEDIUM — CVSS 5.9 |
| Categoria | OWASP A04 — Insecure Design |
| Arquivo | src/app/Services/CheckoutService.php:25-63 |
| Status | ⚠️ Risco aceito |
O CheckoutService::process() cria tenant + subscription ativa (ou trial) sem validar pagamento. O fluxo completo: validar e-mail → criar tenant → criar subscription ACTIVE → criar admin user. Nenhuma integração com gateway.
Evidência:
1
2
3
4
5
6
7
8
public function process(Plan $plan, array $companyData, array $adminData): array
{
$tenant = Tenant::create([...]);
$this->createSubscription($tenant, $plan); // ACTIVE sem pagamento
$tenant->run(function () use ($adminData) {
TenantUser::create([...]);
});
}
Decisão: risco aceito. A aplicação está em fase de MVP sem integração com gateway de pagamento. O fluxo já diferencia trial (trial_days > 0 → status TRIAL) de planos pagos (status ACTIVE). Implementar Stripe ou similar é uma feature de grande escopo que ultrapassa o objetivo desta auditoria.
Mitigações existentes: rate limiting nas rotas de checkout, validação de e-mail único cross-tenant, SubscriptionObserver que sincroniza status do tenant.
Gatilho de revisão: quando a integração com gateway for planejada, este finding deve ser o ponto de partida.
Ref: OWASP Transaction Authorization Cheat Sheet
SEV-009 — DoS via full scan de tenants no checkout
| Campo | Valor |
|---|---|
| Severidade | 🟡 MEDIUM — CVSS 4.3 |
| Categoria | OWASP A04 — Insecure Design |
| Arquivo | src/app/Services/CheckoutService.php:90-108 |
O isEmailAvailable() carregava todos os tenants com Tenant::all() e executava uma query em cada banco. Endpoint público, sem autenticação. Com 100 tenants: 101 queries por request. Com 1000: 1001. Um script simples degradaria o servidor em minutos.
Esse finding não seria detectado por um scanner SAST — o código é PHP válido. A detecção depende de entender o contexto: endpoint público + Tenant::all() + database-per-tenant.
Evidência:
1
2
3
4
5
6
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
$exists = $tenant->run(function () use ($email) {
return TenantUser::where('email', $email)->exists();
});
}
O que foi feito (2026-03-21):
Tenant::all()substituído porTenant::cursor()— lazy loading, não carrega todos em memória- Early return no primeiro match
- Rate limiting
throttle:5,1adicionado à rotacheckout.process
A solução ideal (índice centralizado de e-mails na central database) fica para quando o volume de tenants justificar a mudança.
Ref: OWASP Denial of Service Cheat Sheet
SEV-010 — HSTS sem efeito por falta de redirect HTTP→HTTPS
| Campo | Valor |
|---|---|
| Severidade | 🟡 MEDIUM — CVSS 4.0 |
| Categoria | OWASP A07 — Security Misconfiguration |
| Arquivo | docker/apache/vhost.conf:57-62 |
O vhost SSL (vhost-ssl.conf:72) já configurava Strict-Transport-Security: max-age=31536000; includeSubDomains. Mas o vhost HTTP não redirecionava para HTTPS. Quem acessasse via HTTP ficava em HTTP — o browser nunca recebia o header HSTS porque ele só é enviado via HTTPS.
O que foi feito (2026-03-21):
- Redirect condicional via
RewriteCond %{ENV:FORCE_HTTPS}novhost.conf - Variável
FORCE_HTTPSpassada viaPassEnvno Dockerfile do Apache. Sem oPassEnv, a variável de ambiente do Docker não chega ao Apache — o%{ENV:FORCE_HTTPS}seria vazio e o redirect nunca aconteceria. Bug silencioso que só apareceria em produção - Dev:
FORCE_HTTPS=false(HTTP funciona normalmente). Produção:true - Verificação: dev retorna HTTP 200; com
FORCE_HTTPS=trueretorna 301 para HTTPS
SEV-011 — Permissions-Policy ausente
| Campo | Valor |
|---|---|
| Severidade | 🔵 LOW — CVSS 3.1 |
| Categoria | OWASP A07 — Security Misconfiguration |
| Arquivo | docker/apache/vhost.conf, docker/apache/vhost-ssl.conf |
Nenhum vhost configurava Permissions-Policy. Uma plataforma de gestão de vulnerabilidades não usa câmera, microfone ou GPS — mas sem o header, scripts maliciosos poderiam tentar solicitar essas APIs.
O que foi feito (2026-03-21):
- Header adicionado em
vhost.confevhost-ssl.conf:1
camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()
- Verificação via
curl -I: header presente
SEV-012 — CVE-2026-33347 no league/commonmark
| Campo | Valor |
|---|---|
| Severidade | 🔵 LOW — CVSS 4.3 |
| Categoria | Dependência vulnerável |
| Arquivo | src/composer.lock (league/commonmark <=2.8.1) |
Bypass do allowed_domains na extensão embed. A aplicação usa Tiptap e não a extensão embed do CommonMark diretamente, então o impacto prático é baixo. Mas manter dependências com CVEs conhecidos é ruído desnecessário em qualquer auditoria de compliance.
O que foi feito (2026-03-21):
1
2
3
4
composer update league/commonmark --with-all-dependencies
# 2.8.1 → 2.8.2
composer audit
# No security vulnerability advisories found.
152 testes passando, sem regressão.
A pergunta que sobrou: por que o composer audit não estava no CI/CD? Se estivesse, o advisory teria sido detectado automaticamente.
Ref: GHSA-hh8v-hgvp-g3f5
SEV-013 — XSS no Summernote (dependência transitiva do AdminLTE)
| Campo | Valor |
|---|---|
| Severidade | 🔵 LOW — CVSS 4.3 |
| Categoria | Dependência vulnerável |
| Arquivo | src/package.json (admin-lte 3.2.0 → summernote <=0.8.20) |
| Status | ⚠️ Risco aceito |
AdminLTE 3.2.0 puxa Summernote <=0.8.20, que tem XSS conhecido. Mas o Summernote não é importado, carregado ou referenciado em nenhum arquivo do projeto. A aplicação usa Tiptap como editor rich text.
Verificação:
grep -r "summernote" resources/→ zero resultadospublic/build/assets/→ não presente no bundle Vite- Existe apenas em
node_modules/admin-lte/como dependência transitiva
Remover manualmente seria revertido no próximo npm install. Usar overrides no package.json adicionaria manutenção para um risco com vetor de exploração zero.
Decisão: risco aceito. Documentado em docs/security/accepted-risks.md (ACCEPTED-001, commit fd3377a).
Gatilho de revisão: atualização do AdminLTE ou qualquer código que passe a importar Summernote.
Ref: GHSA-cc55-mvqc-g9mg
SEV-014 — role no $fillable do Tenant\User
| Campo | Valor |
|---|---|
| Severidade | 🔵 LOW — CVSS 3.7 |
| Categoria | OWASP A01 — Broken Access Control |
| Arquivo | src/app/Models/Tenant/User.php:38 |
O campo role estava no $fillable. Os controllers atuais são seguros (extraem campos individualmente via services), mas o risco existe se um controller futuro usar User::create($request->all()) ou $request->validated() sem filtrar o campo.
A primeira tentativa foi remover role do $fillable. Resultado: UserFactory quebrou, seeders falharam, testes automatizados pararam. Reverter exigiria refatorar a infraestrutura de testes.
Evidência:
1
2
3
4
5
protected $fillable = [
'name', 'email', 'password',
'role', // ← risco preventivo
'customer_id', 'phone', 'avatar', 'is_active',
];
O que foi feito (2026-03-21) — defense-in-depth:
Em vez de remover do $fillable, todos os services que atribuem role passaram a fazer assignment explícito:
CheckoutService::process()— role ADMIN atribuído apóscreate(), fora do arrayTenantUserService::createAdmin()— idemUserService::update()— role removido do$updateData, atribuído via$user->role = $role; $user->save()UserService::create()— role no array (endpoint protegido por middlewaretenant.admin)
Camadas de proteção:
- Rotas de CRUD protegidas por middleware
tenant.admin - Controllers usam
Gate::authorize()com policies - Services extraem campos individualmente (nunca
$request->all()) - Nenhum FormRequest aceita
rolede usuários não-admin
Ref: OWASP Mass Assignment Cheat Sheet
Padrões Seguros Confirmados
Itens auditados que estão corretos e não precisam de alteração:
| Item | Localização |
|---|---|
SQL Injection — todos os whereRaw usam bindings | FormRequests, DashboardController |
Zero $request->all() — services extraem campos | Todos os Services |
Models com $fillable e $hidden corretos | Todos os Models |
| Rate limiting em login/password reset/subdomain | AuthControllers, InitializeTenancyMiddleware |
| CSP nonce-based com whitelist restritiva | ContentSecurityPolicy middleware, config/csp.php |
| 2FA TOTP com encrypted secrets e recovery codes | TwoFactorService (central e tenant) |
| Password hashing bcrypt com 12 rounds | .env (BCRYPT_ROUNDS=12) |
Passwords e 2FA secrets em $hidden | User models (central e tenant) |
| UUIDs como primary keys | Todos os Models via HasUuid/HasUuids |
| EXIF stripping em uploads | config/image.php, AttachmentService |
| MIME type validation (whitelist) em attachments | AttachmentService::ALLOWED_MIME_TYPES |
| CSRF proteção automática | Todas as rotas POST/PUT/DELETE |
.env e SSL certs no .gitignore | .gitignore (root e src) |
| Zero inline event handlers nas views | Todas as views (CSP-compliant) |
Zero exec/shell_exec/system no código | Todos os arquivos PHP |
| Policies registradas (Vulnerability, Customer, Project, User) | AppServiceProvider |
| Soft deletes para audit trail | User, Customer, Project models |
| PHP roda como non-root no container | Dockerfile (USER www-data) |
display_errors Off no php.ini | docker/php/php.ini |
session.cookie_httponly On | docker/php/php.ini |
hash_equals() para comparação de cookies 2FA | EnsureTwoFactorVerified middlewares |
| RLS via SetCustomerContext com UUID validation | SetCustomerContext middleware |
Achados Informativos
Não são vulnerabilidades ativas, mas merecem atenção:
- VulnerabilityStatusController sem authorization checks — as rotas estão comentadas em
tenant.php:142-152, não estão acessíveis. Se forem habilitadas, vão precisar de policies. - FormRequest::authorize() retorna
true— padrão Laravel aceitável quando middleware trata autenticação. Todos os endpoints têm middleware de auth e role. - Inconsistência nos recovery codes de 2FA — central usa cast
encrypted:array, tenant usaCrypt::encryptStringmanual. Funcional, mas vale padronizar. - Nomes hardcoded no DashboardService —
'Fixed','Informative'usados para filtrar status. Quebra silenciosamente se um tenant renomear esses status. - N+1 no DashboardService::getTopCustomers() — problema de performance, não de segurança.
Próximos Passos
Ações imediatas (feito)
- Revogar e rotacionar credenciais Gmail e Bugsnag
- Sessões no Redis com cookies criptografados e secure
- Redis com senha, sem port mapping
- PostgreSQL em localhost com senha forte
- Xdebug condicional via build arg
HeaderUtils::makeDisposition()no attachment- Redirect HTTP→HTTPS e Permissions-Policy
composer update league/commonmark
Melhorias estruturais (backlog)
- Multi-stage Docker build separando dev/prod
- Integração com gateway de pagamento no checkout
- Índice centralizado de e-mails cross-tenant
- HTMLPurifier como segunda camada de sanitização no PoC
CI/CD
composer audit+npm auditno pipeline- PHPStan level 6+ com Larastan
- OWASP ZAP ou Nuclei contra staging
- Trufflehog ou GitLeaks no pre-commit hook
- Trivy para scan de imagens Docker