Voltar para o Blog

Release de segurança do Next.js: proteja o app com ClickHouse

O Next.js publica hoje correções para sete vulnerabilidades. Veja como detectar versões afetadas automaticamente no CI e limitar o estrago no acesso ao ClickHouse.

Release de segurança do Next.js: proteja o app com ClickHouse

Nesta terça-feira, 3 de novembro de 2026, o time do Next.js publica uma release de segurança programada. O aviso saiu no blog oficial em 27 de outubro e diz que a atualização vai corrigir sete vulnerabilidades de severidade média, com os alertas completos publicados junto com os pacotes. Na véspera, a versão 16.4.4 chegou ao npm com uma nota lembrando que a release de segurança sairia no dia seguinte.

Avisos assim costumam gerar o mesmo ritual em muitas equipes: alguém lê a notícia, abre o package.json, troca a versão na mão, roda o build e torce para nada quebrar. O problema é que esse ritual depende de alguém lembrar, e em outubro já tivemos alertas críticos de execução remota de código no Next.js, corrigidos na 16.4.1. Quem ainda estava em uma versão anterior ficou exposto até alguém perceber.

Neste tutorial vamos montar um processo que não depende de memória para um caso bem comum: um dashboard em Next.js com App Router que lê dados de eventos do ClickHouse. Primeiro, um script em TypeScript que consulta a base pública de alertas do GitHub e falha quando a versão instalada do next está afetada. Depois, um job de CI que roda esse script em todo pull request e todo dia útil. Por fim, as camadas de defesa que limitam o estrago se uma falha do framework for explorada antes da atualização.

O problema: o framework é só uma das portas

Quando um alerta do Next.js fala em execução remota de código ou em vazamento de cache, o risco real não é o framework em si, mas o que o servidor Next.js consegue alcançar. Num dashboard analítico, isso normalmente é uma conexão com o ClickHouse feita com um usuário que, por comodidade, recebeu permissões de sobra. Se um atacante executar código no processo do Next.js, ele herda essas permissões e pode ler, apagar ou inserir dados à vontade.

O segundo problema é onde a autorização mora. Desde o Next.js 16, o antigo middleware foi renomeado para proxy, e a documentação oficial descreve as verificações feitas ali como otimistas: servem para redirecionar cedo, mas a verificação segura deve ficar perto do acesso aos dados, em uma camada de acesso a dados. Aplicações que protegem a rota de API só no proxy ficam vulneráveis a qualquer falha que permita contornar essa etapa, e o histórico do framework mostra que isso já aconteceu.

O terceiro problema é a consulta em si. Dashboards costumam montar SQL com filtros vindos da URL, como intervalo de datas, cliente e nome do evento. Quando esses valores são concatenados na string da consulta, uma falha de validação vira injeção de SQL. Mesmo sem injeção, uma consulta sem limite de tempo ou de linhas pode derrubar o cluster quando alguém pede um ano inteiro de dados brutos.

O que você terá no final

Ao terminar, você terá um projeto Next.js 16.4.4 com uma rota GET /api/events que exige um token no cabeçalho Authorization, valida os parâmetros com Zod, consulta o ClickHouse só com parâmetros tipados e devolve contagens diárias por evento. O acesso ao banco usa um usuário com readonly = 1, permissão SELECT em uma única tabela e um perfil que corta consultas longas ou com resultado grande demais.

Você também terá o script npm run security:next, que lê a versão do next instalada em node_modules, pergunta à API de alertas do GitHub quais alertas afetam exatamente aquela versão e termina com código de saída 1 quando encontra algo da severidade mínima configurada. Um workflow do GitHub Actions roda o script junto com npm audit, a checagem de tipos e os treze testes automatizados do projeto.

Pré-requisitos e versões

Você vai precisar de Node.js 24 LTS ou 20.9 ou superior, que é o mínimo exigido pelo Next.js 16.4.4, além de npm e de um servidor ClickHouse acessível por HTTP na porta 8123. Para testar localmente, o binário oficial do ClickHouse basta, sem Docker. As versões abaixo foram consultadas no npm e nas páginas oficiais em 3 de novembro de 2026.

ComponenteVersão usada
Next.js16.4.4
React e React DOM19.3.1
@clickhouse/client1.24.1
ClickHouse server26.10.3.7
TypeScript7.0.2
Zod4.6.7
semver7.8.6
Vitest5.1.0
tsx4.23.15
actions/checkout e actions/setup-nodev7

Uma observação importante sobre a data: este texto foi escrito na manhã de 3 de novembro, antes da publicação dos pacotes corrigidos. Por isso ele não cita os identificadores dos sete alertas novos nem a versão que os corrige. O script que vamos escrever existe justamente para isso: assim que os alertas forem publicados, ele passa a acusar a 16.4.4 sem que você precise mudar uma linha de código.

Arquitetura

O fluxo é curto. O navegador ou outro serviço interno chama GET /api/events com um token. O Route Handler compara o token em tempo constante, valida os parâmetros e monta uma consulta com placeholders. O cliente oficial do ClickHouse envia a consulta e os valores separadamente pela interface HTTP, e o servidor aplica o perfil do usuário antes de executar qualquer coisa.

flowchart LR
  C[Cliente com token] --> R[Route Handler /api/events]
  R -->|token inválido| E401[401]
  R -->|parâmetros inválidos| E400[400]
  R -->|query + query_params| CH[(ClickHouse
usuário dashboard_ro
readonly=1)]
  CI[GitHub Actions] --> S[security:next]
  S --> GH[API de alertas do GitHub]

