Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b4049db
fix(security): escapa saidas em views e endurece o gate de XSS
Pr3d4dor Sep 27, 2026
eb9f117
fix: repare a precisao das colunas DECIMAL criadas pelo create_base
Pr3d4dor Sep 27, 2026
c1ef1bb
refactor: torna o Login verificavel e unifica as respostas de erro
Pr3d4dor Sep 27, 2026
dd577f9
test: adiciona suíte de testes, gate de paridade e CI de qualidade
Pr3d4dor Sep 27, 2026
37ddfe5
fix: ignore em arquivos de upload
Pr3d4dor Sep 27, 2026
c759b2d
fix(tests): mantém a conexão do processo em vez de reabrir por contro…
Pr3d4dor Sep 27, 2026
334b7e7
test: isola cada caso numa transação com a trait TransactsDatabase
Pr3d4dor Sep 27, 2026
6abd04a
fix: ci
Pr3d4dor Sep 27, 2026
6457735
perf(tests): mantém o schema entre execuções e limpa os dados por teste
Pr3d4dor Sep 27, 2026
581e52c
fix(security): endurece o gate de XSS contra a varredura que não leu …
Pr3d4dor Sep 29, 2026
800314e
refactor(tests): divide o harness por camada e centraliza a leitura d…
Pr3d4dor Sep 29, 2026
76da715
test(security): cobre o gate de escape de dentro e de fora
Pr3d4dor Sep 29, 2026
f2632f7
fix(security): bloqueia tools/ no nginx, que publica o mapa do que nã…
Pr3d4dor Sep 29, 2026
481f950
test: isola o ParaTest por banco e publica o caminho do CI
Pr3d4dor Sep 29, 2026
6934643
test: tira a tabela duplicada de redirects e limpa a divergência do C…
Pr3d4dor Sep 29, 2026
ffef268
docs: registra a pasta tools/ como bloqueada e o caminho do ParaTest
Pr3d4dor Sep 29, 2026
8bb0e18
fix(security): escapa a saida das 221 views que chegavam sem escape
Pr3d4dor Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,39 @@ on:
permissions:
contents: read

# Um job só, e não um por verificação. Os passos que não precisam de MySQL
# (escaping, estilo) e os que precisam (suíte, paridade) dividiam a mesma
# instalação de dependências e a mesma configuração de PHP, então dois jobs
# pagavam duas vezes por download do Composer e podiam divergir na versão do PHP
# sem ninguém perceber. A barreira de paridade precisa do mesmo banco e da
# mesma versão da suíte, então separá-las não compraria isolamento nenhum.
jobs:
checks:
quality:
runs-on: ubuntu-latest

services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
# A conexão do runner chega pelo bridge do Docker, não por socket, e
# por isso não casa com o root@localhost que a imagem cria.
MYSQL_ROOT_HOST: '%'
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5

env:
MAPOS_TEST_DB_HOSTNAME: 127.0.0.1
MAPOS_TEST_DB_PORT: '3306'
MAPOS_TEST_DB_DATABASE: mapos_test
MAPOS_TEST_DB_USERNAME: root
MAPOS_TEST_DB_PASSWORD: root

steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -34,3 +63,46 @@ jobs:

- name: Check code style
run: composer format:check

- name: Run the test suite
run: composer test

# O runner é limpo, então a execução acima pagou os ~9s da cadeia de
# migrations por completo. Esta segunda execução exercita o caminho que os
# desenvolvedores usam de verdade, o reaproveitamento, e é o único lugar
# onde ele é verificado: localmente ele só roda quando alguém lembra de
# rodar a suíte duas vezes.
#
# Não é uma barra de tempo, e não deveria virar uma. O ganho é do
# desenvolvimento local, onde o banco sobrevive entre execuções; no CI a
# máquina é descartada e o ganho é zero. O que se ganha aqui é a certeza de
# que o reaproveitamento não degradou em silêncio — degradar, aliás, é
# exatamente a forma como ele falha: remontar tudo e passar igual, só mais
# devagar. Por isso o passo exige a mensagem de reuso e não se limita a
# rodar o script.
- name: Run the schema setup again, requiring it to reuse the schema
run: |
saida=$(php application/tests/bin/setup-db.php)
echo "$saida"

