CORS em Zig: Preflight, Headers e Segurança para APIs HTTP

Para configurar CORS em uma API Zig, trate o header Origin como entrada não confiável, compare-o com uma lista explícita de origens permitidas e devolva os headers CORS somente quando a política autorizar. Para requisições de preflight, responda a OPTIONS antes do handler principal e declare apenas os métodos e headers que a rota realmente aceita.

A regra mais importante é simples: não use Access-Control-Allow-Origin: * por conveniência em uma API autenticada. Se o frontend envia cookies ou outra credencial gerenciada pelo navegador, a resposta precisa indicar uma origem específica e pode usar Access-Control-Allow-Credentials: true. CORS não substitui autenticação, autorização, CSRF, validação de entrada nem rate limiting.

Como a API de std.http.Server ainda pode mudar antes do Zig 1.0, os exemplos abaixo concentram a lógica em funções pequenas e adaptáveis. Confira zig version e a documentação da versão instalada para ajustar a escrita dos headers e da resposta; a política HTTP permanece a mesma.

Resposta rápida: política recomendada

CenárioAllow-OriginCredenciaisObservação
API pública sem cookies* pode ser aceitávelnãoainda exige limites e autenticação quando aplicável
SPA própria em produçãoorigem exata, como https://app.exemplo.com.brconforme o loginmantenha allowlist explícita
frontend local de desenvolvimentohttp://localhost:3000 ou porta usadaconforme o loginnão leve a liberação ampla para produção
vários domínios controladosrefletir somente origens presentes na allowlistpossívelenvie Vary: Origin
origem desconhecidanão emitir headers CORSnãonão reflita o valor recebido

Se você está começando o backend, leia primeiro o guia de API REST em Zig e o tutorial de servidor HTTP com std.http.Server. CORS deve entrar como uma política de borda pequena, não como lógica espalhada por cada handler.

O que CORS realmente faz

CORS (Cross-Origin Resource Sharing) é um protocolo entre o navegador e o servidor. Ele decide se JavaScript executado em uma origem pode ler a resposta de outra origem.

Uma origem é a combinação de:

esquema + host + porta

Portanto, estas origens são diferentes:

https://app.exemplo.com.br
http://app.exemplo.com.br
https://api.exemplo.com.br
https://app.exemplo.com.br:8443

O navegador pode até enviar a requisição em alguns casos, mas bloqueia o acesso da página à resposta quando os headers CORS não autorizam a leitura. Isso explica um erro comum: a API parece funcionar no curl e falhar no frontend. O curl não aplica a política do navegador.

CORS também não impede alguém de chamar sua API diretamente. Um bot pode copiar os mesmos headers, e um serviço backend pode ignorar completamente a política. A proteção real continua sendo composta por autenticação, autorização por recurso, validação, limites de body, rate limiting e logs. Para essa camada operacional, veja rate limiting com token bucket e configuração segura de segredos.

Requisição simples e preflight

O navegador não faz preflight para toda chamada. Algumas requisições consideradas “simples” podem ser enviadas diretamente, normalmente com métodos como GET, HEAD ou certos POST, desde que usem apenas headers e content types limitados pela especificação.

Quando o frontend tenta enviar algo como:

PUT /usuarios/42 HTTP/1.1
Origin: https://app.exemplo.com.br
Authorization: Bearer ...
Content-Type: application/json

o navegador tende a consultar a permissão primeiro:

OPTIONS /usuarios/42 HTTP/1.1
Origin: https://app.exemplo.com.br
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

O servidor deve responder sem executar a atualização:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.exemplo.com.br
Access-Control-Allow-Methods: GET, PUT, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Vary: Origin

Somente depois dessa aprovação o navegador envia o PUT real. Se a rota OPTIONS cair no handler de autenticação, tentar ler JSON ou retornar 404, o frontend verá um erro de CORS antes que a operação principal aconteça.

Modele a política separadamente

Uma estrutura pequena evita condicionais duplicadas:

const CorsPolicy = struct {
    allowed_origins: []const []const u8,
    allowed_methods: []const u8 = "GET, POST, PUT, PATCH, DELETE, OPTIONS",
    allowed_headers: []const u8 = "Authorization, Content-Type, Idempotency-Key",
    exposed_headers: []const u8 = "ETag, X-Request-Id",
    allow_credentials: bool = true,
    max_age_seconds: u32 = 600,
};