Em paralelo, o pipeline de CI consulta a API pública de alertas do GitHub, filtrando pelo ecossistema npm e pelo pacote e versão exatos instalados. A escolha dessa API em vez de apenas npm audit tem um motivo prático: o script mostra a faixa afetada e a primeira versão corrigida de cada alerta, e permite escolher a severidade mínima que deve barrar o merge. O npm audit continua no pipeline como uma segunda opinião sobre todas as dependências.

Estrutura de diretórios

dashboard-next-clickhouse/
├── .env.example
├── .github/workflows/next-security.yml
├── app/
│   ├── api/events/route.ts
│   ├── layout.tsx
│   └── page.tsx
├── clickhouse/
│   ├── 001-schema.sql
│   └── 002-usuario-dashboard.sql
├── scripts/check-next-security.ts
├── src/
│   ├── security/advisories.ts
│   └── server/
│       ├── auth.ts
│       ├── clickhouse.ts
│       ├── env.ts
│       └── events.ts
├── tests/
│   ├── advisories.test.ts
│   ├── auth.test.ts
│   └── events.test.ts
├── package.json
├── tsconfig.json
└── vitest.config.ts

Preparando o ambiente

Comece pelo package.json. Todas as versões estão fixas, sem ^, porque o objetivo é saber exatamente qual versão do next está em produção. Atualizar passa a ser uma decisão explícita registrada no repositório, e não um efeito colateral de um npm install qualquer.

Arquivo: package.json

{
  "name": "dashboard-next-clickhouse",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "security:next": "tsx scripts/check-next-security.ts"
  },
  "dependencies": {
    "@clickhouse/client": "1.24.1",
    "next": "16.4.4",
    "react": "19.3.1",
    "react-dom": "19.3.1",
    "semver": "7.8.6",
    "server-only": "0.0.1",
    "zod": "4.6.7"
  },
  "devDependencies": {
    "@types/node": "24.19.2",
    "@types/react": "19.3.0",
    "@types/semver": "7.8.0",
    "tsx": "4.23.15",
    "typescript": "7.0.2",
    "vitest": "5.1.0"
  }
}