if ! grep -q "reaproveitando" <<< "$saida"; then
echo "::error::A segunda montagem não reaproveitou o schema. O caminho de reuso está quebrado."
exit 1
fi

# A contagem das contas é conferida dentro do setup-db.php, que sai com
# erro se ela não bater. Aqui o que interessa é o caminho de reuso ter
# chegado até o fim, e o número fica de fora do grep de propósito: ele
# é TestFixtures::USER_COUNT, e escrevê-lo aqui seria uma segunda
# fonte para a mesma verdade.
if ! grep -q "usuários disponíveis" <<< "$saida"; then
echo "::error::O schema reaproveitado não percorreu a conferência de fixtures."
exit 1
fi

# A suíte monta o schema pelas migrations, e o instalador importa o
# banco.sql. Nada mais exercita os dois caminhos, então sem esta
# comparação eles podem divergir em silêncio — e foi exatamente isso que
# escondeu as colunas DECIMAL(10,0) do create_base por anos.
- name: Check banco.sql against the migrations
run: composer check:parity
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ vendor/
.idea/
.php_cs.cache
.php-cs-fixer.cache
.phpunit.cache
application/logs/*
ci_sessions/

uploads
application/.env
assets/userImage

# Marcador de ambiente do tooling local; é um mountpoint, não um arquivo do projeto.
.ai-jail

/assets/arquivos/*
1 change: 0 additions & 1 deletion .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

$finder = Symfony\Component\Finder\Finder::create()
->notPath('vendor')
->notPath('bootstrap')
->notPath('storage')
// Local development mounts a MySQL data directory here; it is not project code.
->exclude('docker/data')
Expand Down
271 changes: 260 additions & 11 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ composer format:check

Boas práticas adicionais:

- Escape a saída nas views para evitar XSS: use o helper que corresponde ao contexto da saída — `esc()` para texto e atributos HTML, `esc_js()` / `esc_json()` dentro de `<script>`, `esc_url()` em `href`/`src`, `esc_css()` em `style` e `esc_msg()` para mensagens do SweetAlert2. Quando precisar renderizar HTML vindo do usuário, use `printSafeHtml()` (também em `application/helpers/general_helper.php`, baseado no HTMLPurifier). Nunca use `echo` direto em uma linha do banco, valor de sessão ou de configuração.
- Escape a saída nas views para evitar XSS: use o helper que corresponde ao contexto da saída — `esc()` para texto e atributos HTML, `esc_json()` dentro de `<script>`, `esc_url()` em `href`, `esc_img_src()` em `<img src>` (os QR codes de pagamento são data URIs, que `esc_url()` recusa), `esc_css()` em `style` e `esc_msg()` para mensagens do SweetAlert2. Quando precisar renderizar HTML vindo do usuário, use `printSafeHtml()` (também em `application/helpers/general_helper.php`, baseado no HTMLPurifier). Nunca use `echo` direto em uma linha do banco, valor de sessão ou de configuração.
- Rode `composer xss:check` depois de editar views: o comando falha se algum valor chegar à página sem escape. A tabela completa de helpers está em `AGENTS.md`.
- Use o Query Builder do CodeIgniter ou *query bindings* nos models. **Nunca** concatene entrada do usuário em SQL.
- Valide e autorize no controller: confira o ID recebido e a permissão do usuário antes de operar sobre o registro.
Expand Down
2 changes: 2 additions & 0 deletions application/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ APP_PROXY_IPS=

DB_DSN=
DB_HOSTNAME="enter_db_hostname"
# Opcional. Vazio ou ausente usa a porta padrão do MySQL (3306).
DB_PORT=
DB_USERNAME="enter_db_username"
DB_PASSWORD="enter_db_password"
DB_DATABASE="enter_db_name"
Expand Down
2 changes: 1 addition & 1 deletion application/config/autoload.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
| $autoload['helper'] = array('url', 'file');
*/

$autoload['helper'] = ['url', 'audit', 'date', 'dd', 'validation', 'general', 'captcha'];
$autoload['helper'] = ['url', 'audit', 'date', 'dd', 'validation', 'general', 'captcha', 'redirect'];

