From 46746558b40810937f316ed58653be7e09a24cd0 Mon Sep 17 00:00:00 2001 From: pedrobragabes Date: Thu, 16 Jul 2026 12:28:35 -0300 Subject: [PATCH] Harden frontend XSS and script CSP --- backend/minha-conta/js/app.js | 11 +- .../sections/admin/ModeracaoLojasSection.js | 14 +- .../js/sections/common/InicioSection.js | 4 +- .../js/sections/common/PerfilSection.js | 16 +- .../js/sections/merchant/ProdutosSection.js | 13 +- backend/src/__tests__/accountPanelXss.test.js | 42 +++++ .../src/__tests__/frontendSecurity.test.js | 149 ++++++++++++++++ .../src/__tests__/serviceWorkerCache.test.js | 4 +- backend/src/server.js | 2 +- css/style.css | 11 ++ html/cadastro.html | 157 ++--------------- html/login.html | 110 +----------- index.html | 161 ++++++------------ js/app.js | 134 +++++++++------ js/modules/auth-ui.js | 6 +- js/modules/map.js | 29 +++- js/modules/merchant-ui.js | 6 +- js/modules/utils.js | 15 ++ js/pages/cadastro.js | 142 +++++++++++++++ js/pages/login.js | 96 +++++++++++ js/render/cards.js | 25 ++- js/render/modal.js | 49 ++++-- js/render/promotions.js | 4 +- sw.js | 4 +- 24 files changed, 742 insertions(+), 462 deletions(-) create mode 100644 backend/src/__tests__/accountPanelXss.test.js create mode 100644 backend/src/__tests__/frontendSecurity.test.js create mode 100644 js/pages/cadastro.js create mode 100644 js/pages/login.js diff --git a/backend/minha-conta/js/app.js b/backend/minha-conta/js/app.js index 84a4ec0..34dcb3b 100644 --- a/backend/minha-conta/js/app.js +++ b/backend/minha-conta/js/app.js @@ -70,9 +70,14 @@ function renderStoreSwitcher() { } storeSwitch.classList.remove('hidden'); - storeSelect.innerHTML = ctx.stores - .map(s => ``) - .join(''); + const options = ctx.stores.map((store) => { + const option = document.createElement('option'); + option.value = String(store.id); + option.textContent = String(store.nome || 'Loja sem nome'); + option.selected = store.id === ctx.activeStoreId; + return option; + }); + storeSelect.replaceChildren(...options); storeSelect.addEventListener('change', async () => { const storeId = Number(storeSelect.value); diff --git a/backend/minha-conta/js/sections/admin/ModeracaoLojasSection.js b/backend/minha-conta/js/sections/admin/ModeracaoLojasSection.js index 49c4682..25007f4 100644 --- a/backend/minha-conta/js/sections/admin/ModeracaoLojasSection.js +++ b/backend/minha-conta/js/sections/admin/ModeracaoLojasSection.js @@ -53,7 +53,7 @@ async function carregarLojas(container, ctx) { return; } - tbody.innerHTML = lojas.map(l => ` + tbody.innerHTML = lojas.map((l, index) => `
@@ -66,7 +66,7 @@ async function carregarLojas(container, ctx) { ${l.aberto ? 'Aberto' : 'Fechado'}
- +
@@ -74,6 +74,16 @@ async function carregarLojas(container, ctx) { `).join(''); // Eventos + tbody.querySelectorAll('.btn-ver-pagina').forEach(btn => { + btn.addEventListener('click', () => { + const loja = lojas[Number(btn.dataset.index)]; + if (!loja?.slug) return; + const perfilUrl = new URL('/', window.location.origin); + perfilUrl.searchParams.set('loja', String(loja.slug)); + window.open(perfilUrl.href, '_blank', 'noopener,noreferrer'); + }); + }); + tbody.querySelectorAll('.btn-suspender').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; diff --git a/backend/minha-conta/js/sections/common/InicioSection.js b/backend/minha-conta/js/sections/common/InicioSection.js index 2b57fa3..925ad73 100644 --- a/backend/minha-conta/js/sections/common/InicioSection.js +++ b/backend/minha-conta/js/sections/common/InicioSection.js @@ -66,7 +66,7 @@ export function mount(container, ctx) {
${config.emoji}
-

Olá, ${user.nome.split(' ')[0]}!

+

Olá, !

${config.titulo}

@@ -173,6 +173,8 @@ export function mount(container, ctx) { `; + container.querySelector('#inicio-primeiro-nome').textContent = String(user.nome || '').split(' ')[0]; + // Delegação de eventos nos cards de acesso rápido const handleCardClick = (e) => { const card = e.target.closest('.inicio-card[data-path]'); diff --git a/backend/minha-conta/js/sections/common/PerfilSection.js b/backend/minha-conta/js/sections/common/PerfilSection.js index a72c455..8d3e78a 100644 --- a/backend/minha-conta/js/sections/common/PerfilSection.js +++ b/backend/minha-conta/js/sections/common/PerfilSection.js @@ -19,10 +19,10 @@ export function mount(container, ctx) {
-
${user.nome.charAt(0).toUpperCase()}
+
-

${user.nome}

-

${{ admin: 'Administrador', comerciante: 'Comerciante', cliente: 'Cliente' }[user.role] || user.role}

+

+

@@ -58,7 +58,7 @@ export function mount(container, ctx) {

E-mail (não editável)

-

${user.email}

+

@@ -97,6 +97,14 @@ export function mount(container, ctx) { `; + const displayName = String(user.nome || ''); + const roleLabel = { admin: 'Administrador', comerciante: 'Comerciante', cliente: 'Cliente' }[user.role] + || String(user.role || ''); + container.querySelector('#perfil-avatar-preview').textContent = displayName.charAt(0).toUpperCase(); + container.querySelector('#perfil-avatar-name').textContent = displayName; + container.querySelector('#perfil-avatar-role').textContent = roleLabel; + container.querySelector('#perfil-email').textContent = String(user.email || ''); + // Carrega dados atuais loadPerfil(container, ctx); diff --git a/backend/minha-conta/js/sections/merchant/ProdutosSection.js b/backend/minha-conta/js/sections/merchant/ProdutosSection.js index 98e01e8..4b1690a 100644 --- a/backend/minha-conta/js/sections/merchant/ProdutosSection.js +++ b/backend/minha-conta/js/sections/merchant/ProdutosSection.js @@ -192,15 +192,15 @@ function mostrarFormulario(container, ctx, produto) {
- +
- +
- +
@@ -218,12 +218,17 @@ function mostrarFormulario(container, ctx, produto) {
`; + const form = formEl.querySelector('#form-produto'); + form.elements.nome.value = produto ? (produto.nome_produto || produto.nome || '') : ''; + form.elements.descricao.value = produto?.descricao || ''; + form.elements.preco.value = produto?.preco || ''; + formEl.querySelector('#btn-cancelar-produto').addEventListener('click', () => { formEl.classList.add('hidden'); formEl.innerHTML = ''; }); - formEl.querySelector('#form-produto').addEventListener('submit', async (e) => { + form.addEventListener('submit', async (e) => { e.preventDefault(); const f = e.target; const body = { diff --git a/backend/src/__tests__/accountPanelXss.test.js b/backend/src/__tests__/accountPanelXss.test.js new file mode 100644 index 0000000..ebdc0b5 --- /dev/null +++ b/backend/src/__tests__/accountPanelXss.test.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.join(__dirname, '..', '..', '..'); + +function readPanelFile(...segments) { + return fs.readFileSync(path.join(projectRoot, 'backend', 'minha-conta', 'js', ...segments), 'utf8'); +} + +describe('Sinks XSS do painel Minha Conta', () => { + it('preenche dados editaveis de produto pelas propriedades value do DOM', () => { + const source = readPanelFile('sections', 'merchant', 'ProdutosSection.js'); + + expect(source).not.toContain('value="${produto'); + expect(source).not.toContain('${produto?.descricao'); + expect(source).toContain('form.elements.nome.value ='); + expect(source).toContain('form.elements.descricao.value ='); + }); + + it('renderiza nomes e dados da conta com textContent', () => { + const app = readPanelFile('app.js'); + const perfil = readPanelFile('sections', 'common', 'PerfilSection.js'); + const inicio = readPanelFile('sections', 'common', 'InicioSection.js'); + + expect(app).toContain('option.textContent ='); + expect(app).not.toContain('>${s.nome}'); + expect(perfil).not.toContain('${user.nome}'); + expect(perfil).not.toContain('${user.email}'); + expect(perfil).toContain("querySelector('#perfil-avatar-name').textContent"); + expect(perfil).toContain("querySelector('#perfil-email').textContent"); + expect(inicio).not.toContain("${user.nome.split(' ')[0]}"); + expect(inicio).toContain("querySelector('#inicio-primeiro-nome').textContent"); + }); + + it('nao usa handler inline para abrir a pagina moderada', () => { + const source = readPanelFile('sections', 'admin', 'ModeracaoLojasSection.js'); + + expect(source).not.toMatch(/\sonclick\s*=/i); + expect(source).toContain("searchParams.set('loja', String(loja.slug))"); + expect(source).toContain("'noopener,noreferrer'"); + }); +}); diff --git a/backend/src/__tests__/frontendSecurity.test.js b/backend/src/__tests__/frontendSecurity.test.js new file mode 100644 index 0000000..9fd2d15 --- /dev/null +++ b/backend/src/__tests__/frontendSecurity.test.js @@ -0,0 +1,149 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const request = require('supertest'); +const app = require('../server'); + +const projectRoot = path.join(__dirname, '..', '..', '..'); + +function filesBelow(relativeRoot, extensions) { + const absoluteRoot = path.join(projectRoot, relativeRoot); + if (!fs.existsSync(absoluteRoot)) return []; + + return fs.readdirSync(absoluteRoot, { withFileTypes: true }).flatMap(entry => { + const relativePath = path.join(relativeRoot, entry.name); + return entry.isDirectory() + ? filesBelow(relativePath, extensions) + : extensions.includes(path.extname(entry.name)) ? [relativePath] : []; + }); +} + +function read(relativePath) { + return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'); +} + +function loadFrontendModule(relativePath, exports, context = {}) { + const module = { exports: {} }; + const source = read(relativePath) + .replace(/^import .*;\r?\n/gm, '') + .replace(/export function /g, 'function ') + .concat(`\nmodule.exports = { ${exports.join(', ')} };`); + + vm.runInNewContext(source, { + module, + URL, + window: { location: { origin: 'https://comerciobes.com.br' } }, + ...context, + }, { filename: relativePath }); + + return module.exports; +} + +describe('XSS e Content Security Policy do frontend', () => { + it('nao usa handlers nem scripts executaveis inline', () => { + const sourceFiles = [ + 'index.html', + ...filesBelow('html', ['.html']), + ...filesBelow('js', ['.js']), + ...filesBelow(path.join('backend', 'minha-conta'), ['.html', '.js']), + ]; + + sourceFiles.forEach(relativePath => { + expect(read(relativePath)).not.toMatch(/\bon[a-z]+\s*=\s*["']/i); + }); + + const htmlFiles = sourceFiles.filter(relativePath => relativePath.endsWith('.html')); + htmlFiles.forEach(relativePath => { + const scriptTags = read(relativePath).match(/]*>/gi) || []; + scriptTags.forEach(tag => expect(tag).toMatch(/\bsrc\s*=/i)); + }); + }); + + it('aplica a CSP de scripts tambem quando o frontend e hospedado como site estatico', () => { + const publicPages = ['index.html', ...filesBelow('html', ['.html'])]; + + publicPages.forEach(relativePath => { + const html = read(relativePath); + const meta = html.match( + //i + ); + + expect(meta).not.toBeNull(); + const scriptSrc = meta[1].split(';') + .find(directive => directive.trim().startsWith('script-src')); + expect(scriptSrc).toContain("script-src 'self' https://unpkg.com"); + expect(scriptSrc).not.toContain("'unsafe-inline'"); + // A politica portatil nao restringe conexoes: o fallback file:// da #41 + // ainda precisa alcancar http://localhost:3000. + expect(meta[1]).not.toContain('connect-src'); + expect(meta[1]).not.toContain('upgrade-insecure-requests'); + }); + }); + + it('entrega script-src sem unsafe-inline', async () => { + const page = await request(app).get('/'); + const directives = page.headers['content-security-policy'].split(';'); + const scriptSrc = directives.find(directive => directive.trim().startsWith('script-src')); + + expect(scriptSrc).toBeDefined(); + expect(scriptSrc).not.toContain("'unsafe-inline'"); + expect(scriptSrc).toContain("'self'"); + }); + + it('rejeita URLs maliciosas e renderiza dados da loja apenas como texto', () => { + const utils = loadFrontendModule( + 'js/modules/utils.js', + ['escapeHTML', 'safeImageUrl', 'safeCoordinates', 'gerarStars'] + ); + const cards = loadFrontendModule('js/render/cards.js', ['criarCard'], { + ITEMS_POR_PAGINA: 8, + Favorites: { isFav: () => false }, + observarLazyImages: () => {}, + state: {}, + filtrarPorCategoria: () => [], + ...utils, + }); + + expect(utils.safeImageUrl('javascript:alert(1)')).toBeNull(); + expect(utils.safeImageUrl('data:image/svg+xml,')).toBeNull(); + expect(utils.safeImageUrl('https://res.cloudinary.com.evil.test/foto.jpg')).toBeNull(); + expect(utils.safeImageUrl('/uploads/../admin/segredo.jpg')).toBeNull(); + expect(utils.safeCoordinates(null, null)).toBeNull(); + expect(utils.safeCoordinates('', '')).toBeNull(); + expect(utils.safeCoordinates(0, 0)).toBeNull(); + expect(utils.safeCoordinates(91, -48.39)).toBeNull(); + expect(utils.safeCoordinates(-21.99, -48.39)).toEqual({ lat: -21.99, lng: -48.39 }); + + const payload = '\">'; + const html = cards.criarCard({ + id: 7, + nome: payload, + categoria: 'padaria', + tags: [], + endereco: payload, + emoji: payload, + fotos: ['javascript:alert(1)'], + rating: 5, + visitas: 1, + whatsapp: '5516999999999', + aberto: true, + destaque: false, + catalogo: [], + }); + + expect(html).toContain('<img src=x onerror=globalThis.__xss=1>'); + expect(html).not.toContain(']+\bon[a-z]+\s*=/i); + }); + + it('nao interpola o slug da loja em codigo executavel', () => { + const modalSource = read('js/render/modal.js'); + const maliciousSlug = "x');globalThis.__xss=1;//"; + + expect(modalSource).not.toContain('onclick='); + expect(modalSource).not.toContain("copiarLinkLoja('" + maliciousSlug); + expect(modalSource).toContain('data-action="copy-store-link"'); + expect(modalSource).toContain('data-comercio-id='); + }); +}); diff --git a/backend/src/__tests__/serviceWorkerCache.test.js b/backend/src/__tests__/serviceWorkerCache.test.js index f780392..d699f0e 100644 --- a/backend/src/__tests__/serviceWorkerCache.test.js +++ b/backend/src/__tests__/serviceWorkerCache.test.js @@ -78,7 +78,7 @@ function createHarness() { describe('Service worker e isolamento entre sessoes', () => { it('instala apenas o shell local e assume clientes depois de limpar caches antigos', async () => { const harness = createHarness(); - harness.caches.keys.mockResolvedValue(['comercio-bes-v10', 'comercio-bes-v11']); + harness.caches.keys.mockResolvedValue(['comercio-bes-v11', 'comercio-bes-v12']); await harness.runLifecycle('install'); const offlineAssets = harness.currentCache.addAll.mock.calls[0][0]; @@ -90,7 +90,7 @@ describe('Service worker e isolamento entre sessoes', () => { await harness.runLifecycle('activate'); expect(harness.caches.delete).toHaveBeenCalledTimes(1); - expect(harness.caches.delete).toHaveBeenCalledWith('comercio-bes-v10'); + expect(harness.caches.delete).toHaveBeenCalledWith('comercio-bes-v11'); expect(harness.self.clients.claim).toHaveBeenCalledTimes(1); }); diff --git a/backend/src/server.js b/backend/src/server.js index 6ed2d97..8887365 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -67,7 +67,7 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], + scriptSrc: ["'self'", "https://unpkg.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], fontSrc: ["'self'", "https://fonts.gstatic.com"], imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://res.cloudinary.com", "https://http2.mlstatic.com", "blob:"], diff --git a/css/style.css b/css/style.css index d409d94..84b25d7 100644 --- a/css/style.css +++ b/css/style.css @@ -1591,8 +1591,19 @@ align-items: center; justify-content: center; font-size: 80px; + overflow: hidden; } + .modal-hero-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .modal-hero-image[hidden] { display: none; } + .modal-img.has-photo::after { content: ''; position: absolute; diff --git a/html/cadastro.html b/html/cadastro.html index 19788b6..ea98e3b 100644 --- a/html/cadastro.html +++ b/html/cadastro.html @@ -4,6 +4,7 @@ + Cadastro — Comércio BES @@ -495,12 +496,12 @@

Criar conta

Escolha o tipo de conta que deseja criar

-
+
👤 Cliente Explore e favorite comércios
-
+
🏪 Lojista Cadastre sua loja @@ -511,26 +512,26 @@

Criar conta

- + Preencha seu nome completo.
- + Preencha um e-mail válido.
- + Preencha seu telefone.
- + Senha deve ter ao menos 6 caracteres.
- +
@@ -539,23 +540,23 @@

Criar conta

- + Preencha seu nome completo.
- + Preencha um e-mail válido.
- + Preencha seu telefone.
- + Senha deve ter ao menos 6 caracteres.
@@ -563,153 +564,31 @@

Criar conta

- + Preencha o nome da sua loja.
- + Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.
- + Preencha o WhatsApp da loja.
- +
- +
- + diff --git a/html/login.html b/html/login.html index 2599ad4..ae56e70 100644 --- a/html/login.html +++ b/html/login.html @@ -4,6 +4,7 @@ + Login — Comércio BES @@ -415,7 +416,7 @@

Bem-vindo de volta

- + Preencha um e-mail válido.
@@ -432,112 +433,7 @@

Bem-vindo de volta

Não tem uma conta? Cadastre aqui!
- + diff --git a/index.html b/index.html index a98261f..4928048 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + Comércio BES — Boa Esperança do Sul @@ -337,22 +338,22 @@ @@ -370,9 +371,8 @@

Encontre tudo em
Boa Esperança do Sul

Restaurantes, farmácias, mecânicas, pet shops e muito mais

- + Pizza - + Farmácia - + Mecânico - + Pet Shop - + Cabelo - + Roupa - + Supermercado - + Gás
@@ -424,40 +424,40 @@

Categorias

-
+
Todos
-
+
Restaurantes
-
+
Farmácias
-
+
Pet Shops
-
+
Mecânicas
-
+
Moda
-
+
Mercados
-
+
Barbearias
-
+
Beleza
-
+
Padarias
-
+
Material
-
+
Gás
@@ -482,7 +482,7 @@

Todos os Comércios

- @@ -499,7 +499,7 @@

Meus Favoritos

- +

- 💡 Dica: Para adicionar sua loja no mapa, cadastre-se gratuitamente

@@ -638,7 +638,7 @@

Navegação

-