A lista deve vir da configuração validada no startup, não de uma substring improvisada. Em produção, algo como CORS_ALLOWED_ORIGINS=https://app.exemplo.com.br,https://admin.exemplo.com.br pode ser convertido em uma lista e validado antes de o servidor aceitar tráfego.

Evite regras como:

origin termina com "exemplo.com.br"

A origem https://naoexemplo.com.br também termina com esse texto. Mesmo uma checagem de sufixo com ponto precisa considerar normalização, portas e domínios internacionais. Para a maioria das APIs, igualdade exata é mais fácil de revisar.

Validação explícita da origem em Zig

A função central pode ser independente do servidor HTTP:

const std = @import("std");

fn allowedOrigin(policy: CorsPolicy, origin: []const u8) ?[]const u8 {
    for (policy.allowed_origins) |allowed| {
        if (std.mem.eql(u8, origin, allowed)) return allowed;
    }
    return null;
}

test "aceita somente origem cadastrada" {
    const origins = [_][]const u8{
        "https://app.exemplo.com.br",
        "http://localhost:3000",
    };
    const policy = CorsPolicy{ .allowed_origins = &origins };

    try std.testing.expect(allowedOrigin(
        policy,
        "https://app.exemplo.com.br",
    ) != null);
    try std.testing.expect(allowedOrigin(
        policy,
        "https://app.exemplo.com.br.atacante.test",
    ) == null);
}

Retornar o valor da allowlist, em vez de refletir diretamente o header recebido, reduz a chance de uma validação frouxa virar vulnerabilidade. Antes de comparar, imponha um limite pequeno ao tamanho de Origin; headers gigantes devem ser rejeitados na camada HTTP geral.

Aplicando headers à resposta

O adaptador concreto depende da versão de std.http.Server, mas a decisão pode produzir uma lista de headers que o handler adiciona à resposta:

const CorsHeaders = struct {
    allow_origin: []const u8,
    allow_methods: []const u8,
    allow_headers: []const u8,
    expose_headers: []const u8,
    allow_credentials: bool,
    max_age_seconds: u32,
};

fn evaluateCors(policy: CorsPolicy, origin: ?[]const u8) ?CorsHeaders {
    const received = origin orelse return null;
    const allowed = allowedOrigin(policy, received) orelse return null;

    return .{
        .allow_origin = allowed,
        .allow_methods = policy.allowed_methods,
        .allow_headers = policy.allowed_headers,
        .expose_headers = policy.exposed_headers,
        .allow_credentials = policy.allow_credentials,
        .max_age_seconds = policy.max_age_seconds,
    };
}

Na resposta real, traduza esse resultado para:

Access-Control-Allow-Origin: <origem aprovada>
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: ETag, X-Request-Id
Vary: Origin

Access-Control-Expose-Headers é necessário quando o JavaScript precisa ler headers que não fazem parte da lista exposta automaticamente pelo navegador. É comum liberar ETag, Location, X-Request-Id ou um header de paginação; não exponha informação interna sem necessidade.

Como tratar OPTIONS antes do roteamento

O fluxo recomendado é:

1. validar tamanho e sintaxe básica dos headers
2. ler Origin
3. identificar OPTIONS com Access-Control-Request-Method
4. avaliar a política CORS
5. responder 204 ou rejeitar
6. para a chamada real, executar autenticação e handler
7. adicionar headers CORS à resposta final quando a origem for permitida

Um esqueleto conceitual:

fn handleRequest(req: *Request, policy: CorsPolicy) !void {
    const origin = req.header("origin");
    const cors = evaluateCors(policy, origin);

    if (req.method == .OPTIONS and
        req.header("access-control-request-method") != null)
    {
        const approved = cors orelse {
            return respondNoContent(req, .forbidden);
        };

        if (!requestedMethodAllowed(req, policy) or
            !requestedHeadersAllowed(req, policy))
        {
            return respondNoContent(req, .forbidden);
        }

        return respondPreflight(req, approved);
    }

    // Autenticação, autorização, roteamento e handler normal.
    const response = try route(req);
    if (cors) |headers| addCorsHeaders(response, headers);
    try response.send();
}

Os nomes Request, header, respondPreflight e send são deliberadamente adaptadores. O ponto importante é a ordem: o preflight não deve executar efeito colateral, e a resposta real também precisa incluir Access-Control-Allow-Origin. Aprovar somente OPTIONS não basta.

Valide método e headers solicitados