O tsconfig.json abaixo já inclui os ajustes que o próprio next build aplica na primeira execução, como allowJs e a pasta de tipos gerados em .next/dev/types. O alias @/* aponta para src, onde fica todo o código que só roda no servidor.

Arquivo: tsconfig.json

{
  "compilerOptions": {
    "target": "ES2023",
    "lib": [
      "dom",
      "dom.iterable",
      "ES2023"
    ],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true,
    "noEmit": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
    "allowJs": true
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

O arquivo de exemplo de variáveis de ambiente documenta tudo o que a aplicação precisa, sem nenhum valor real. Copie para .env.local, preencha com seus dados e nunca envie o arquivo preenchido para o repositório. O token do dashboard deve ser aleatório e longo; o comando sugerido no comentário gera 32 bytes em base64url.

Arquivo: .env.example

# ClickHouse: usuário somente leitura criado em clickhouse/002-usuario-dashboard.sql
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_DATABASE=analytics
CLICKHOUSE_USER=dashboard_ro
CLICKHOUSE_PASSWORD=troque-por-uma-senha-forte

# Token que o front-end interno (ou outro serviço) envia no header Authorization: Bearer
# Gere com: node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
DASHBOARD_API_TOKEN=gere-um-token-aleatorio-com-32-bytes-ou-mais

# Opcional: token do GitHub para não esbarrar no limite de 60 requisições/hora sem autenticação
GITHUB_TOKEN=

# Severidade mínima que faz o script de segurança falhar: low, medium, high ou critical
NEXT_SECURITY_MIN_SEVERITY=medium

Instale as dependências com npm install. Se você usar um npm recente, ele pode avisar que o esbuild tem um script de instalação ainda não aprovado; o projeto funciona do mesmo jeito, mas vale revisar o aviso com npm install-scripts ls antes de aprovar.

Passo 1: o usuário somente leitura no ClickHouse

A primeira camada de defesa fica no banco, porque ela continua valendo mesmo que todo o resto falhe. A tabela de eventos usa MergeTree ordenada por cliente, data e nome do evento, que é exatamente o padrão de filtro do dashboard. Isso não é só desempenho: consultas que respeitam a chave de ordenação leem menos dados e terminam bem antes do limite de tempo.

Arquivo: clickhouse/001-schema.sql

CREATE DATABASE IF NOT EXISTS analytics;

CREATE TABLE IF NOT EXISTS analytics.events
(
    tenant_id  UUID,
    event_date Date,
    event_time DateTime64(3),
    event_name LowCardinality(String),
    user_id    String,
    properties String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, event_name);

O usuário do dashboard recebe um perfil de configurações com readonly = 1, o que impede escrita e também impede que a própria conexão altere configurações. Em seguida vêm os limites: no máximo dez segundos de execução, no máximo dez mil linhas no resultado, erro em vez de truncamento silencioso quando o limite é ultrapassado e um teto de memória por consulta. A permissão vem de um papel que só tem SELECT na tabela analytics.events.

Arquivo: clickhouse/002-usuario-dashboard.sql

-- Rode com um usuário administrador. Troque a senha antes de executar.
CREATE SETTINGS PROFILE IF NOT EXISTS dashboard_profile SETTINGS
    readonly = 1,
    max_execution_time = 10,
    max_result_rows = 10000,
    result_overflow_mode = 'throw',
    max_memory_usage = 2000000000;

CREATE ROLE IF NOT EXISTS dashboard_reader;
GRANT SELECT ON analytics.events TO dashboard_reader;

CREATE USER IF NOT EXISTS dashboard_ro
    IDENTIFIED WITH sha256_password BY 'troque-por-uma-senha-forte'
    DEFAULT ROLE dashboard_reader
    SETTINGS PROFILE 'dashboard_profile';

Execute os dois arquivos com um usuário administrador, por exemplo com clickhouse client --multiquery < clickhouse/001-schema.sql e depois o segundo arquivo. Troque a senha antes de rodar e prefira guardá-la em um gerenciador de segredos. O valor de memória do perfil é um ponto de partida; ajuste-o ao tamanho das suas consultas reais.

Há um detalhe do cliente JavaScript que pega muita gente desprevenida. A documentação oficial do @clickhouse/client avisa que, com um usuário readonly = 1, não dá para ligar a compressão da resposta, porque isso exige alterar a configuração enable_http_compression. Pelo mesmo motivo, não envie clickhouse_settings nas consultas: os limites ficam no perfil do servidor, e qualquer tentativa de mudar uma configuração é recusada.

Passo 2: variáveis de ambiente e cliente do ClickHouse

As variáveis são validadas com Zod na primeira vez que o servidor precisa delas. O import de server-only faz o build falhar se algum componente de cliente tentar importar esse módulo, o que evita que a senha do banco acabe em um bundle enviado ao navegador.

Arquivo: src/server/env.ts

import "server-only";
import { z } from "zod";

const schema = z.object({
  CLICKHOUSE_URL: z.url(),
  CLICKHOUSE_DATABASE: z.string().min(1),
  CLICKHOUSE_USER: z.string().min(1),
  CLICKHOUSE_PASSWORD: z.string().min(1),
  DASHBOARD_API_TOKEN: z.string().min(32, "DASHBOARD_API_TOKEN precisa ter pelo menos 32 caracteres"),
});

export type ServerEnv = z.infer<typeof schema>;

let cached: ServerEnv | undefined;

export function serverEnv(): ServerEnv {
  cached ??= schema.parse(process.env);
  return cached;
}

O cliente é criado uma única vez por processo e reaproveitado entre requisições. A compressão está desligada nos dois sentidos, conforme a limitação do usuário somente leitura, e o nome da aplicação aparece nos logs de consultas do ClickHouse, o que ajuda a identificar de onde veio uma consulta suspeita.

Arquivo: src/server/clickhouse.ts

import "server-only";
import { createClient, type ClickHouseClient } from "@clickhouse/client";
import { serverEnv } from "./env";

let client: ClickHouseClient | undefined;

export function clickhouse(): ClickHouseClient {
  if (client) return client;
  const env = serverEnv();
  client = createClient({
    url: env.CLICKHOUSE_URL,
    database: env.CLICKHOUSE_DATABASE,
    username: env.CLICKHOUSE_USER,
    password: env.CLICKHOUSE_PASSWORD,
    application: "dashboard-next",
    request_timeout: 15_000,
    // Usuário readonly=1 não pode ligar enable_http_compression, então a compressão
    // da resposta fica desligada. Nenhum clickhouse_settings é enviado: os limites
    // moram no perfil do usuário, do lado do servidor.
    compression: { response: false, request: false },
  });
  return client;
}

Passo 3: autorização ao lado dos dados

A verificação do token fica em um módulo pequeno e testável. Comparar strings com === vaza, em teoria, informação pelo tempo de resposta; por isso usamos timingSafeEqual do módulo node:crypto. Como essa função exige buffers do mesmo tamanho, os dois lados passam antes por SHA-256, o que também evita revelar o tamanho do token esperado.

Arquivo: src/server/auth.ts

import { createHash, timingSafeEqual } from "node:crypto";

function digest(value: string): Buffer {
  return createHash("sha256").update(value, "utf8").digest();
}

// Compara o token recebido com o esperado em tempo constante.
// O hash deixa os dois buffers com o mesmo tamanho, exigência do timingSafeEqual.
export function isAuthorized(authorizationHeader: string | null, expectedToken: string): boolean {
  if (authorizationHeader === null) return false;
  const match = /^Bearer\s+(\S+)$/i.exec(authorizationHeader.trim());
  const received = match?.[1];
  if (received === undefined) return false;
  return timingSafeEqual(digest(received), digest(expectedToken));
}

Este token fixo serve bem para um dashboard interno chamado por outro serviço. Se a sua aplicação tem usuários com sessão, troque a função por uma verificação de sessão na sua camada de acesso a dados, mas mantenha o princípio: quem chama o ClickHouse confere a permissão no mesmo lugar, mesmo que o proxy.ts já tenha feito uma checagem antes.

Passo 4: validação e consulta parametrizada

O esquema Zod define exatamente o que a rota aceita: um UUID de cliente, duas datas ISO, um intervalo de no máximo 31 dias e, opcionalmente, um nome de evento com caracteres restritos. A restrição de intervalo é uma defesa de custo, e não só de segurança, porque impede que alguém peça um ano de dados por engano.

Arquivo: src/server/events.ts

import { z } from "zod";

const MAX_RANGE_DAYS = 31;

export const eventsQuerySchema = z
  .object({
    tenantId: z.uuid(),
    from: z.iso.date(),
    to: z.iso.date(),
    eventName: z
      .string()
      .regex(/^[a-z0-9_.:-]{1,64}$/, "eventName aceita letras minúsculas, números e _ . : -")
      .optional(),
  })
  .refine((value) => value.from <= value.to, { message: "from deve ser menor ou igual a to" })
  .refine(
    (value) => {
      const days = (Date.parse(value.to) - Date.parse(value.from)) / 86_400_000;
      return days <= MAX_RANGE_DAYS;
    },
    { message: `o intervalo máximo é de ${MAX_RANGE_DAYS} dias` },
  );

export type EventsQuery = z.infer<typeof eventsQuerySchema>;

export interface DailyCount {
  day: string;
  event_name: string;
  total: number;
}

export interface BuiltQuery {
  query: string;
  query_params: Record<string, string>;
}

// Nenhum valor vindo da requisição é concatenado no SQL: tudo passa por
// parâmetros tipados {nome:Tipo}, que o ClickHouse valida do lado do servidor.
export function buildDailyCountsQuery(input: EventsQuery): BuiltQuery {
  const filters = [
    "tenant_id = {tenantId:UUID}",
    "event_date BETWEEN {from:Date} AND {to:Date}",
  ];
  const params: Record<string, string> = {
    tenantId: input.tenantId,
    from: input.from,
    to: input.to,
  };

  if (input.eventName !== undefined) {
    filters.push("event_name = {eventName:String}");
    params.eventName = input.eventName;
  }

  const query = `
    SELECT
      toString(event_date) AS day,
      event_name,
      toUInt32(count()) AS total
    FROM events
    WHERE ${filters.join(" AND ")}
    GROUP BY event_date, event_name
    ORDER BY event_date, event_name
    LIMIT 5000
  `;

  return { query, query_params: params };
}

Repare que nenhum valor da requisição entra na string SQL. Os filtros usam a sintaxe {nome:Tipo} do ClickHouse, e os valores vão separados em query_params. O servidor converte cada valor para o tipo declarado e recusa a consulta se a conversão falhar, então um UUID malformado nunca chega a virar parte do comando. A conversão de count() para UInt32 garante que o total chegue como número no JSON.

Passo 5: o Route Handler

A rota junta as peças na ordem certa: primeiro a autorização, depois a validação e só então a consulta. Erros do banco são registrados no log do servidor, mas a resposta ao cliente é genérica, para não expor nomes de tabelas, colunas ou detalhes da infraestrutura. O cabeçalho Cache-Control: private, no-store impede que uma CDN guarde dados de um cliente e os entregue a outro.

Arquivo: app/api/events/route.ts

import { NextResponse, type NextRequest } from "next/server";
import { isAuthorized } from "@/server/auth";
import { clickhouse } from "@/server/clickhouse";
import { serverEnv } from "@/server/env";
import { buildDailyCountsQuery, eventsQuerySchema, type DailyCount } from "@/server/events";

export const dynamic = "force-dynamic";

export async function GET(request: NextRequest): Promise<NextResponse> {
  // A autorização acontece aqui, ao lado do acesso aos dados, e não só no proxy.ts.
  if (!isAuthorized(request.headers.get("authorization"), serverEnv().DASHBOARD_API_TOKEN)) {
    return NextResponse.json({ error: "não autorizado" }, { status: 401 });
  }

  const parsed = eventsQuerySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
  if (!parsed.success) {
    return NextResponse.json(
      { error: "parâmetros inválidos", issues: parsed.error.issues.map((issue) => issue.message) },
      { status: 400 },
    );
  }

  const { query, query_params } = buildDailyCountsQuery(parsed.data);

  try {
    const result = await clickhouse().query({ query, query_params, format: "JSONEachRow" });
    const rows = await result.json<DailyCount>();
    return NextResponse.json(
      { rows },
      { headers: { "Cache-Control": "private, no-store" } },
    );
  } catch (error) {
    console.error("falha ao consultar o ClickHouse", error);
    return NextResponse.json({ error: "falha ao consultar os dados" }, { status: 502 });
  }
}

Para o build funcionar, o projeto precisa de um layout e de uma página mínima. Eles não têm nada de especial, mas estão aqui para que o projeto seja reproduzível do começo ao fim.

Arquivo: app/layout.tsx

import type { ReactNode } from "react";

export const metadata = { title: "Dashboard de eventos" };

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="pt-BR">
      <body>{children}</body>
    </html>
  );
}

Arquivo: app/page.tsx

export default function Home() {
  return (
    <main>
      <h1>Dashboard de eventos</h1>
      <p>Os dados são servidos por GET /api/events, protegido por token.</p>
    </main>
  );
}

Passo 6: o verificador de alertas do Next.js

Agora a parte que responde diretamente à release de hoje. O módulo abaixo sabe interpretar a resposta da API de alertas do GitHub. Essa API devolve faixas afetadas no formato >= 16.0.0, <= 16.4.0, com vírgulas e espaços, enquanto o pacote semver espera >=16.0.0 <=16.4.0. A função toSemverRange faz essa conversão.

Arquivo: src/security/advisories.ts

import semver from "semver";

export type Severity = "low" | "medium" | "high" | "critical";

export interface AdvisoryVulnerability {
  package: { ecosystem: string; name: string };
  vulnerable_version_range: string | null;
  first_patched_version: string | null;
}

export interface Advisory {
  ghsa_id: string;
  cve_id: string | null;
  html_url: string;
  summary: string;
  severity: Severity | "unknown";
  published_at: string;
  withdrawn_at: string | null;
  vulnerabilities: AdvisoryVulnerability[] | null;
}

export interface Finding {
  ghsaId: string;
  cveId: string | null;
  severity: Severity;
  summary: string;
  url: string;
  vulnerableRange: string;
  firstPatched: string | null;
}

const SEVERITY_ORDER: Record<Severity, number> = {
  low: 1,
  medium: 2,
  high: 3,
  critical: 4,
};

export function parseSeverity(value: string | undefined): Severity {
  const normalized = (value ?? "medium").trim().toLowerCase();
  if (normalized in SEVERITY_ORDER) {
    return normalized as Severity;
  }
  throw new Error(`Severidade inválida: "${value}". Use low, medium, high ou critical.`);
}

// A API do GitHub devolve faixas como ">= 16.0.0, <= 16.4.0".
// O pacote semver entende a mesma faixa sem vírgulas: ">=16.0.0 <=16.4.0".
export function toSemverRange(githubRange: string): string {
  return githubRange
    .split(",")
    .map((part) => part.trim().replace(/^(>=|<=|>|<|=)\s+/, "$1"))
    .join(" ");
}

export function isVersionAffected(version: string, githubRange: string): boolean {
  const range = semver.validRange(toSemverRange(githubRange));
  if (range === null) {
    // Faixa que não conseguimos interpretar: tratamos como afetada para não esconder risco.
    return true;
  }
  return semver.satisfies(version, range, { includePrerelease: true });
}

export function findingsForVersion(
  advisories: Advisory[],
  packageName: string,
  installedVersion: string,
  minSeverity: Severity,
): Finding[] {
  const findings: Finding[] = [];

  for (const advisory of advisories) {
    if (advisory.withdrawn_at !== null) continue;
    if (advisory.severity === "unknown") continue;
    if (SEVERITY_ORDER[advisory.severity] < SEVERITY_ORDER[minSeverity]) continue;

    for (const vulnerability of advisory.vulnerabilities ?? []) {
      if (vulnerability.package.name !== packageName) continue;
      if (vulnerability.vulnerable_version_range === null) continue;
      if (!isVersionAffected(installedVersion, vulnerability.vulnerable_version_range)) continue;

      findings.push({
        ghsaId: advisory.ghsa_id,
        cveId: advisory.cve_id,
        severity: advisory.severity,
        summary: advisory.summary,
        url: advisory.html_url,
        vulnerableRange: vulnerability.vulnerable_version_range,
        firstPatched: vulnerability.first_patched_version,
      });
    }
  }

  return findings.sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity]);
}

// Entre as versões corrigidas da mesma linha (mesmo major), escolhe a maior,
// que é a menor versão que fecha todos os alertas encontrados.
export function suggestedUpgrade(installedVersion: string, findings: Finding[]): string | null {
  const installedMajor = semver.major(installedVersion);
  const candidates = findings
    .map((finding) => finding.firstPatched)
    .filter((patched): patched is string => patched !== null && semver.valid(patched) !== null)
    .filter((patched) => semver.major(patched) === installedMajor);

  if (candidates.length === 0) return null;
  return candidates.sort(semver.rcompare)[0] ?? null;
}

Há uma decisão deliberada em isVersionAffected: se a faixa vier em um formato que o semver não entende, a versão é tratada como afetada. Isso aconteceu de verdade durante a preparação deste texto, quando a listagem de alertas do repositório do Next.js mostrava faixas como < 16.3.?. Falhar com um alarme falso é melhor do que deixar passar um alerta real em silêncio. A função suggestedUpgrade escolhe a maior versão corrigida da mesma linha principal, que é a menor versão capaz de fechar todos os alertas encontrados de uma vez.

O script de linha de comando lê a versão real em node_modules/next/package.json, e não a do package.json do projeto, porque é a versão instalada que vai para produção. Depois chama GET https://api.github.com/advisories com os filtros ecosystem=npm e affects=next@<versão>, que fazem o próprio GitHub devolver apenas os alertas que afetam aquela versão.

Arquivo: scripts/check-next-security.ts

import { readFile } from "node:fs/promises";
import path from "node:path";
import {
  type Advisory,
  findingsForVersion,
  parseSeverity,
  suggestedUpgrade,
} from "../src/security/advisories";

const PACKAGE_NAME = "next";

async function installedVersion(): Promise<string> {
  const file = path.join(process.cwd(), "node_modules", PACKAGE_NAME, "package.json");
  const raw = await readFile(file, "utf8");
  const parsed = JSON.parse(raw) as { version?: string };
  if (typeof parsed.version !== "string") {
    throw new Error(`Não encontrei a versão instalada em ${file}. Rode npm ci antes.`);
  }
  return parsed.version;
}

async function fetchAdvisories(version: string): Promise<Advisory[]> {
  const url = new URL("https://api.github.com/advisories");
  url.searchParams.set("ecosystem", "npm");
  url.searchParams.set("affects", `${PACKAGE_NAME}@${version}`);
  url.searchParams.set("per_page", "100");

  const headers: Record<string, string> = {
    Accept: "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
    "User-Agent": "dashboard-next-clickhouse-security-check",
  };
  const token = process.env.GITHUB_TOKEN?.trim();
  if (token) headers.Authorization = `Bearer ${token}`;

  const response = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
  if (!response.ok) {
    throw new Error(`GitHub Advisory API respondeu ${response.status} ${response.statusText}`);
  }
  return (await response.json()) as Advisory[];
}

async function main(): Promise<void> {
  const minSeverity = parseSeverity(process.env.NEXT_SECURITY_MIN_SEVERITY);
  const version = await installedVersion();
  const advisories = await fetchAdvisories(version);
  const findings = findingsForVersion(advisories, PACKAGE_NAME, version, minSeverity);

  console.log(`next instalado: ${version} | severidade mínima: ${minSeverity}`);

  if (findings.length === 0) {
    console.log("Nenhum alerta publicado afeta esta versão.");
    return;
  }

  for (const finding of findings) {
    const id = finding.cveId ? `${finding.ghsaId} / ${finding.cveId}` : finding.ghsaId;
    console.log(`- [${finding.severity}] ${id}: ${finding.summary}`);
    console.log(`  faixa afetada: ${finding.vulnerableRange} | corrigido em: ${finding.firstPatched ?? "sem correção"}`);
    console.log(`  ${finding.url}`);
  }

  const upgrade = suggestedUpgrade(version, findings);
  if (upgrade) {
    console.log(`Sugestão: npm install next@${upgrade} --save-exact (ou a versão mais nova da mesma linha).`);
  }
  process.exitCode = 1;
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 2;
});

Os códigos de saída foram pensados para o CI: zero quando está tudo certo, um quando há alerta acima da severidade mínima e dois quando o próprio script falhou, por exemplo por limite de requisições da API. Sem autenticação, a API pública do GitHub permite poucas requisições por hora a partir do mesmo IP; no GitHub Actions, o GITHUB_TOKEN do próprio workflow resolve isso.

Passo 7: o job de CI

O workflow roda em todo pull request, em todo dia útil às 11h UTC, que são 8h no horário de Brasília, e sob demanda. O agendamento diário é o que transforma o processo em algo que não depende de memória: no dia em que um alerta novo for publicado, o job daquela manhã falha mesmo que ninguém tenha mexido no código.

Arquivo: .github/workflows/next-security.yml

name: next-security

on:
  pull_request:
  schedule:
    - cron: "0 11 * * 1-5"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm audit --omit=dev --audit-level=high
      - run: npm run security:next
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NEXT_SECURITY_MIN_SEVERITY: medium
      - run: npm run typecheck
      - run: npm test

O workflow pede apenas permissão de leitura do conteúdo do repositório, o mínimo necessário para o checkout. O npm audit usa --omit=dev para focar no que vai para produção e só falha a partir de severidade alta, enquanto o nosso script é mais rigoroso com o next e falha já na severidade média, que é justamente a classificação dos sete alertas anunciados para hoje.

Testes automatizados

Os testes usam Vitest e não precisam de rede nem de banco. Eles cobrem a conversão de faixas, o filtro por severidade, o descarte de alertas retirados, a sugestão de atualização, a comparação de tokens, a validação dos parâmetros e a garantia de que nenhum valor do usuário aparece na string SQL.

Arquivo: vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["tests/**/*.test.ts"],
    environment: "node",
  },
});

