Auditoria de Segurança

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

SeveridadeQtd
🔴 CRITICAL1
🟠 HIGH4
🟡 MEDIUM5
🔵 LOW4
Total14

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

#FindingSeveridadeEsforçoStatus
1[SEV-001] Credenciais reais no .env🔴 CRITICALBaixo✅ Corrigido (2026-03-20)
2[SEV-002] Session driver/cookies inseguros🟠 HIGHBaixo✅ Corrigido (2026-03-20)
3[SEV-003] Redis sem autenticação🟠 HIGHBaixo✅ Corrigido (2026-03-20)
4[SEV-004] PostgreSQL exposto ao host🟠 HIGHBaixo✅ Corrigido (2026-03-20)
5[SEV-005] Xdebug no Docker image🟠 HIGHMédio✅ Corrigido (2026-03-20)
6[SEV-006] Header injection no Content-Disposition🟡 MEDIUMBaixo✅ Corrigido (2026-03-21)
7[SEV-007] Stored XSS residual via {!! !!}🟡 MEDIUMAlto⚠️ Risco aceito
8[SEV-008] Checkout sem validação de pagamento🟡 MEDIUMAlto⚠️ Risco aceito
9[SEV-009] DoS via full scan no checkout🟡 MEDIUMMédio✅ Corrigido (2026-03-21)
10[SEV-010] HSTS sem efeito (falta redirect HTTP)🟡 MEDIUMBaixo✅ Corrigido (2026-03-21)
11[SEV-011] Permissions-Policy ausente🔵 LOWBaixo✅ Corrigido (2026-03-21)
12[SEV-012] CVE-2026-33347 no league/commonmark🔵 LOWBaixo✅ Corrigido (2026-03-21)
13[SEV-013] XSS no Summernote (dep. transitiva)🔵 LOWBaixo⚠️ Risco aceito
14[SEV-014] role no $fillable (mass assignment)🔵 LOWBaixo✅ Mitigado (2026-03-21)

Findings Detalhados

SEV-001 — Credenciais reais no .env

CampoValor
Severidade🔴 CRITICAL — CVSS 8.6
CategoriaOWASP A07 — Security Misconfiguration
Arquivosrc/.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_MAILER alterado de smtp para log — e-mails vão para o log do Laravel em vez de serem enviados
  • MAIL_HOST127.0.0.1, MAIL_PORT2525, MAIL_USERNAME e MAIL_PASSWORDnull
  • BUGSNAG_API_KEY limpa
  • .env.example atualizado 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

CampoValor
Severidade🟠 HIGH — CVSS 7.4
CategoriaOWASP A07 — Security Misconfiguration
Arquivosrc/.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_DRIVERredis
  • SESSION_ENCRYPT=true no .env
  • SESSION_SECURE_COOKIE=false no .env (dev local usa HTTP)
  • Defaults do config/session.php invertidos: encrypt agora default true, secure agora default true. Se alguém esquecer de configurar em produção, o sistema se protege sozinho — o caminho inseguro agora exige opt-in explícito
  • .env.example atualizado

Ref: OWASP Session Management Cheat Sheet


SEV-003 — Redis aberto e sem senha

CampoValor
Severidade🟠 HIGH — CVSS 7.2
CategoriaOWASP A07 — Security Misconfiguration
Arquivodocker-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_PASSWORD configurado no .env raiz (Docker Compose) e no src/.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

CampoValor
Severidade🟠 HIGH — CVSS 6.8
CategoriaOWASP A07 — Security Misconfiguration
Arquivodocker-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_PASSWORD no Docker Compose não teve efeito. A senha foi alterada manualmente via ALTER USER ... WITH PASSWORD direto no banco. Esse detalhe não aparece na documentação superficial do PostgreSQL no Docker — a variável de ambiente só funciona no initdb
  • 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

CampoValor
Severidade🟠 HIGH — CVSS 6.5
CategoriaOWASP A05 — Security Misconfiguration
Arquivodocker/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_XDEBUG com default false. Xdebug só é instalado quando explicitamente solicitado
  • xdebug.ini só é copiado para conf.d quando INSTALL_XDEBUG=true
  • No docker-compose.yml: container app recebe INSTALL_XDEBUG=${INSTALL_XDEBUG:-true}; queue e scheduler mantêm o default false — não há razão para debugger nesses containers
  • Verificação: app tem Xdebug ativo; queue e scheduler não

Ref: Xdebug — All Settings


SEV-006 — Header injection via Content-Disposition

CampoValor
Severidade🟡 MEDIUM — CVSS 5.3
CategoriaOWASP A03 — Injection
Arquivosrc/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 ({!! !!})

