Cloudflare Worker — Código Completo de Produção
Última atualização: 2026-03-08 Versão: v2 + Burst Rate Limiter + X-OLP-Client-IP Onde colar: Cloudflare Dashboard → Workers & Pages → olp-gateway → Edit Code
Código
javascript
/**
* OLP Gateway - Cloudflare Worker v2
*
* Correções aplicadas:
* - Set-Cookie multi-header via getSetCookie()
* - CORS não seta ACAO quando sem origin
* - Sem fallback 503 (MVP - simplicidade)
* - Headers de segurança HTTP (HSTS, X-Frame-Options, etc.)
* - Burst rate limiter para portal-escola (150 req/5s por IP)
*/
// ============= CONFIGURAÇÃO =============
const COOKIES_TO_REWRITE = ['olp_auth', 'olp_mural'];
const PRODUCTION_DOMAIN = '.olp.digital';
const FETCH_TIMEOUT = 25000;
const ALLOWED_ORIGINS = [
'http://localhost:5173',
'http://localhost:8080',
'https://olp.digital',
];
const ORIGIN_PATTERNS = [
/\.lovableproject\.com$/,
/\.lovable\.app$/,
/\.olp\.digital$/,
];
const PRESERVE_HEADERS = [
'authorization',
'apikey',
'content-type',
'cookie',
'accept',
'x-client-info',
'x-supabase-api-version',
'x-webhook-secret',
'x-webhook-signature',
];
// Headers de segurança HTTP injetados em TODAS as respostas
const SECURITY_HEADERS = {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()',
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'",
};
// ============= BURST RATE LIMITER =============
const BURST_LIMIT = { max: 150, windowMs: 5000 };
const burstTracker = new Map(); // IP -> timestamp[]
function checkBurst(ip) {
const now = Date.now();
const timestamps = (burstTracker.get(ip) || []).filter(t => now - t < BURST_LIMIT.windowMs);
timestamps.push(now);
burstTracker.set(ip, timestamps);
// Cleanup periódico
if (burstTracker.size > 10000) {
for (const [key, ts] of burstTracker) {
if (now - ts[ts.length - 1] > BURST_LIMIT.windowMs * 2) burstTracker.delete(key);
}
}
return timestamps.length <= BURST_LIMIT.max;
}
// ============= VALIDAÇÃO DE ORIGEM =============
function isOriginAllowed(origin) {
if (!origin) return false;
if (ALLOWED_ORIGINS.includes(origin)) return true;
return ORIGIN_PATTERNS.some(pattern => pattern.test(origin));
}
// ============= CORS HEADERS =============
/**
* CORREÇÃO: Não setar ACAO quando não tem origin
* Requests sem origin (curl, server-to-server) não precisam de CORS
*/
function getCorsHeaders(origin) {
// Se não tem origin, retorna headers mínimos (sem ACAO)
if (!origin) {
return {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, cookie, accept',
};
}
// Se tem origin mas não é permitido, não setar ACAO (browser vai bloquear)
if (!isOriginAllowed(origin)) {
return {};
}
// Origin válido: setar ACAO com o origin exato
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, cookie, accept, x-supabase-api-version',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
'Access-Control-Expose-Headers': 'x-supabase-api-version, set-cookie',
};
}
// ============= REESCRITA DE COOKIES (CORRIGIDO) =============
/**
* CORREÇÃO CRÍTICA: Usar getSetCookie() para pegar TODOS os Set-Cookie headers
*
* Em Cloudflare Workers, response.headers.get('set-cookie') pode retornar
* apenas o primeiro header. Usar getSetCookie() garante pegar todos.
*/
function rewriteAllCookies(response, origin) {
const newHeaders = new Headers();
// Copiar todos os headers EXCETO Set-Cookie (vamos tratar separadamente)
for (const [key, value] of response.headers.entries()) {
if (key.toLowerCase() !== 'set-cookie') {
newHeaders.set(key, value);
}
}
// Pegar TODOS os Set-Cookie headers
// getSetCookie() retorna array de strings, um para cada cookie
const cookies = response.headers.getSetCookie();
if (!cookies || cookies.length === 0) {
return newHeaders;
}
// Determinar se devemos reescrever baseado na origem
const shouldRewrite = shouldRewriteCookieDomain(origin);
// Processar cada cookie individualmente
for (const cookie of cookies) {
let processedCookie = cookie;
// Verificar se é um dos nossos cookies que precisa rewrite
const isOurCookie = COOKIES_TO_REWRITE.some(name => cookie.includes(`${name}=`));
// CORREÇÃO: Detectar cookie de REMOÇÃO (logout) - não reescrever estes
const isRemovalCookie = cookie.toLowerCase().includes('max-age=0');
if (isOurCookie && shouldRewrite && !isRemovalCookie) {
// Remover Domain existente (se houver)
processedCookie = processedCookie.replace(/;\s*Domain=[^;]*/gi, '');
// Adicionar Domain de produção
processedCookie = processedCookie.replace(
/(olp_auth|olp_mural)=([^;]+)/,
`$1=$2; Domain=${PRODUCTION_DOMAIN}`
);
console.log(`[GATEWAY] Cookie reescrito: ${processedCookie.substring(0, 50)}...`);
} else if (isRemovalCookie) {
console.log(`[GATEWAY] Cookie de logout detectado, passando sem reescrita`);
}
// Adicionar cookie (append, não set - para múltiplos)
newHeaders.append('set-cookie', processedCookie);
}
return newHeaders;
}
/**
* Determina se deve reescrever o Domain do cookie
*
* Regras:
* - localhost: NÃO reescrever (desenvolvimento)
* - *.lovable*: NÃO reescrever (preview)
* - *.olp.digital: SIM, reescrever para .olp.digital
* - Sem origin: SIM, assumir produção
*/
function shouldRewriteCookieDomain(origin) {
if (!origin) {
// Sem origin (curl, server) - assumir produção
return true;
}
// Localhost - não reescrever
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
console.log('[GATEWAY] Localhost detectado, cookies não modificados');
return false;
}
// Preview Lovable - não reescrever
if (origin.includes('.lovableproject.com') || origin.includes('.lovable.app')) {
console.log('[GATEWAY] Preview Lovable detectado, cookies não modificados');
return false;
}
// Produção ou olp.digital - reescrever
return true;
}
// ============= HANDLER PRINCIPAL =============
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const origin = request.headers.get('origin');
const method = request.method;
// --- 1. CORS Preflight ---
if (method === 'OPTIONS') {
const corsHeaders = getCorsHeaders(origin);
// Se origin inválido, retornar 403
if (origin && !isOriginAllowed(origin)) {
console.error(`[CORS] Preflight bloqueado: ${origin}`);
return new Response(null, { status: 403 });
}
return new Response(null, {
status: 204,
headers: {
...corsHeaders,
...SECURITY_HEADERS,
},
});
}
// --- 2. Validar Origin (apenas se presente) ---
if (origin && !isOriginAllowed(origin)) {
console.error(`[CORS] Origin bloqueado: ${origin}`);
return new Response(
JSON.stringify({
success: false,
error: 'Origin not allowed',
}),
{
status: 403,
headers: {
'Content-Type': 'application/json',
...SECURITY_HEADERS,
},
}
);
}
// --- 3. Validar configuração ---
const supabaseUrl = env.SUPABASE_URL;
if (!supabaseUrl) {
console.error('[GATEWAY] SUPABASE_URL não configurado!');
return new Response(
JSON.stringify({ success: false, error: 'Gateway misconfigured' }),
{
status: 500,
headers: {
'Content-Type': 'application/json',
...SECURITY_HEADERS,
},
}
);
}
// --- 4. Construir URL de destino ---
const targetUrl = new URL(url.pathname + url.search, supabaseUrl);
// --- 5. Preparar Headers para Supabase ---
const forwardHeaders = new Headers();
for (const headerName of PRESERVE_HEADERS) {
const value = request.headers.get(headerName);
if (value) {
forwardHeaders.set(headerName, value);
}
}
// Adicionar apikey se não presente
if (!forwardHeaders.has('apikey') && env.SUPABASE_ANON_KEY) {
forwardHeaders.set('apikey', env.SUPABASE_ANON_KEY);
}
// Host do Supabase
forwardHeaders.set('Host', new URL(supabaseUrl).host);
// Headers de rastreamento
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
forwardHeaders.set('X-Forwarded-For', clientIP);
forwardHeaders.set('X-OLP-Client-IP', clientIP); // Header customizado — Supabase não sobrescreve
forwardHeaders.set('X-Real-IP', clientIP); // Mantido para compatibilidade
forwardHeaders.set('X-Gateway', 'olp-gateway-v2');
// Headers de geolocalização do Cloudflare (request.cf)
const cfGeo = request.cf || {};
if (cfGeo.city) forwardHeaders.set('X-Geo-City', encodeURIComponent(cfGeo.city));
if (cfGeo.region) forwardHeaders.set('X-Geo-Region', encodeURIComponent(cfGeo.region));
if (cfGeo.country) forwardHeaders.set('X-Geo-Country', encodeURIComponent(cfGeo.country));
// Headers de metadados para enriquecimento de logs
const userAgent = request.headers.get('User-Agent');
if (userAgent) forwardHeaders.set('X-OLP-User-Agent', userAgent);
if (cfGeo.asOrganization) forwardHeaders.set('X-OLP-ASN', cfGeo.asOrganization);
// --- 5.5. Burst Rate Limiter (portal-escola apenas) ---
if (url.pathname.includes('/portal-escola')) {
if (!checkBurst(clientIP)) {
forwardHeaders.set('X-Burst-Blocked', 'true');
console.warn(`[BURST] IP ${clientIP} excedeu ${BURST_LIMIT.max} req/${BURST_LIMIT.windowMs}ms`);
}
}
// --- 6. Preparar Body ---
let body = null;
if (method !== 'GET' && method !== 'HEAD') {
body = request.body;
}
// --- 7. Executar Request com Timeout ---
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
console.log(`[GATEWAY] ${method} ${url.pathname} -> ${targetUrl.host}`);
const response = await fetch(targetUrl.toString(), {
method: method,
headers: forwardHeaders,
body: body,
signal: controller.signal,
redirect: 'manual',
});
clearTimeout(timeoutId);
// --- 8. Reescrever Cookies (TODOS) ---
const responseHeaders = rewriteAllCookies(response, origin);
// --- 9. Adicionar CORS ---
const corsHeaders = getCorsHeaders(origin);
for (const [key, value] of Object.entries(corsHeaders)) {
responseHeaders.set(key, value);
}
// --- 9.5. Adicionar Headers de Segurança ---
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
responseHeaders.set(key, value);
}
// --- 10. Retornar Response ---
console.log(`[GATEWAY] ${response.status} ${url.pathname}`);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
clearTimeout(timeoutId);
// Log do erro
const errorType = error.name === 'AbortError' ? 'TIMEOUT' : 'NETWORK_ERROR';
console.error(`[GATEWAY] ${errorType}: ${url.pathname}`, error.message);
// MVP: Retornar erro simples, sem fallback complexo
// O frontend vai mostrar erro e usuário pode retry
return new Response(
JSON.stringify({
success: false,
error: errorType === 'TIMEOUT'
? 'Gateway timeout - tente novamente'
: 'Erro de conexão - tente novamente',
}),
{
status: errorType === 'TIMEOUT' ? 504 : 502,
headers: {
...getCorsHeaders(origin),
...SECURITY_HEADERS,
'Content-Type': 'application/json',
},
}
);
}
},
};