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
11 changes: 8 additions & 3 deletions backend/minha-conta/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@ function renderStoreSwitcher() {
}

storeSwitch.classList.remove('hidden');
storeSelect.innerHTML = ctx.stores
.map(s => `<option value="${s.id}" ${s.id === ctx.activeStoreId ? 'selected' : ''}>${s.nome}</option>`)
.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);
Expand Down
14 changes: 12 additions & 2 deletions backend/minha-conta/js/sections/admin/ModeracaoLojasSection.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ async function carregarLojas(container, ctx) {
return;
}

tbody.innerHTML = lojas.map(l => `
tbody.innerHTML = lojas.map((l, index) => `
<tr>
<td>
<div class="nome-loja">
Expand All @@ -66,14 +66,24 @@ async function carregarLojas(container, ctx) {
<td><span class="badge-status ${l.aberto ? 'aberto' : 'fechado'}">${l.aberto ? 'Aberto' : 'Fechado'}</span></td>
<td>
<div class="acoes">
<button class="btn-link" onclick="window.open('/${esc(l.slug)}','_blank')">Ver Página</button>
<button class="btn-link btn-ver-pagina" data-index="${index}">Ver Página</button>
<button class="btn-link ${l.aberto ? 'danger' : ''} btn-suspender" data-id="${l.id}" data-aberto="${l.aberto}">${l.aberto ? 'Suspender' : 'Reativar'}</button>
</div>
</td>
</tr>
`).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;
Expand Down
4 changes: 3 additions & 1 deletion backend/minha-conta/js/sections/common/InicioSection.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function mount(container, ctx) {
<div class="inicio-greeting">
<span class="inicio-emoji">${config.emoji}</span>
<div>
<h2 class="inicio-titulo">Olá, ${user.nome.split(' ')[0]}!</h2>
<h2 class="inicio-titulo">Olá, <span id="inicio-primeiro-nome"></span>!</h2>
<p class="inicio-subtitulo">${config.titulo}</p>
</div>
</div>
Expand Down Expand Up @@ -173,6 +173,8 @@ export function mount(container, ctx) {
</style>
`;

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]');
Expand Down
16 changes: 12 additions & 4 deletions backend/minha-conta/js/sections/common/PerfilSection.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ export function mount(container, ctx) {

<div class="card perfil-card">
<div class="avatar-row">
<div class="avatar-circle" id="perfil-avatar-preview">${user.nome.charAt(0).toUpperCase()}</div>
<div class="avatar-circle" id="perfil-avatar-preview"></div>
<div>
<p class="avatar-name">${user.nome}</p>
<p class="avatar-role">${{ admin: 'Administrador', comerciante: 'Comerciante', cliente: 'Cliente' }[user.role] || user.role}</p>
<p class="avatar-name" id="perfil-avatar-name"></p>
<p class="avatar-role" id="perfil-avatar-role"></p>
</div>
</div>

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

<div class="card" style="margin-top:16px">
<p class="info-label">E-mail (não editável)</p>
<p class="info-value">${user.email}</p>
<p class="info-value" id="perfil-email"></p>
</div>
</section>

Expand Down Expand Up @@ -97,6 +97,14 @@ export function mount(container, ctx) {
</style>
`;

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);

Expand Down
13 changes: 9 additions & 4 deletions backend/minha-conta/js/sections/merchant/ProdutosSection.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,15 @@ function mostrarFormulario(container, ctx, produto) {
<div class="form-grid">
<div class="form-group span2">
<label>Nome do Produto *</label>
<input type="text" name="nome" required value="${produto ? (produto.nome_produto || produto.nome || '') : ''}" />
<input type="text" name="nome" required />
</div>
<div class="form-group span2">
<label>Descrição</label>
<textarea name="descricao" rows="2">${produto?.descricao || ''}</textarea>
<textarea name="descricao" rows="2"></textarea>
</div>
<div class="form-group">
<label>Preco (R$) *</label>
<input type="number" step="0.01" name="preco" required value="${produto?.preco || ''}" />
<input type="number" step="0.01" name="preco" required />
</div>
<div class="form-group">
<label>Disponível?</label>
Expand All @@ -218,12 +218,17 @@ function mostrarFormulario(container, ctx, produto) {
</div>
`;

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 = {
Expand Down
42 changes: 42 additions & 0 deletions backend/src/__tests__/accountPanelXss.test.js
Original file line number Diff line number Diff line change
@@ -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}</option>');
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'");
});
});
149 changes: 149 additions & 0 deletions backend/src/__tests__/frontendSecurity.test.js
Original file line number Diff line number Diff line change
@@ -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(/<script\b[^>]*>/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(
/<meta\s+http-equiv="Content-Security-Policy"\s+content="([^"]+)"\s*\/?>/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,<svg onload=alert(1)>')).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 = '\"><img src=x onerror=globalThis.__xss=1>';
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('&lt;img src=x onerror=globalThis.__xss=1&gt;');
expect(html).not.toContain('<img src=x onerror=');
expect(html).not.toContain('javascript:');
expect(html).not.toMatch(/<[^>]+\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=');
});
});
4 changes: 2 additions & 2 deletions backend/src/__tests__/serviceWorkerCache.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:"],
Expand Down
11 changes: 11 additions & 0 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading