Segurança em Sistemas WEB

SEGURANÇA EM SISTEMAS WEB

Recentemente rodamos um Pentest para detectar vulnerabilidades existentes no sistema da empresa na qual trabalho e diversos problemas foram apontados. Neste artigo irei falar um pouco cada item e quais ações tomadas para corrigir essas vulnerabilidades.

1. Cabeçalhos HTTP

As configurações de cabeçalhos abaixo são voltadas para informar o navegador como se comportar diante de uma determinada situação. O título de cada diretiva é um link com direcionamento para a página do Mozilla, onde contém maiores detalhes.

1.1. Access-Control-Allow-Origin

Indica quais as origens (domínios/subdomínios) estão habilitadas a acessar recursos da página.

Ex.: uma página que recebe via POST um array de filtro e retorna um JSON com uma lista de usuários. Segue abaixo dois exemplos de configuração do cabeçalho:

– “Access-Control-Allow-Origin: https://www.meudominio.com.br => irá atender somente requisições de origem https://www.meudominio.com.br;

– “Access-Control-Allow-Origin: *” => irá atender a qualquer origem, ou seja, um sistema externo poderá consumir as informações da página;

PHP
header(“Access-Control-Allow-Origin: https://www.meudominio.com.br”);

1.2. Content-Security-Policy (CSP)

Indica o nível de segurança de cada item (diretiva) incorporado à página, por exemplo: imagem, iframe, Javascript etc. Utilizado para evitar ataques XSS (Cross Site Scripting) e injeção de dados.

As diretivas podem ser declaradas sequencialmente, separadas por “;”.

Ex.: Content-Security-Policy: img-src *; script-src ‘self’

PHP

header(“Content-Security-Policy: frame-ancestors ‘self’; img-src ‘self’; child-src ‘self’ https://www.google.com; “);

Diretivas Aceitas

Clique aqui para consultar a lista completa de diretivas e compatibilidade com os principais navegadores. As principais diretivas são:

default-src: valor default, caso a diretiva não seja declarada;

child-src: origem válida para web workers e contextos aninhados de navegação carregados por um <iframe>. Podem ser declarados separadamente com “worker-src” e “frame-src”, respectivamente;

img-src: imagens <img>;

script-src: Javascripts <script>;

style-src: origens válidas para CSS <style>, <link rel=“stylesheet”> e css inline (attr). Podem ser declaradas separadamente com “style-src-elem” e “style-src-attr”, respectivamente;

form-action: restringe URL’s alvo para submissões de formulários;

1.3. X-Content-Type-Options

Usado pelo servidor para indicar que os MIME types enviados pelos headers Content-Type não devem ser alterados e seguidos. Evita que navegadores interpretem o conteúdo da página (sniffing) e execute o dado como código/tag.  Um exemplo básico é ao fazer o upload de um arquivo texto com um código javascript e o navegador ler o conteúdo que está no arquivo e executar, mesmo sendo apenas um texto e não parte do código.

PHP

header(“X-Content-Type-Options: nosniff”);

1.4. Strict-Transport-Security (HSTS)

Permite que um site informe aos navegadores que ele deve ser acessado apenas por HTTPS, em vez de usar HTTP. “max-age” indica o tempo, em segundos, que o navegador deve lembrar que um site só pode ser acessado usando HTTPS.

PHP

header(“strict-transport-security: max-age= 7776000”);

1.5. X-XSS-Protection

Informa a navegadores mais modernos que deve ser aplicado os filtros anti-XSS. Os navegadores Internet Explorer, Chrome e Safari impedem páginas de carregarem quando detectado ataques XSS (ataques entre sites). Apesar destas proteções serem majoritariamente desnecessárias em navegadores modernos em sites utilizando uma forte Content-Security-Policy que desabilita o uso de JavaScript inline (‘unsafe-inline’), eles ainda podem oferecer proteções para usuários de navegadores mais antigos que ainda não suportam CSP (item 1.2).

PHP

header(“X-XSS-Protection: 1”);

Valores Possíveis

– 0: desabilita filtragem XSS;

– 1: habilita filtragem XSS (geralmente padrão em navegadores). Se um ataque de scripting entre sites é detectado, o navegador irá higienizar a página (remover as partes inseguras);

– 1; mode=block: habilita filtragem XSS. Ao invés de higienizar a página, o navegador irá impedir a renderização da página em que o ataque foi detectado;

– 1; report=<reporting-URI>: Apenas para Chromium, habilita filtragem XSS. Se o ataque de scripting entre sites é detectado, o navegador irá higienizar a página e reportar a violação. Isso utiliza a funcionalidade da diretiva CSP report-uri para enviar o relatório;

1.6. X-Frame-Options

Impossibilita que o navegador renderize conteúdo externo em objetos DOM como <frame>, <iframe> ou <object> e consequentemente protegendo a aplicação de ataques de click-jacking, assegurando que seus conteúdos não sejam embebedados em outros sites.

PHP

header(“X-Frame-Options: DENY”);

Valores Possíveis

– DENY: a página não pode ser carregada, independente do site que esteja tentando fazer isso.

– SAMEORIGIN: a página só é carregada se for de mesma origem (domínio/subdomínio);

2. Filtro de Requisições PHP

Com a finalidade de remover códigos maliciosos enviados ao servidor via requisições HTTP, podemos filtrá-las através de um script incluído automaticamente pelo PHP, para todas as requisições feitas para o servidor, seguindo o passo a passo abaixo:

2.1. Criando o Script em PHP

Escolha o diretório do arquivo e crie um arquivo chamado “auto_prepend_file.php” (sugestão). Neste arquivo, cole o código abaixo.

<?php

### INICIO do trecho voltado para prevenção de ataques XSS ###

//Funções para tratamento de string
$aFn = [
    //Configuração de páginas/campos a serem liberados e log
    'aConf' => [
        'aVarTratamento' => [
            'server' => true,
            'get'    => true,
            'post'   => true
        ],
        //Array contendo as páginas que foram liberadas (remove somente algumas tags - <script>, <input>, <form>, etc - e não todas) para $_POST.
        //Se a chave for a página e o valor um array, considera-se que somente alguns campos daquela pagina foram liberados
        'aPaginasLiberadas' => [
            'ajax/admin/atendimento.php',
            //Da pagina de login, liberar somente campo de senha, pois será convertido em MD5
            'principal/login/index.php' => [
                'senha'
            ]
        ],
        //Array contendo as páginas que foram liberadas de qualquer tratamento para $_POST.
        //Se a chave for a página e o valor um array, considera-se que somente alguns campos daquela pagina foram liberados
        'aPaginasCamposSemTratamento' => [],
        //configuracao do log para gravação de tratativas realizadas
        'aLog' => [
            'ativo'   => true,
            'debug'   => false,
            //Tamanho maximo do arquivo, em Mb
            'tamanho' => 50,
            //caminho do arquivo a partir da raiz do sistema
            'dir'     => "/arquivos/log",
            'arq'     => 'prependfile'
        ]
    ],
    //Variáveis usadas nos tratamentos
    'aVar' => [
        'aLog' => []
    ],
    //Checa se o log está ativo
    'isLogAtivo' => function () use (&$aFn) {
        return !empty($aFn['aConf']['aLog']['ativo']);
    },
    //Checa se houve alteração da string e grava em variável, para posteriormente registrar em log
    'setLog' => function ($sValorOrig, $sValor, $sNomeVar) use (&$aFn) {
        //Checando se houve alteração de string
        if ($aFn['isLogAtivo']() && !empty($sNomeVar) && !empty($sValorOrig) && $sValorOrig != $sValor && strlen($sValorOrig) !== strlen($sValor))
            $aFn['aVar']['aLog'][] = [
                'var'    => $sNomeVar,
                'antes'  => $sValorOrig,
                'depois' => $sValor
            ];
    },
    //Grava o conteúdo de [aVar][aLog] em arquivo
    'gravarLog' => function () use (&$aFn) {
        try {
            if (empty($aFn['aVar']['aLog']))
                return false;

            //Definindo o arquivo em que o log será registrado
            $sDir       = rtrim(realpath(__DIR__ . "/../"), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . trim(str_replace(["/", "\\"], DIRECTORY_SEPARATOR, $aFn['aConf']['aLog']['dir']), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
            $sArquivo   = $sDir . "{$aFn['aConf']['aLog']['arq']}.log";
            $iTamLimite = $aFn['aConf']['aLog']['tamanho'] * 1024 * 1000;

            if (!is_dir($sDir)) {
                if (!mkdir($sDir, 0777))
                    throw new Exception("Diretório inacessível: {$sDir}");
            }

            //Checando o tamanho do arquivo
            if (file_exists($sArquivo) && filesize($sArquivo) >= $iTamLimite) {
                $sOldArquivo = $sDir . "{$aFn['aConf']['aLog']['arq']}_old.log";
                if (file_exists($sOldArquivo))
                    unlink($sOldArquivo);

                rename($sArquivo, $sOldArquivo);
            }

            if (!$handle = fopen($sArquivo, "a"))
                throw new Exception("Falha ao abrir o arquivo para escrita: {$sArquivo}");

            $sSeparador = "
--
";
            $sFim       = "
----------

";

            $sTexto = "DATA: " . date("d/m/y - H:i:s") . "h
" .
                "URL: " .
                //schema
                //($_SERVER['REQUEST_SCHEME'] ?: "http") . "://" .
                //endereço
                //($_SERVER['HTTP_HOST'] ?: $_SERVER['SERVER_NAME']) .
                //porta
                //("80" != $_SERVER['SERVER_PORT'] ? ":{$_SERVER['SERVER_PORT']}" : "") .
                //caminho da url
                $_SERVER['REQUEST_URI'] . "
" .
                "IP: {$_SERVER['REMOTE_ADDR']}";

            foreach ($aFn['aVar']['aLog'] as $aL) {
                $sTexto .= $sSeparador .
                    "VAR: {$aL['var']}

" .
                    "ANTES: {$aL['antes']}

" .
                    "DEPOIS: {$aL['depois']}";
            }

            $sTexto .= $sFim;

            fwrite($handle, $sTexto);

            fclose($handle);

            if (!empty($aFn['aConf']['aLog']['debug']))
                die("ARQUIVO: " . $sDir . $sArquivo . "<br /><br />" . str_replace(["<", ">", "
"], ["&lt;", "&gt;", "<br />"], $sTexto));

            return true;
        } catch (Exception $e) {
            if (!empty($aFn['aConf']['aLog']['debug']))
                die("<b>ERRO AO GRAVAR LOG</b>: " . $e->getMessage());

            return false;
        }
    },
    //Tratamento com strip tags
    'trataStripTags' => function ($sValor, $sNomeVar = null) use (&$aFn) {
        if (is_array($sValor)) {
            foreach ($sValor as $kV => $sV)
                $sValor[$kV] = $aFn['trataStripTags']($sV, (null !== $sNomeVar ? "{$sNomeVar}[{$kV}]" : null));
        } elseif (is_string($sValor) && !empty($sValor)) {
            $sValorOrig = $sValor;
            $sValor     = strip_tags($sValor);

            $aFn['setLog']($sValorOrig, $sValor, $sNomeVar);
        }

        return $sValor;
    },
    //Tratamento para páginas exceção
    'trataPaginaExcecao' => function ($sValor, $sNomeVar = null) use (&$aFn) {
        if (is_array($sValor)) {
            foreach ($sValor as $kV => $sV)
                $sValor[$kV] = $aFn['trataPaginaExcecao']($sV, (null !== $sNomeVar ? "{$sNomeVar}[{$kV}]" : null));
        } elseif (is_string($sValor) && !empty($sValor)) {
            //Tags a serem removidas do $_POST
            $aTagsRemove = [
                "html",
                "head",
                "body",
                "footer",
                "meta",
                "script",
                //"style",
                "form",
                "input",
                "textarea",
                "select"
            ];

            //Tags que nao possuem tag de fechamento
            $aTagsSemFechamento = [
                'input',
                'meta'
            ];

            $sValorOrig = $sValor;
            foreach ($aTagsRemove as $sTag) {
                //Tags sem fechamento são removidas por completo
                if (!in_array($sTag, $aTagsSemFechamento)) {
                    //Coletando o conteúdo da tag
                    $aConteudo = [];
                    preg_match_all("#<{$sTag}.*?>(.*?)</{$sTag}>#is", $sValor, $aConteudo);

                    //Percorrendo as tags encontradas
                    if (!empty($aConteudo[1]) && is_array($aConteudo[1])) {
                        foreach ($aConteudo[1] as $sStringTag) {
                            if (empty($sStringTag))
                                continue;

                            $sValor = preg_replace("#<{$sTag}.*?>(.*?)</{$sTag}>#is", $aFn['trataStripTags']($sStringTag), $sValor, 1);
                        }
                    }
                }

                $sValor = preg_replace(["#<{$sTag}.*?/>#is", "#<{$sTag}*?>#is"], "", $sValor);
            }

            $aFn['setLog']($sValorOrig, $sValor, $sNomeVar);
        }

        return $sValor;
    },
    //Checa se a página acessada contem no array de páginas passado
    'isPagina' => function ($aPaginas = [], $sURL = null) use (&$aFn) {
        if (null === $sURL)
            $sURL = [
                $_SERVER['REQUEST_URI'],
                $_SERVER['SCRIPT_NAME']
            ];

        if (is_array($sURL)) {
            foreach ($sURL as $sU) {
                if ($sValor = $aFn['isPagina']($aPaginas, $sU))
                    return $sValor;
            }

            return false;
        }

        if (empty($sURL))
            return false;

        $sPathURL = strtolower(parse_url($sURL)['path']);
        foreach ($aPaginas as $kPagina => $sPagina) {
            $sPag = (is_array($sPagina) ? $kPagina : $sPagina);
            if (is_string($sPag) && $sPag === substr($sPathURL, strlen($sPag) * -1))
                return $sPagina;
        }

        //Para o cake, o id vem no final da url. Para estes casos, desconsiderar o ID da url e testar a página novamente
        $aPathURL = explode("/", $sPathURL);
        if (is_numeric(end($aPathURL))) {
            array_pop($aPathURL);
            return $aFn['isPagina']($aPaginas, implode("/", $aPathURL));
        }

        return false;
    },
    //Função para identificar se a página está liberada do tratamento do $_POST
    'isPaginaLiberada' => function () use (&$aFn) {
        //Paginas com liberação total tem a chave int e valor string
        $aPaginas = array_filter($aFn['aConf']['aPaginasLiberadas'], function ($sPag) {
            return is_string($sPag);
        });

        $sPagina = $aFn['isPagina']($aPaginas);

        return (!empty($sPagina));
    },
    'getCamposLiberados' => function () use (&$aFn) {
        //Páginas com liberação parcial tem a chave string e valor array
        $aPaginas = array_filter($aFn['aConf']['aPaginasLiberadas'], function ($sPag) {
            return is_array($sPag);
        });

        if (empty($aPaginas))
            return [];

        $aCampos = $aFn['isPagina']($aPaginas);

        return ($aCampos ?: []);
    },
    'getCamposSemTratamento' => function () use (&$aFn) {
        if (empty($aFn['aConf']['aPaginasCamposSemTratamento']))
            return [];

        $aCampos = $aFn['isPagina']($aFn['aConf']['aPaginasCamposSemTratamento']);

        return ($aCampos ?: []);
    },
    //Função auxiliar usada em debug
    'pre' => function ($aArray, $isDie = true) {
        echo "<pre>" . print_r($aArray, true) . "</pre>";

        if ($isDie)
            die();
    }
];

//Tratando variável $_SERVER cujo chave carrega o conteúdo editável da url
if ($aFn['aConf']['aVarTratamento']['server']) {
    foreach ([
        'PHP_SELF',
        'REQUEST_URI',
        'PATH_INFO',
        'PATH_TRANSLATED',
        'QUERY_STRING'
    ] as $sVar) {
        if (isset($_SERVER[$sVar]))
            $_SERVER[$sVar] = $aFn['trataStripTags']($_SERVER[$sVar], "\$_SERVER[{$sVar}]");
    }
}

//Tratando variáveis globais $_GET e $_RESQUEST (provenientes do GET)
if ($aFn['aConf']['aVarTratamento']['get']) {
    foreach (($_GET ?: []) as $sKey => $sVal) {
        $_GET[$sKey] = $aFn['trataStripTags']($sVal, "\$_GET[{$sKey}]");

        if (isset($_REQUEST[$sKey]) && !isset($_POST[$sKey]))
            $_REQUEST[$sKey] = $_GET[$sKey];
    }
}

if ($aFn['aConf']['aVarTratamento']['post'] && !empty($_POST)) {
    //Tratamento diferenciado para paginas excecao
    $aFn['aVar']['pagina_liberada']        = $aFn['isPaginaLiberada']();
    $aFn['aVar']['campos_liberados']       = (!$aFn['aVar']['pagina_liberada'] ? ($aFn['getCamposLiberados']() ?: []) : []);
    $aFn['aVar']['campos_sem_tratamento']  = ($aFn['getCamposSemTratamento']() ?: []);

    //Tratando variáveis $_POST e $_REQUEST (provenientes do POST)
    if (!(!empty($aFn['aVar']['campos_sem_tratamento']) && is_string($aFn['aVar']['campos_sem_tratamento']))) {
        foreach ($_POST as $sKey => $sVal) {
            //Checa se deve ignorar tratamento do campo
            if (in_array($sKey, $aFn['aVar']['campos_sem_tratamento']))
                continue;

            //Definindo a função de tratamento de acordo com a liberação
            $aFn['funcao_tratamento'] = ($aFn['aVar']['pagina_liberada'] || in_array($sKey, $aFn['aVar']['campos_liberados']) ? "trataPaginaExcecao" : "trataStripTags");

            $_POST[$sKey] = $aFn[$aFn['funcao_tratamento']]($sVal, "\$_POST[{$sKey}]");

            if (isset($_REQUEST[$sKey]))
                $_REQUEST[$sKey] = $_POST[$sKey];
        }
    }
}

$aFn['gravarLog']();

//Limpando variável utilizada no tratamento
unset($aFn);

### FIM do trecho voltado para prevenção de ataques XSS ###

Obs.: Note que não há chamadas de funções/métodos não-nativas(os) externas(os) ao documento para garantir que funcione em qualquer requisição. Tudo está atrelado a uma única variável, que é eliminada antes da próxima execução para garantir que não haja nenhuma “sujeira” a ser herdada pelos próximos blocos

2.2. Configurando o servidor PHP

Navegue até o local de instalação e edite o arquivo “php.ini”. Pesquise por “auto_prepend_file” e adicione o caminho completo até o arquivo que criamos na etapa anterior.

Ex.: auto_prepend_file=“C:\Servidor\ProjetoX\app\include\auto_prepend_file.php”

Salve o arquivo e reinicie o serviço do PHP.

3. reCAPTCHA do Google

É um tipo de medida de segurança conhecido como autenticação por desafio e resposta. O CAPTCHA protege contra spam e descriptografia de senhas com um teste simples que prova que você é um ser humano, não um computador tentando invadir uma conta protegida por senha.

3.1. Versões

O Google possui 3 versões do reCAPTCHA, que trabalham das seguintes maneiras:

Versão 1 – 2007 (descontinuada)

Uma palavra ou frase é exibida em tela e o usuário deve digitá-la no campo disponibilizado.

Montanhas ou Colinas

Versão 2 – 2014 (recomendo)

O Google analisa o comportamento do usuário e seu algoritmo decide se há a necessidade de resolver o “desafio”. Ou seja, caso o Google não tenha dúvidas de que se trata de um humano, o desafio não é exibido. Caso seja exibido, o usuário deve clicar nas imagens correspondentes ao que foi solicitado (“montranhas ou colinas”, por exemplo).

Opcionalmente exibe-se o checkbox “Não sou um robô”.

Versão 3 – 2018 (não recomendo)

O script trabalha de forma “invisível” ao usuário, analisando seu comportamento e pontuando-o em uma nota de 0 a 1. Quanto maior for a nota, maior a probabilidade de ser um humano. Cabe ao sistema decidir qual nota considerar confiável e impedir ou não a progressão do usuário.

Eu, particularmente, não recomendo esta versão pela má experiência ao utilizá-la: em base DEV, as notas sempre foram 0.9 e, ao fazer o deploy, diversos acessos obtinham notas 0.3 e 0.4 – que considerei muito baixa. Acabei por optar pela versão 2 – não tive dores de cabeça.

3.2. Funcionamento na versão 2

A página com o script do Google incorporado faz uma requisição ao servidor do Google, enviando o token público que criamos com uma conta no Google (explicada no próximo item), e o Google gera um token único para aquela operação (chamaremos de “Token de Recuperação”), que usaremos após a submissão do formulário.

Quando o usuário preenche os campos de login e senha e faz o submit do formulário, o Google decide se deve exibir o desafio de imagem (v2) ou não. Após o preenchimento, a página submita o login, senha e token que o Google gerou.

Já no servidor, enviamos o “Token de Recuperação” ao servidor do Google para que cheque se é um token válido. Sendo um token válido, seu usuário e senha é validado. Caso não passe pela validação do Google ou usuário/senha, exibimos mensagem de erro. Caso contrário, redirecionamos para página principal.

3.3. Como incorporar ao seu site (v2)

Crie ou se autentique em uma conta no Google. Acesse o endereço para criar um token clicando aqui. Selecione a versão do reCAPTCHA que escolheu, indique os domínios que irão utilizá-los e aceite os termos de serviço.

Carregue a API do Google em seu site, adicionando o seguinte trecho de código em seu HTML:

<script src="https://www.google.com/recaptcha/api.js" async defer></script>

Adicione os seguintes atributos ao seu botão do tipo “submit”:

<button class="g-recaptcha" data-sitekey="your_site_key" data-callback="onSubmit">Submit</button>

Adicione a seguinte função ao seu javascript:

function onSubmit(token) {
    document.getElementById("demo-form").submit();
}

4. CSRF

CSRF utiliza a confiança que um site tenha no navegador de um usuário autenticado para ataques maliciosos. CSRF utiliza links ou scripts para enviar solicitações de HTTP involuntárias para um site de destino onde o usuário está autenticado.

Como forma de se proteger destes ataques, é comum utilizar-se de um token único, que é gerado e injetado ao formulário no carregamento da página para que, quando o formulário seja enviado para o servidor, ele possa validar o token e dar sequência na requisição (se válido).

A grande maioria dos frameworks já vem com o recurso embutido. Caso você utilize algum framework, pesquise em sua documentação como utilizá-lo. Caso não utilize nenhum framework, esse controle deve ser criado manualmente, utilizando a sessão ou banco de dados.