CampoValor
Severidade🟡 MEDIUM — CVSS 5.4
CategoriaOWASP A03 — Injection (XSS)
Arquivosrc/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

CampoValor
Severidade🟡 MEDIUM — CVSS 5.9
CategoriaOWASP A04 — Insecure Design
Arquivosrc/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

CampoValor
Severidade🟡 MEDIUM — CVSS 4.3
CategoriaOWASP A04 — Insecure Design
Arquivosrc/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 por Tenant::cursor() — lazy loading, não carrega todos em memória
  • Early return no primeiro match
  • Rate limiting throttle:5,1 adicionado à rota checkout.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

CampoValor
Severidade🟡 MEDIUM — CVSS 4.0
CategoriaOWASP A07 — Security Misconfiguration
Arquivodocker/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} no vhost.conf
  • Variável FORCE_HTTPS passada via PassEnv no Dockerfile do Apache. Sem o PassEnv, 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=true retorna 301 para HTTPS

Ref: OWASP HSTS Cheat Sheet


SEV-011 — Permissions-Policy ausente

CampoValor
Severidade🔵 LOW — CVSS 3.1
CategoriaOWASP A07 — Security Misconfiguration
Arquivodocker/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.conf e vhost-ssl.conf:
    1
    
    camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()
    
  • Verificação via curl -I: header presente

Ref: MDN Permissions-Policy


SEV-012 — CVE-2026-33347 no league/commonmark

CampoValor
Severidade🔵 LOW — CVSS 4.3
CategoriaDependência vulnerável
Arquivosrc/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)

CampoValor
Severidade🔵 LOW — CVSS 4.3
CategoriaDependência vulnerável
Arquivosrc/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 resultados
  • public/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

CampoValor
Severidade🔵 LOW — CVSS 3.7
CategoriaOWASP A01 — Broken Access Control
Arquivosrc/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ós create(), fora do array
  • TenantUserService::createAdmin() — idem
  • UserService::update() — role removido do $updateData, atribuído via $user->role = $role; $user->save()
  • UserService::create() — role no array (endpoint protegido por middleware tenant.admin)

Camadas de proteção:

  1. Rotas de CRUD protegidas por middleware tenant.admin
  2. Controllers usam Gate::authorize() com policies
  3. Services extraem campos individualmente (nunca $request->all())
  4. Nenhum FormRequest aceita role de 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:

ItemLocalização
SQL Injection — todos os whereRaw usam bindingsFormRequests, DashboardController
Zero $request->all() — services extraem camposTodos os Services
Models com $fillable e $hidden corretosTodos os Models
Rate limiting em login/password reset/subdomainAuthControllers, InitializeTenancyMiddleware
CSP nonce-based com whitelist restritivaContentSecurityPolicy middleware, config/csp.php
2FA TOTP com encrypted secrets e recovery codesTwoFactorService (central e tenant)
Password hashing bcrypt com 12 rounds.env (BCRYPT_ROUNDS=12)
Passwords e 2FA secrets em $hiddenUser models (central e tenant)
UUIDs como primary keysTodos os Models via HasUuid/HasUuids
EXIF stripping em uploadsconfig/image.php, AttachmentService
MIME type validation (whitelist) em attachmentsAttachmentService::ALLOWED_MIME_TYPES
CSRF proteção automáticaTodas as rotas POST/PUT/DELETE
.env e SSL certs no .gitignore.gitignore (root e src)
Zero inline event handlers nas viewsTodas as views (CSP-compliant)
Zero exec/shell_exec/system no códigoTodos os arquivos PHP
Policies registradas (Vulnerability, Customer, Project, User)AppServiceProvider
Soft deletes para audit trailUser, Customer, Project models
PHP roda como non-root no containerDockerfile (USER www-data)
display_errors Off no php.inidocker/php/php.ini
session.cookie_httponly Ondocker/php/php.ini
hash_equals() para comparação de cookies 2FAEnsureTwoFactorVerified middlewares
RLS via SetCustomerContext com UUID validationSetCustomerContext middleware

Achados Informativos

Não são vulnerabilidades ativas, mas merecem atenção:

  1. 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.
  2. FormRequest::authorize() retorna true — padrão Laravel aceitável quando middleware trata autenticação. Todos os endpoints têm middleware de auth e role.
  3. Inconsistência nos recovery codes de 2FA — central usa cast encrypted:array, tenant usa Crypt::encryptString manual. Funcional, mas vale padronizar.
  4. Nomes hardcoded no DashboardService'Fixed', 'Informative' usados para filtrar status. Quebra silenciosamente se um tenant renomear esses status.
  5. 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 audit no 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