Arquivo: tests/advisories.test.ts

import { describe, expect, it } from "vitest";
import {
  type Advisory,
  findingsForVersion,
  isVersionAffected,
  suggestedUpgrade,
  toSemverRange,
} from "../src/security/advisories";

function advisory(partial: Partial<Advisory> & Pick<Advisory, "ghsa_id" | "severity">): Advisory {
  return {
    cve_id: null,
    html_url: `https://github.com/advisories/${partial.ghsa_id}`,
    summary: "exemplo",
    published_at: "2026-10-14T00:00:00Z",
    withdrawn_at: null,
    vulnerabilities: [],
    ...partial,
  };
}

const sample: Advisory[] = [
  advisory({
    ghsa_id: "GHSA-aaaa-aaaa-aaaa",
    severity: "critical",
    vulnerabilities: [
      { package: { ecosystem: "npm", name: "next" }, vulnerable_version_range: ">= 16.0.0, <= 16.4.0", first_patched_version: "16.4.1" },
      { package: { ecosystem: "npm", name: "next" }, vulnerable_version_range: ">= 10.0.0, <= 15.5.27", first_patched_version: "15.5.28" },
    ],
  }),
  advisory({
    ghsa_id: "GHSA-bbbb-bbbb-bbbb",
    severity: "low",
    vulnerabilities: [
      { package: { ecosystem: "npm", name: "next" }, vulnerable_version_range: ">= 16.0.0, < 16.3.9", first_patched_version: "16.3.9" },
    ],
  }),
  advisory({
    ghsa_id: "GHSA-cccc-cccc-cccc",
    severity: "high",
    withdrawn_at: "2026-10-20T00:00:00Z",
    vulnerabilities: [
      { package: { ecosystem: "npm", name: "next" }, vulnerable_version_range: ">= 16.0.0", first_patched_version: null },
    ],
  }),
];

