Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Quality Checks

on:
push:
branches: [master, main]
pull_request:

permissions:
contents: read

jobs:
checks:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: curl, gd, mbstring
coverage: none

- name: Validate composer.json
run: composer validate --no-check-publish

- name: Install dependencies
run: composer install --no-interaction --no-progress

- name: Check view output escaping
run: composer xss:check

- name: Check code style
run: composer format:check
2 changes: 2 additions & 0 deletions .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
->notPath('vendor')
->notPath('bootstrap')
->notPath('storage')
// Local development mounts a MySQL data directory here; it is not project code.
->exclude('docker/data')
->in(__DIR__)
->name('*.php')
->notName('*.blade.php');
Expand Down
41 changes: 40 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,53 @@ Map-OS is an open-source Service Order and Business Management system built in P
- Always format PHP code using project standards: `composer format` (which invokes `application/vendor/bin/php-cs-fixer fix`).
2. **Security & Input/Output Handling:**
- Always validate user input.
- Always escape output in views using `html_escape()`.
- **Always escape output in views.** Use the helpers in `application/helpers/general_helper.php`, picking the one that matches the output context (see table below). Never `echo` a database row, `$_GET`/`$_POST` value, session value or config value raw.
- Never use raw SQL string concatenation; use CodeIgniter Query Builder or query bindings (`?` or `$this->db->where()`) to prevent SQL injection.
- Never expose sensitive data (e.g., password hashes) in public models or API responses.
3. **Database Changes:**
- Schema modifications must be implemented via migrations (`application/database/migrations/`), never by editing `banco.sql` directly.
4. **Commit Messages:**
- Follow [Conventional Commits](https://www.conventionalcommits.org/): `feat`, `fix`, `docs`, `refactor`, `chore`, etc.

## Output Escaping in Views

Pick the escaper by the context the value lands in. Using the wrong one is a
common source of XSS, so match the row, not just the habit.

| Helper | Use for | Example |
| --- | --- | --- |
| `esc($v)` | HTML text and quoted attributes | `<td><?= esc($row->nome) ?></td>`, `value="<?= esc($row->nome) ?>"` |
| `esc_js($v)` | JS string / value inside `<script>` | `var x = <?= esc_js($v) ?>;` |
| `esc_json($v)` | Arrays and structs inside `<script>` | `var cfg = <?= esc_json($arr) ?>;` |
| `esc_url($v)` | `href` / `src`; rejects `javascript:`, `data:` | `href="<?= esc_url($link) ?>"` |
| `esc_css($v)` | Values inside a `style` attribute | `style="color: <?= esc_css($c) ?>"` |
| `esc_msg($v)` | Flashdata messages shown in a SweetAlert2 popup | `Swal.fire({ text: <?= esc_msg($m) ?> })` |
| `printSafeHtml($v)` | **Only** rich text from a WYSIWYG field (HTMLPurifier) | `<?= printSafeHtml($os->defeito) ?>` |
| *none* | A value that already holds finished markup | `<?= $modalGerarPagamento ?>` |

Note on `esc_url()` and `src`: it rejects `data:`, so an `<img src>` holding a
data URI must use `esc()`, not `esc_url()`. The QR codes rendered by
`getQrCode()` are data URIs, and passing them through `esc_url()` returns an
empty string and silently drops the image.

Additional rules:

- Escape inside string concatenation part by part: `<?= esc($a->rua) . ', ' . esc($a->numero) ?>`.
- In `<script>`, prefer `Swal.fire({ text: ... })` over `title:`/`html:` — those are parsed as HTML.
- `esc()` inside `value="…"` is transparent to JavaScript, because the browser decodes entities before `.val()` returns the value.
- `esc_js()` and `esc_json()` already include the surrounding quotes for scalars, so never add manual quotes: `"<?= esc_js($v) ?>"` emits `"\"value\""`. Write `<?= esc_js($v) ?>` bare.
- For the same reason, never pass them to `JSON.parse()` — assign the value directly: `var cfg = <?= esc_json($arr) ?>;`.
- `esc_json()` casts scalars to string, so an int or float arrives as a JS string, not a number. Only `bool` and `null` keep their own type (`true`/`false`/`null`). Arrays and objects are emitted as a JSON literal.
- Values that intentionally carry pre-rendered markup (`$topo`, `$custom_error`, `$modalGerarPagamento`, and anything from `printSafeHtml()`) must not be escaped again — escape them where they are built instead. These come from `$this->load->view($name, $data, true)` or from markup the controller assembled, so they are already finished HTML. Escaping one is not redundant, it is destructive: `htmlspecialchars()` turns the markup into visible text **and neutralises any `<script>` nested inside it**, which silently disables whatever that view was included for.
- Build URLs with `rawurlencode()` on each query value, then pass the result through `esc_url()`.
- Run `composer xss:check` after editing views. It runs three checks: values that reach the page without an escaper, values whose type the escaper changes (such as a `JSON.parse()` fed an escaper), and pre-rendered markup wrongly wrapped in an escaper. If an omission is deliberate, record it with `composer xss:baseline` and explain it in `tools/xss-baseline.txt`.

Note: `global_xss_filtering` in `application/config/config.php` is an input
filter, not output encoding. It does not protect values read from the database
and it rewrites legitimate input, so it is not a substitute for the escapers
above.


## Security & Integrity Mandates

- **Secrets:** Never log, print, or commit `.env` files, credentials, API keys, or database dumps.
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,13 @@ composer format
Para apenas verificar, sem alterar os arquivos:

```bash
application/vendor/bin/php-cs-fixer fix --dry-run --diff
composer format:check
```

Boas práticas adicionais:

- Escape a saída nas views para evitar XSS: use `html_escape()` para texto e o helper `printSafeHtml()` (`application/helpers/general_helper.php`, baseado no HTMLPurifier) quando precisar renderizar HTML vindo do usuário.
- 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.
- 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.
- Siga o idioma já usado no arquivo que você está editando (o código do projeto mistura português e inglês; mantenha a consistência local em vez de renomear o entorno).
Expand Down Expand Up @@ -282,6 +283,7 @@ A descrição pode ser em português ou inglês — o histórico aceita ambos. P

- [ ] O PR resolve **um** problema (evite juntar assuntos diferentes).
- [ ] O código está formatado (`composer format`).
- [ ] Se você editou views, `composer xss:check` passa.
- [ ] Não há credenciais, `.env`, dumps de banco ou arquivos de IDE no diff.
- [ ] A pasta `application/vendor/` não foi commitada.
- [ ] Alterações de schema têm migration com `up()` e `down()`.
Expand Down
4 changes: 3 additions & 1 deletion application/controllers/Mine.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ public function gerarTokenResetarSenha()
// Mesma resposta de sucesso quando o e-mail não existe, para não
// permitir enumeração de contas.
log_info('Cliente solicitou alteração de senha para um e-mail inexistente.');
$this->session->set_flashdata('success', 'Solicitação realizada com sucesso! <br> Um e-mail com as instruções será enviado para ' . html_escape($emailSolicitado));
// A mensagem é escapada no momento da exibição (views com esc_msg),
// por isso o valor deve ser enviado puro.
$this->session->set_flashdata('success', 'Solicitação realizada com sucesso! <br> Um e-mail com as instruções será enviado para ' . $emailSolicitado);
redirect(base_url() . 'index.php/mine');
} else {
$this->load->helper('string');
Expand Down
174 changes: 174 additions & 0 deletions application/helpers/general_helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,181 @@ function json_decode_legacy(string $raw): mixed
}
}

