Skip to content

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',
          },
        }
      );
    }
  },
};