Não devolva uma lista ampla sem olhar o pedido. O preflight informa o método desejado em Access-Control-Request-Method e os headers em Access-Control-Request-Headers.

A validação deve:

  • comparar método sem aceitar valores inventados;
  • normalizar nomes de headers de forma case-insensitive;
  • dividir a lista por vírgula;
  • remover espaços externos;
  • rejeitar header fora da allowlist;
  • impor limite de quantidade e tamanho;
  • não confundir header vazio com autorização irrestrita.

Se a API só aceita GET e POST, não anuncie DELETE. Se não usa X-Admin-Override, não o inclua em Access-Control-Allow-Headers. A resposta CORS também funciona como documentação de superfície da API.

Credenciais, cookies e CSRF

Access-Control-Allow-Credentials: true permite que o navegador disponibilize ao frontend uma resposta associada a credenciais, desde que a chamada do JavaScript também opte por enviá-las. Isso costuma aparecer com cookies de sessão:

fetch("https://api.exemplo.com.br/perfil", {
  credentials: "include"
});

Nesse modo:

  • Access-Control-Allow-Origin não pode ser *;
  • a origem deve ser exata;
  • o cookie deve ter atributos adequados, como Secure, HttpOnly e política SameSite coerente;
  • operações mutáveis ainda precisam de defesa contra CSRF quando a autenticação é automática por cookie;
  • Origin e, quando apropriado, Referer podem participar da defesa, mas não substituem token CSRF em todos os desenhos.

Bearer tokens enviados explicitamente em Authorization não tornam a API automaticamente segura. Um XSS no frontend pode acessar o token, e uma política CORS permissiva pode expor respostas a origens indevidas.

Por que Vary: Origin é obrigatório no caso dinâmico

Quando o servidor devolve uma origem diferente conforme a requisição, caches precisam saber que a resposta varia por Origin:

Vary: Origin

Sem isso, um CDN ou proxy pode armazenar uma resposta liberada para https://app.exemplo.com.br e entregá-la a outra origem. Se a resposta já possui Vary: Accept-Encoding, não substitua o valor; combine os campos corretamente.

O mesmo cuidado vale para cache de preflight. Access-Control-Max-Age: 600 reduz chamadas OPTIONS, mas também prolonga uma política antiga no navegador. Comece com alguns minutos, valide o comportamento e evite valores enormes durante migrações de domínio.

CORS no Nginx ou no serviço Zig?

As duas opções funcionam, mas escolha um único dono da política.

No serviço Zig, a política pode variar por rota e fica perto da autenticação. É mais fácil testar como código e evitar liberar métodos inexistentes.

No Nginx, Caddy ou gateway, a organização consegue padronizar vários serviços. Porém, configurações com condicionais e headers duplicados ficam difíceis de revisar. Um proxy que adiciona Allow-Origin: * por cima de uma origem específica pode gerar resposta inválida.

Para uma API pequena, manter a decisão no Zig e deixar o proxy apenas repassar Origin e OPTIONS costuma ser previsível. Em plataformas maiores, um gateway central pode ser adequado desde que haja testes de contrato. O guia de Zig por trás de Nginx cobre a fronteira entre proxy e aplicação.

Como testar com curl

Teste uma requisição real autorizada:

curl -i https://api.exemplo.com.br/perfil \
  -H 'Origin: https://app.exemplo.com.br'

Teste o preflight:

curl -i -X OPTIONS https://api.exemplo.com.br/usuarios/42 \
  -H 'Origin: https://app.exemplo.com.br' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization, content-type'

Teste uma origem atacante:

curl -i -X OPTIONS https://api.exemplo.com.br/usuarios/42 \
  -H 'Origin: https://app.exemplo.com.br.atacante.test' \
  -H 'Access-Control-Request-Method: PUT'

A última resposta não deve refletir a origem. No teste automatizado, cubra ainda:

  1. origem permitida em GET;
  2. origem ausente, como chamada server-to-server;
  3. origem desconhecida;
  4. preflight com método permitido;
  5. preflight com método proibido;
  6. header permitido com capitalização diferente;
  7. header desconhecido;
  8. credenciais sem wildcard;
  9. presença de Vary: Origin;
  10. resposta de erro 401, 403, 404 e 500 com CORS coerente.

Esse último item é importante: se apenas respostas 200 recebem CORS, o navegador esconde o body do erro e o frontend mostra “falha de CORS” no lugar da mensagem útil da API.

Erros comuns de configuração

Refletir qualquer Origin