if (! function_exists('esc')) {
/**
* Escapa um valor para contexto de texto HTML ou atributo entre aspas.
*
* Use em `<?= esc($valor) ?>` dentro do corpo da pagina e dentro de
* atributos delimited por aspas (`value="<?= esc($valor) ?>"`).
*
* @param mixed $value
*/
function esc($value): string
{
if ($value === null || is_bool($value)) {
return '';
}

if (is_array($value) || is_object($value)) {
return '';
}

return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

if (! function_exists('esc_js')) {
/**
* Escapa um valor para contexto JavaScript.
*
* Use dentro de `<script>`, tanto em literais de string
* (`var x = <?= esc_js($v) ?>;`) quanto em interpolacoes de atributos
* JS. O `json_encode` com flags HEX garante que tanto `"` quanto
* `</script>` fiquem neutralizados.
*
* @param mixed $value
*/
function esc_js($value): string
{
return esc_json($value);
}
}

if (! function_exists('esc_json')) {
/**
* Codifica um valor como JSON seguro para ser embutido em `<script>`.
*
* Use para arrays/estruturas: `var cfg = <?= esc_json($arr) ?>;`.
*
* @param mixed $value
*/
function esc_json($value): string
{
$flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
| JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE;

if (is_bool($value)) {
return $value ? 'true' : 'false';
}

if ($value === null) {
return 'null';
}

if (is_array($value) || is_object($value)) {
$encoded = json_encode($value, $flags);

return $encoded === false ? 'null' : $encoded;
}

$encoded = json_encode((string) $value, $flags);

return $encoded === false ? '""' : $encoded;
}
}

if (! function_exists('esc_url')) {
/**
* Escapa uma URL para uso em `href`/`src`.
*
* Rejeita esquemas executaveis (`javascript:`, `data:`, `vbscript:`) e
* devolve string vazia, o que neutraliza ataques de URI execucao.
*
* @param mixed $url
*/
function esc_url($url): string
{
if ($url === null || is_array($url) || is_object($url) || is_bool($url)) {
return '';
}

$url = trim((string) $url);

if ($url === '') {
return '';
}

// Barras de controle e espacos internos sao removidos por alguns
// navegadores ao resolver a URL, o que permitiria contrabandear um
// esquema (ex.: "java\nscript:alert(1)"). Recusamos antes de decidir.
if (preg_match('/[\x00-\x20\x7F]/', $url)) {
return '';
}

// Ancoras, query strings e URLs relativas/associadas nao possuem
// esquema proprio, portanto nao podem trocar a origem da pagina.
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));

if ($scheme === '') {
return esc($url);
}

$allowed = ['http', 'https', 'mailto', 'tel', 'whatsapp', 'ftp', 'ftps'];

if (! in_array($scheme, $allowed, true)) {
return '';
}

return esc($url);
}
}

