Korzen
Bot Protection

Next.js

Bot Protection in un progetto Next.js con App Router: il tag nel root layout, la chiamata in una server action, l'IP del visitatore su Vercel e dietro un proxy.

Questa guida è per l'App Router, Next.js 15 e successivi. Aggiungi il tag al root layout e chiami Bot Protection dalla server action o dalla route handler che crea l'account.

Il tag

Nel root layout, così ce l'hanno tutte le pagine:

// app/layout.js
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
      </head>
      <body>{children}</body>
    </html>
  );
}

La chiamata

Il client è un solo file senza dipendenze: chiede a /v1/decide, rinuncia allo scadere del tuo timeout e lascia passare il visitatore a ogni errore. Scaricalo in lib/ e leggilo prima del commit:

curl -o lib/bot-protection.js https://korzen.dev/bot-protection/sdk/korzen.js
curl -o lib/bot-protection.d.ts https://korzen.dev/bot-protection/sdk/korzen.d.ts # types, for TypeScript

Poi una funzione per ogni azione protetta:

// lib/korzen.js
import { headers } from "next/headers";
import { createClient, fromHeaders, ipFrom } from "./bot-protection.js";

export const client = createClient({ apiKey: process.env.KORZEN_API_KEY, url: "https://korzen.dev", timeoutMs: 200 });

// decide asks Bot Protection about the request being handled. It returns the answer,
// or null on any error: the caller then goes on as on allow (fail open).
export async function decide(path) {
  // ipFrom.vercel on Vercel; see "The visitor's IP"
  return client.decide(fromHeaders(await headers(), { path, ip: ipFrom.vercel }));
}

Poi, nella server action che crea l'account:

// app/signup/actions.js
"use server";
import { decide } from "@/lib/korzen";

export async function signUp(prevState, formData) {
  const d = await decide("/signup");
  if (d?.action === "block") return { error: "We could not create your account." };
  if (d?.action === "challenge") {
    // your CAPTCHA, or an email or phone check, before going on
  }
  const user = await createAccount(formData); // your own code
  await saveRequestID(user.id, d?.request_id); // see "Keep the request_id"
  // ...
}

In un route handler, withKorzen lo avvolge: risponde 403 su block e passa la risposta all'handler dopo i suoi argomenti:

// app/api/signup/route.js
import { ipFrom, withKorzen } from "@/lib/bot-protection";
import { client } from "@/lib/korzen";

export const POST = withKorzen(
  async (request, ctx, d) => {
    if (d?.action === "challenge") {
      // your CAPTCHA, or an email or phone check, before going on
    }
    const user = await createAccount(await request.formData()); // your own code
    await saveRequestID(user.id, d?.request_id);
    return Response.json({ ok: true });
  },
  // onChallenge answers nothing, so the handler runs its own check; without it, a
  // challenged post is refused (403)
  { client, ip: ipFrom.vercel, onChallenge: () => undefined },
);

L'IP del visitatore

Su Vercel, x-forwarded-for contiene l'IP del visitatore: Vercel lo sovrascrive, quindi un visitatore non può scrivere il proprio. Altrove, leggi l'header impostato dal tuo edge: cf-connecting-ip dietro Cloudflare, x-real-ip se lo imposta il tuo nginx. Dietro un proxy che aggiunge voci a x-forwarded-for invece di sovrascriverlo, la prima voce è quella che ha inviato il visitatore: prendi l'ultima, quella aggiunta dal tuo proxy.

Applica l'azione

Leggi solo action. Su block, rispondi con lo stesso errore che l'azione mostra quando fallisce per qualsiasi altro motivo, così il bot non impara niente. Su challenge, usa la verifica che hai già (un CAPTCHA, un codice via email o telefono); se non ne hai, prosegui. L'account parte in monitor mode, dove action è sempre allow: per i visitatori non cambia niente finché non attivi il block mode. Oppure manda il visitatore alla nostra pagina di challenge, in challenge.url: vedi la pagina di challenge.

Senza onChallenge, il middleware non lascia passare nessuna challenge: il caricamento di una pagina (GET) va alla nostra pagina di challenge, e l'invio di un modulo o una chiamata API viene rifiutato con 403. Proteggi anche la pagina che mostra il modulo: lì una persona incontra la challenge, e il suo pass lascia passare l'invio. Con una verifica tua, passa onChallenge ed eseguila lì. Il middleware rimanda la persona all'indirizzo che ha chiesto, letto da X-Forwarded-Host e X-Forwarded-Proto quando il tuo proxy li imposta; dietro un proxy che inoltra Host: localhost e nessuno dei due (nginx di default), dai publicURL: "https://shop.example".

Conserva il request_id

Ogni risposta ha un request_id. Conservalo insieme a ciò che l'azione ha creato, in una colonna dell'account o dell'ordine, o in una riga di log con il loro id. Identifica quella decisione quando ci chiedi informazioni, e ti permette di scoprire in seguito quale risposta ha fatto entrare un account falso.

Provalo sulla tua macchina

Avvia next dev e apri http://localhost:3000. Le pagine su localhost accettano sempre il tag e non compaiono mai tra i domini. In locale l'IP è 127.0.0.1 o ::1, e va bene così. In Docker sulla tua macchina, l'app vede invece l'indirizzo del bridge, come 172.17.0.1: la pagina di challenge ti riporta comunque su localhost, perché il tag ha visto lì la tua pagina.

  1. Imposta KORZEN_API_KEY in .env.local e apri la pagina di registrazione. Negli strumenti per sviluppatori del browser, il cookie _kz compare dopo qualche secondo.
  2. Registrati. La decisione compare nella pagina Decisioni della dashboard, sotto il path che hai inviato.
  3. Verifica il fail-open: imposta una chiave sbagliata e registrati di nuovo. La chiamata risponde 401, e la registrazione va a buon fine.

Errori comuni

  • Leggere la prima voce di x-forwarded-for dietro un proxy che vi aggiunge voci. Un bot si sceglie allora il proprio IP.
  • Un form inviato via JavaScript appena la pagina si carica, prima che il tag abbia inviato i dati. Chiama await window.korzen?.ready() prima di inviarlo; si risolve entro pochi secondi anche quando Bot Protection è lento.
  • La chiamata nel middleware. Gira a ogni richiesta, e ogni chiamata conta come decisione: falla solo nell'azione che proteggi.
  • Chiamare /v1/decide dal browser. L'API key arriverebbe a ogni visitatore; la chiamata va fatta dal tuo server.
  • Nessun timeout, o un errore che blocca la registrazione. Qualsiasi risposta diversa da 200, o nessuna risposta in tempo, significa: lascia passare il visitatore.
  • Una riga nel tuo log che inizia con korzen: the visitor's IP sent to Bot Protection. Il client la scrive una volta, alla prima decisione il cui IP è della tua rete (di un load balancer, di un proxy, di un container), che Bot Protection giudica come senza IP: imposta ip sull'indirizzo del visitatore (l'IP del visitatore). In Docker sulla tua macchina è l'indirizzo del bridge, e lì non fa danni.