describe("toSemverRange", () => {
  it("remove vírgulas e espaços depois dos operadores", () => {
    expect(toSemverRange(">= 16.0.0, <= 16.4.0")).toBe(">=16.0.0 <=16.4.0");
  });
});

describe("isVersionAffected", () => {
  it("detecta versão dentro e fora da faixa", () => {
    expect(isVersionAffected("16.4.0", ">= 16.0.0, <= 16.4.0")).toBe(true);
    expect(isVersionAffected("16.4.1", ">= 16.0.0, <= 16.4.0")).toBe(false);
  });

  it("trata faixa ilegível como afetada", () => {
    expect(isVersionAffected("16.4.4", ">= 16.0.0, < 16.3.?")).toBe(true);
  });
});

describe("findingsForVersion", () => {
  it("respeita severidade mínima e ignora alertas retirados", () => {
    const findings = findingsForVersion(sample, "next", "16.3.2", "medium");
    expect(findings.map((finding) => finding.ghsaId)).toEqual(["GHSA-aaaa-aaaa-aaaa"]);
  });

  it("inclui severidade baixa quando pedido", () => {
    const findings = findingsForVersion(sample, "next", "16.3.2", "low");
    expect(findings.map((finding) => finding.ghsaId)).toEqual(["GHSA-aaaa-aaaa-aaaa", "GHSA-bbbb-bbbb-bbbb"]);
  });

  it("não acusa nada numa versão corrigida", () => {
    expect(findingsForVersion(sample, "next", "16.4.4", "low")).toEqual([]);
  });
});

