Skip to content

Cloudflare Worker — Código Completo de Produção

Última atualização: 2026-04-16 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_portal', '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_portal|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. Preparar Headers para Supabase ---
    const forwardHeaders = new Headers();

    // --- 5. Construir URL de destino ---
    const targetUrl = new URL(url.pathname + url.search, supabaseUrl);

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

Diferenças entre Produção e Staging

O código é idêntico entre os dois ambientes. As diferenças estão apenas nas variáveis de ambiente (env vars) configuradas no Cloudflare Dashboard:

VariávelProdução (olp-gateway)Staging (olp-gateway-staging)
SUPABASE_URLhttps://mjvuzsizjlcalyfmbquy.supabase.cohttps://nrgcajnhpjmwilfcmqyb.supabase.co
SUPABASE_ANON_KEYAnon key de produçãoAnon key de staging

Nota: O PRODUCTION_DOMAIN (.olp.digital) e COOKIE_DOMAIN são hardcoded no Worker. Para staging, o Worker de staging em gateway-staging.olp.digital usa o mesmo código — os cookies são reescritos para .olp.digital que cobre tanto olp.digital quanto staging.olp.digital.


Seções do Handler

SeçãoDescrição
1. CORS PreflightResponde OPTIONS com headers CORS
2. Validar OriginBloqueia origins não permitidos
3. Validar configuraçãoVerifica SUPABASE_URL
4. Preparar HeadersInicializa forwardHeaders
5. Construir URLMonta targetUrl + headers de rastreamento/geo
5.5. Burst Rate LimiterLimita portal-escola a 150 req/5s por IP
6–7. Body + TimeoutPrepara body e executa com timeout 25s
8–10. RespostaReescreve cookies, adiciona CORS e segurança

Histórico

DataMudança
2026-04-16Removido redirect /pagar/{token} — pipeline de pagamento via link externo descontinuado. Pagamento agora é exclusivo pelo painel.
2026-04-08Adicionado redirect /pagar/{token} (seção 4.5). Cookie olp_portal adicionado à lista de reescrita. Código sincronizado entre produção e staging.
2026-03-08v2: Burst rate limiter, X-OLP-Client-IP, headers de geo/ASN.