if (! function_exists('esc_css')) {
/**
* Escapa um valor para contexto de estilo em CSS inline.
*
* @param mixed $value
*/
function esc_css($value): string
{
if ($value === null || is_array($value) || is_object($value) || is_bool($value)) {
return '';
}

$value = trim((string) $value);

// Impede quebra de contexto e injecao de regras via `;`, `{` ou `}`.
$value = (string) preg_replace('/[^a-zA-Z0-9#%.,()\s\-_]/', '', $value);

return str_replace(['\\', '<', '>'], '', $value);
}
}

if (! function_exists('esc_msg')) {
/**
* Prepara uma mensagem de flashdata para exibicao em um alerta JS.
*
* As mensagens historicas carregam `<br>` para quebrar linha. Aqui o
* `<br>` e convertido em quebra de linha real e **toda** as demais
* marcacao e descartada, de forma que a mensagem nunca seja interpretada
* como HTML pelo alerta. Sempre devolver dentro de `esc_js()`, ex.:
*
* Swal.fire({ icon: 'success', text: <?= esc_msg($msg) ?> });
*
* @param mixed $message
*/
function esc_msg($message): string
{
if ($message === null || is_array($message) || is_object($message) || is_bool($message)) {
return '""';
}

$message = (string) $message;
$message = (string) preg_replace('#<br\s*/?>#i', "\n", $message);
$message = strip_tags($message);

return esc_js($message);
}
}

if (! function_exists('printSafeHtml')) {
/**
* Sanitiza conteudo rico (HTML vindo de editor WYSIWYG) via HTMLPurifier.
*
* Use **apenas** para campos que legitimamente aceitam HTML
* (descricao de produto, defeito, observacoes, laudo tecnico, termo de
* garantia). Para texto simples e para atributos use `esc()`.
*/
function printSafeHtml(string $html): string
{
static $purifier = null;
Expand Down
16 changes: 8 additions & 8 deletions application/views/arquivos/arquivos.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,29 +56,29 @@
}
foreach ($results as $r) : ?>
<tr>
<td><?= $r->idDocumentos ?></td>
<td><?= esc($r->idDocumentos) ?></td>
<td>
<?php if (@getimagesize($r->path)) : ?>
<a href="<?= $r->url ?>"> <img src="<?= $r->url ?> "></a>
<a href="<?= esc($r->url) ?>"> <img src="<?= esc($r->url) ?> "></a>
<?php else : ?>
<span>-</span>
<?php endif ?>
</td>
<td><?= $r->documento ?></td>
<td><?= esc($r->documento) ?></td>
<td><?= date('d/m/Y', strtotime($r->cadastro)) ?></td>
<td><?= $r->descricao ?></td>
<td><?= $r->tamanho ?> KB</td>
<td><?= $r->tipo ?></td>
<td><?= esc($r->descricao) ?></td>
<td><?= esc($r->tamanho) ?> KB</td>
<td><?= esc($r->tipo) ?></td>
<td><?php if ($this->permission->checkPermission($this->session->userdata('permissao'), 'vArquivo')) : ?>
<a href="<?= base_url() ?>index.php/arquivos/download/<?= $r->idDocumentos; ?>" class="btn-nwe" title="Baixar Arquivo"><i class="bx bx-download"></i>
<?php endif ?>

<?php if ($this->permission->checkPermission($this->session->userdata('permissao'), 'eArquivo')) : ?>
<a href="<?= base_url() ?>index.php/arquivos/editar/<?= $r->idDocumentos ?>" class="btn-nwe3" title="Editar"><i class="bx bx-edit"></i></a>
<a href="<?= base_url() ?>index.php/arquivos/editar/<?= esc($r->idDocumentos) ?>" class="btn-nwe3" title="Editar"><i class="bx bx-edit"></i></a>
<?php endif ?>

<?php if ($this->permission->checkPermission($this->session->userdata('permissao'), 'dArquivo')) : ?>
<a href="#modal-excluir" style="margin-right: 1%" role="button" data-toggle="modal" arquivo="<?= $r->idDocumentos ?>" class="btn-nwe4" title="Excluir"><i class="bx bx-trash-alt"></i></a>
<a href="#modal-excluir" style="margin-right: 1%" role="button" data-toggle="modal" arquivo="<?= esc($r->idDocumentos) ?>" class="btn-nwe4" title="Excluir"><i class="bx bx-trash-alt"></i></a>
</a>
<?php endif ?>
</td>
Expand Down
Loading
Loading