Isto é praticamente equivalente a liberar todo mundo:

Access-Control-Allow-Origin: <valor recebido>

Só reflita após igualdade com uma allowlist confiável.

Usar wildcard com cookies

A combinação abaixo é inválida para credenciais:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Escolha uma origem específica ou remova credenciais.

Liberar somente no ambiente local

localhost:3000, localhost:5173 e 127.0.0.1:5173 são origens diferentes. Cadastre apenas as usadas pela equipe e mantenha a configuração de desenvolvimento separada da produção.

Exigir autenticação no preflight

O navegador não envia necessariamente as credenciais da operação final no OPTIONS. Avalie origem, método e headers no preflight; autentique a requisição real.

Tratar CORS como controle de acesso

Uma origem aprovada não prova quem é o usuário. O handler ainda precisa verificar se aquele usuário pode ler ou alterar o recurso solicitado.

Observabilidade sem vazar dados

Registre decisões CORS com baixa cardinalidade:

cors_preflight result=allowed route=/usuarios/:id method=PUT
cors_preflight result=denied reason=origin_not_allowed
cors_preflight result=denied reason=header_not_allowed

Evite colocar a origem completa como label livre de Prometheus, pois hosts aleatórios geram cardinalidade sem limite. Uma métrica útil pode separar apenas allowed, denied_origin, denied_method e denied_header. Para investigar, registre a origem normalizada em log estruturado com retenção e controles apropriados.

Checklist de produção

  • Origens de produção usam igualdade exata com allowlist.
  • Configuração de localhost não é carregada em produção.
  • Preflight OPTIONS é tratado antes do handler e sem efeito colateral.
  • Métodos anunciados correspondem às rotas reais.
  • Headers solicitados são comparados de forma case-insensitive.
  • Access-Control-Allow-Origin aparece também em respostas de erro aplicáveis.
  • Credenciais nunca são combinadas com origem *.
  • Cookies têm Secure, HttpOnly e SameSite revisados.
  • Operações autenticadas por cookie têm defesa contra CSRF.
  • Respostas dinâmicas incluem Vary: Origin.
  • Access-Control-Max-Age tem duração moderada e revisável.
  • Proxy e aplicação não adicionam headers CORS conflitantes.
  • Testes cobrem origem atacante parecida com o domínio legítimo.
  • CORS não é tratado como substituto de autenticação ou autorização.

Perguntas frequentes

Como liberar CORS em uma API Zig?

Valide Origin contra uma lista explícita, devolva Access-Control-Allow-Origin apenas para origens autorizadas e responda ao preflight OPTIONS com os métodos e headers realmente aceitos. Não basta adicionar um asterisco a todas as respostas.

Posso usar Access-Control-Allow-Origin com asterisco e credenciais?

Não. Quando cookies ou credenciais do navegador estão habilitados, Access-Control-Allow-Origin precisa conter uma origem específica. O valor * não pode ser combinado com Access-Control-Allow-Credentials: true.

Por que o navegador envia OPTIONS antes do POST ou PUT?

Essa requisição é o preflight. O navegador verifica se a origem, o método e os headers pretendidos são aceitos antes de enviar uma operação que não se enquadra como requisição simples.

CORS protege a API contra curl, bots ou chamadas entre servidores?

Não. CORS é aplicado pelo navegador. curl, scripts e backends continuam capazes de chamar o endpoint, portanto autenticação, autorização, rate limiting e validação permanecem obrigatórios.

Como testar CORS sem depender de um frontend?

Use curl enviando Origin e, no preflight, Access-Control-Request-Method e Access-Control-Request-Headers. Teste origens permitidas e rejeitadas, verifique Vary: Origin e confirme que respostas de erro também seguem a política.

Conclusão

Uma implementação segura de CORS em Zig não precisa virar um framework. Ela pode ser composta por três partes pequenas: uma allowlist validada no startup, uma função pura que decide se a origem é aceita e um adaptador HTTP que trata preflight e adiciona headers à resposta final.

O principal é manter o contrato explícito. Origem não é identidade, preflight não executa a operação, wildcard não combina com credenciais e CORS não bloqueia clientes fora do navegador. Com essas fronteiras claras, o frontend consegue conversar com a API sem transformar uma conveniência de desenvolvimento em uma liberação acidental de produção.

Para completar a camada HTTP, conecte esta política ao guia de autenticação JWT em Zig, aos limites e health checks do servidor em produção e ao checklist de TLS e certificados.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.