describe("suggestedUpgrade", () => {
  it("sugere a maior versão corrigida da mesma linha", () => {
    const findings = findingsForVersion(sample, "next", "16.3.2", "low");
    expect(suggestedUpgrade("16.3.2", findings)).toBe("16.4.1");
  });
});

Arquivo: tests/auth.test.ts

import { describe, expect, it } from "vitest";
import { isAuthorized } from "../src/server/auth";

const token = "a".repeat(43);

describe("isAuthorized", () => {
  it("aceita o token correto", () => {
    expect(isAuthorized(`Bearer ${token}`, token)).toBe(true);
  });

  it("recusa ausência de header, esquema errado e token errado", () => {
    expect(isAuthorized(null, token)).toBe(false);
    expect(isAuthorized(`Basic ${token}`, token)).toBe(false);
    expect(isAuthorized("Bearer outro-token", token)).toBe(false);
  });
});

Arquivo: tests/events.test.ts

import { describe, expect, it } from "vitest";
import { buildDailyCountsQuery, eventsQuerySchema } from "../src/server/events";

const tenantId = "3f2b8c1e-7a4d-4e8f-9b6a-1c2d3e4f5a6b";

describe("eventsQuerySchema", () => {
  it("aceita uma consulta válida", () => {
    const parsed = eventsQuerySchema.safeParse({ tenantId, from: "2026-10-01", to: "2026-10-31" });
    expect(parsed.success).toBe(true);
  });

  it("recusa intervalo maior que 31 dias e datas invertidas", () => {
    expect(eventsQuerySchema.safeParse({ tenantId, from: "2026-01-01", to: "2026-03-01" }).success).toBe(false);
    expect(eventsQuerySchema.safeParse({ tenantId, from: "2026-10-31", to: "2026-10-01" }).success).toBe(false);
  });

  it("recusa eventName com caracteres de SQL", () => {
    const parsed = eventsQuerySchema.safeParse({
      tenantId,
      from: "2026-10-01",
      to: "2026-10-02",
      eventName: "x' OR 1=1 --",
    });
    expect(parsed.success).toBe(false);
  });
});