/*
| -------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions application/config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
$db['default'] = [
'dsn' => $_ENV['DB_DSN'] ?? '',
'hostname' => $_ENV['DB_HOSTNAME'] ?? 'enter_hostname',
'port' => $_ENV['DB_PORT'] ?? '',
'username' => $_ENV['DB_USERNAME'] ?? 'enter_db_username',
'password' => $_ENV['DB_PASSWORD'] ?? 'enter_db_password',
'database' => $_ENV['DB_DATABASE'] ?? 'enter_database_name',
Expand Down
26 changes: 26 additions & 0 deletions application/config/testing/config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

defined('BASEPATH') or exit('No direct script access allowed');

/*
|--------------------------------------------------------------------------
| Configuração do ambiente de testes
|--------------------------------------------------------------------------
|
| Este arquivo é carregado DEPOIS de application/config/config.php, com o mesmo
| array $config (ver Common.php::_ci_load_class), então sobrescrever uma chave
| aqui vale para o resto da requisição.
|
| Existe para tirar a suíte do caminho de código-fonte. O config.php deixa
| log_path vazio, o que faz o CI_Log gravar em application/logs/ — dentro da
| árvore que o php-cs-fixer percorre. A suíte escreve log de verdade (o
| log_message() do Security, o log_info() do Login), então uma execução gerava
| application/logs/log-<data>.php e o composer format:check falhava em cima de um
| arquivo gerado, obrigando a uma exceção no .php-cs-fixer.php.
|
| Mandar para o diretório temporário resolve na raiz, sem exceção: o log é saída
| de runtime e não tem por que viver no repositório.
|
*/

$config['log_path'] = rtrim(sys_get_temp_dir(), '/') . '/mapos-test-logs/';
21 changes: 21 additions & 0 deletions application/config/testing/routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
|--------------------------------------------------------------------------
| Rotas do ambiente de testes
|--------------------------------------------------------------------------
|
| Este arquivo é carregado DEPOIS de application/config/routes.php
| (ver Router::_set_default_controller), então os valores aqui têm
| precedência sobre os de produção.
|
| O default_controller e o 404_override apontam para um controller inerte de
| propósito: o bootstrap da suíte precisa que a requisição de boot não toque em
| dados. O 404_override também absorve qualquer rota inesperada, o que impede
| que um exit() do show_404() mate o processo de teste.
|
*/

$route['default_controller'] = 'phpunit/index';
$route['404_override'] = 'phpunit/index';
$route['translate_uri_dashes'] = false;
160 changes: 108 additions & 52 deletions application/controllers/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,66 +19,122 @@ public function sair()

// O Referer é controlado pelo cliente; redirecionar para ele permitiria
// que um terceiro enviasse o usuário para um domínio externo.
return redirect(site_url('login'));
respond_redirect(site_url('login'));
}