describe("buildDailyCountsQuery", () => {
  it("usa parâmetros tipados e nunca concatena valores", () => {
    const built = buildDailyCountsQuery({ tenantId, from: "2026-10-01", to: "2026-10-02", eventName: "checkout" });
    expect(built.query).toContain("{tenantId:UUID}");
    expect(built.query).toContain("{eventName:String}");
    expect(built.query).not.toContain(tenantId);
    expect(built.query).not.toContain("checkout");
    expect(built.query_params).toEqual({ tenantId, from: "2026-10-01", to: "2026-10-02", eventName: "checkout" });
  });
});

Executando o projeto

Com o ClickHouse no ar e os dois scripts SQL aplicados, rode a sequência abaixo. O último comando chama a rota com um token de exemplo e um intervalo de três dias; troque os valores pelos do seu .env.local.

npm install
npm run typecheck
npm test
npm run security:next
npm run build
npm run start
curl -H "Authorization: Bearer $DASHBOARD_API_TOKEN" \
  "http://localhost:3000/api/events?tenantId=3f2b8c1e-7a4d-4e8f-9b6a-1c2d3e4f5a6b&from=2026-10-01&to=2026-10-03"

Na preparação deste artigo, tudo isso foi executado localmente com Node.js 20.19.2 e ClickHouse 26.10.3.7. A checagem de tipos passou sem erros, os treze testes passaram e o next build gerou a rota /api/events como dinâmica. Com dados de exemplo inseridos, a rota respondeu 401 sem token, 200 com as contagens diárias quando o token estava correto e 400 quando recebeu um UUID inválido e um intervalo de dois meses. O workflow do GitHub Actions não foi executado em um repositório real.

O verificador também foi testado contra a base real do GitHub. Com a 16.4.4 instalada, a resposta foi que nenhum alerta publicado afetava a versão, o que era o esperado antes da release desta terça. Simulando uma instalação da 16.3.2, o script listou doze alertas, entre eles os críticos de execução remota de código corrigidos na 16.4.1, terminou com código 1 e sugeriu npm install [email protected] --save-exact.