public function verificarLogin()
{
header('Access-Control-Allow-Origin: ' . base_url());
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Max-Age: 1000');
header('Access-Control-Allow-Headers: Content-Type');

$this->load->library('form_validation');
$this->form_validation->set_rules('email', 'E-mail', 'valid_email|required|trim');
$this->form_validation->set_rules('senha', 'Senha', 'required|trim');
if ($this->form_validation->run() == false) {
$json = ['result' => false, 'message' => validation_errors()];
echo json_encode($json);
} else {
$email = $this->input->post('email');
$password = $this->input->post('senha');
$this->load->model('Mapos_model');
$user = $this->Mapos_model->check_credentials($email);

if ($user) {
// Verificar se acesso está expirado
if ($this->chk_date($user->dataExpiracao)) {
$json = ['result' => false, 'message' => 'A conta do usuário está expirada, por favor entre em contato com o administrador do sistema.'];
echo json_encode($json);
exit();
}

// Verificar credenciais do usuário
if (password_verify($password, $user->senha)) {
// Novo ID de sessão a cada autenticação, para que um ID
// fixado antes do login não continue válido depois dele.
$this->session->sess_regenerate(true);

$session_admin_data = ['nome_admin' => $user->nome, 'email_admin' => $user->email, 'url_image_user_admin' => $user->url_image_user, 'id_admin' => $user->idUsuarios, 'permissao' => $user->permissoes_id, 'logado' => true];
$this->session->set_userdata($session_admin_data);
log_info('Efetuou login no sistema');
$json = ['result' => true];
echo json_encode($json);
} else {
$json = ['result' => false, 'message' => 'Os dados de acesso estão incorretos.', 'MAPOS_TOKEN' => $this->security->get_csrf_hash()];
echo json_encode($json);
}
} else {
// Mesma mensagem do erro de senha: mensagens distintas revelam
// quais e-mails possuem conta.
$json = ['result' => false, 'message' => 'Os dados de acesso estão incorretos.', 'MAPOS_TOKEN' => $this->security->get_csrf_hash()];
echo json_encode($json);
}
// Pelo CI_Output, e não pelo header() do PHP: o Output só emite no fim
// da requisição, o que mantém o cabeçalho correto sem acordar os avisos
// de "Cannot modify header information" do CLI, que o Whoops converte
// em exceção e abortava a suíte. Não existe um set_headers() plural no
// CI3, então cada cabeçalho sai pelo set_header().
$this->output->set_header('Access-Control-Allow-Origin: ' . base_url());
$this->output->set_header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
$this->output->set_header('Access-Control-Max-Age: 1000');
$this->output->set_header('Access-Control-Allow-Headers: Content-Type');

$email = trim((string) $this->input->post('email'));
$password = trim((string) $this->input->post('senha'));

$erro = $this->validarCredenciais($email, $password);

if ($erro !== null) {
return $this->respondFailure($erro);
}

$this->load->model('Mapos_model');
$user = $this->Mapos_model->check_credentials($email);

if (! $user) {
// Mesma mensagem do erro de senha: mensagens distintas revelam
// quais e-mails possuem conta.
return $this->respondFailure('Os dados de acesso estão incorretos.');
}

if ($this->chk_date($user->dataExpiracao)) {
return $this->respondFailure('A conta do usuário está expirada, por favor entre em contato com o administrador do sistema.');
}

if (! password_verify($password, $user->senha)) {
return $this->respondFailure('Os dados de acesso estão incorretos.');
}

// Novo ID de sessão a cada autenticação, para que um ID
// fixado antes do login não continue válido depois dele.
$this->session->sess_regenerate(true);

$this->session->set_userdata([
'nome_admin' => $user->nome,
'email_admin' => $user->email,
'url_image_user_admin' => $user->url_image_user,
'id_admin' => $user->idUsuarios,
'permissao' => $user->permissoes_id,
'logado' => true,
]);

log_info('Efetuou login no sistema');

return $this->respondJson(['result' => true]);
}

/**
* Devolve a mensagem de erro, ou null quando os campos estão preenchidos.
*
* A view já valida no navegador (jQuery Validate), então isto é a segunda
* linha: o endpoint é público e pode ser chamado direto.
*
* As mensagens são texto puro de propósito. A resposta é JSON e a view
* escreve com $('#message').text(), então o HTML do validation_errors()
* apareceria como tag visível na tela, em inglês, com uma mensagem por
* linha. Um único texto também serve para o log e para o Swal de outros
* pontos que consomem este endpoint.
*/
private function validarCredenciais(string $email, string $senha): ?string
{
if ($email === '' || $senha === '') {
return 'Preencha o e-mail e a senha.';
}
exit();

if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return 'Insira um e-mail válido.';
}

return null;
}

private function chk_date($data_banco)
private function respondFailure(string $message)
{
$data_banco = new DateTime($data_banco);
$data_hoje = new DateTime('now');
// MAPOS_TOKEN vai em toda falha, e não só na de credenciais: a view usa
// o campo para renovar o token a cada tentativa, e um caminho sem o
// campo deixaria o formulário com o token velho.
return $this->respondJson([
'result' => false,
'message' => $message,
'MAPOS_TOKEN' => $this->security->get_csrf_hash(),
]);
}

private function respondJson(array $payload)
{
$this->output
->set_content_type('application/json')
->set_output(json_encode($payload, JSON_UNESCAPED_UNICODE));
}

/**
* Diz se a expiração da conta já passou.
*
* dataExpiracao é date DEFAULT NULL, então null é um valor legítimo: conta
* sem expiração. Passar null para new DateTime() é depreciado no PHP 8.1
* e, com failOnDeprecation someday ligado, vira erro.
*/
private function chk_date(?string $dataExpiracao): bool
{
if ($dataExpiracao === null || $dataExpiracao === '') {
return false;
}

return $data_banco < $data_hoje;
return new DateTime($dataExpiracao) < new DateTime('now');
}
}
Loading
Loading