Do lado do banco, os limites do perfil também foram conferidos com o usuário dashboard_ro. Uma tentativa de INSERT foi recusada por falta de permissão, a leitura de system.users também, uma consulta que devolveria vinte mil linhas parou no limite de dez mil e uma tentativa de aumentar max_execution_time dentro da própria consulta foi bloqueada pelo modo somente leitura.

Quando a release sair: o roteiro de atualização

No dia em que os alertas forem publicados, o job agendado vai falhar e mostrar a versão corrigida. Atualize com npm install next@<versão corrigida> --save-exact, rode npm test e npm run build e suba o pull request; o próprio workflow confirma que o alerta sumiu. Leia o texto de cada alerta antes de fazer o deploy, porque às vezes ele traz uma mitigação temporária útil para quem não consegue atualizar no mesmo dia.

Se a sua aplicação ainda estiver na linha 15, verifique a política de suporte na documentação do Next.js e a versão de backport no npm antes de escolher o alvo. Ao escrever este texto, a tag backport do npm apontava para a 15.5.28, a versão que corrigiu os alertas de outubro nessa linha. Não presuma que a linha antiga vai receber todas as correções novas: confirme no alerta.

Erros comuns

O erro mais frequente com usuários somente leitura é ligar compression: { response: true } no cliente. A consulta falha porque o cliente tenta alterar enable_http_compression, o que o modo readonly = 1 proíbe. O mesmo acontece se você passar clickhouse_settings em qualquer consulta. A solução é manter a compressão desligada e configurar tudo no perfil do servidor.

Outro erro comum é o script de alertas terminar com código 2 e a mensagem de status 403 ou 429. Isso quase sempre é o limite de requisições da API sem autenticação. Localmente, defina GITHUB_TOKEN com um token sem nenhuma permissão extra; no CI, use o token do próprio workflow, como no exemplo.

Se o script disser que não encontrou a versão instalada, você provavelmente o rodou antes do npm ci ou em uma pasta diferente da raiz do projeto. Ele lê node_modules/next/package.json a partir do diretório atual de propósito, para refletir o que foi de fato instalado pelo lockfile.

Por fim, se o build reclamar de server-only, algum componente marcado com "use client" está importando um módulo de src/server. Isso é o comportamento desejado: mova a chamada para um Route Handler, uma Server Action ou um Server Component e passe ao cliente apenas os dados já prontos.

Segurança

O token fixo é adequado para comunicação entre serviços internos, mas precisa ser tratado como segredo: guarde-o em um gerenciador de segredos, troque-o periodicamente e nunca o exponha em variáveis com o prefixo NEXT_PUBLIC_, que vão para o navegador. Para usuários finais, prefira sessões com expiração e checagem de permissão por cliente, porque o token deste exemplo dá acesso a qualquer tenantId.

O perfil readonly = 1 não substitui permissões bem desenhadas. Se o seu ClickHouse guarda dados de vários clientes, considere políticas de linha com CREATE ROW POLICY para que o usuário do dashboard só enxergue as linhas permitidas, mesmo que o filtro por tenant_id seja esquecido em alguma consulta futura. Teste a política em homologação antes de aplicá-la em produção.

Desempenho e custos

A ordenação da tabela por tenant_id, event_date e event_name faz com que as consultas do dashboard leiam apenas os blocos relevantes. O limite de 31 dias na rota e os limites do perfil no servidor formam duas barreiras contra consultas caras. Em serviços gerenciados que cobram por recursos consumidos, essas barreiras também protegem a fatura.

O verificador de alertas faz uma única requisição HTTP por execução e roda em poucos segundos. No GitHub Actions, o maior custo do job é o npm ci; o cache do setup-node reduz esse tempo nas execuções seguintes. Rodar uma vez por dia útil e em cada pull request é suficiente para pegar alertas novos sem gastar minutos de CI à toa.

Limitações

O script só olha o pacote next. Outras dependências críticas, como react-dom ou bibliotecas de autenticação, ficam por conta do npm audit, que tem regras próprias de severidade. Se quiser o mesmo rigor para elas, generalize a constante PACKAGE_NAME para uma lista e repita a consulta para cada pacote.

A base de alertas do GitHub depende do momento em que cada alerta é publicado e revisado. Pode haver um intervalo entre o anúncio de uma correção e o alerta aparecer na API, e por isso vale continuar acompanhando o blog oficial do Next.js e as notas das releases no GitHub. O script reduz o esquecimento, mas não substitui a leitura dos avisos.

Conclusão

A release de segurança desta terça é uma boa oportunidade para tirar a atualização do Next.js da memória das pessoas e colocá-la no pipeline. Com um script de poucas dezenas de linhas, o próprio CI passa a avisar quando a versão instalada está afetada, mostra a correção e bloqueia o merge até alguém agir.

Ao mesmo tempo, as camadas no ClickHouse e na rota garantem que uma falha do framework não vire acesso irrestrito aos dados. Usuário somente leitura, permissão em uma única tabela, limites no servidor, consultas parametrizadas e autorização ao lado dos dados são medidas que valem para qualquer alerta futuro, não só para os de hoje.

Referências

Precisa implementar isso na sua empresa?

Quer aplicar essas práticas de engenharia, dados ou inteligência artificial no seu produto? Solicite um diagnóstico técnico sem compromisso.

Solicitar orçamento