Moderní tvorba webových aplikací

O webu

Proč !== v JS není bezpečné pro porovnávání tajných řetězců

Operátor !== v JavaScriptu není constant-time. Při porovnávání hesel, tokenů nebo API klíčů to umožňuje timing attack.

7 minut

Operátory === a !== v JavaScriptu porovnávají řetězce znak po znaku a vrátí výsledek ihned, jakmile najdou rozdíl. To je efektivní, ale při porovnávání tajných hodnot (hesla, tokeny, API klíče) to představuje bezpečnostní risiko.

V čem je problém

Představte si tento kód pro ověření API klíče:

function overApiKlic(vstup: string): boolean {
  const spravnyKlic = process.env.API_KEY;
  return vstup === spravnyKlic;
}

Vypadá nevinně, ale JavaScript engine porovnává takto:

  1. Porovná první znak
  2. Pokud se liší → okamžitě vrátí false
  3. Pokud se shoduje → porovná druhý znak
  4. Opakuje, dokud nenajde rozdíl nebo nedojde na konec

To znamená, že:

  • "AAAA" === "XXXX" — selže okamžitě (první znak)
  • "XAAA" === "XXXX" — selže o něco později (druhý znak)
  • "XXAA" === "XXXX" — selže ještě později (třetí znak)

Útočník může měřit čas odpovědi a postupně uhodnout každý znak tajného klíče.

Co je timing attack

Timing attack je typ útoku postranním kanálem (side-channel attack), kdy útočník získává informace na základě doby trvání operace, nikoliv z jejího výstupu.

Při porovnávání řetězců:

// Útočník zkouší různé první znaky
"A..." → 0.1 ms
"B..." → 0.1 ms
"X..." → 0.2 ms  ← trvá déle, první znak je správně!

// Pak zkouší druhý znak
"XA..." → 0.2 ms
"XB..." → 0.2 ms
"XX..." → 0.3 ms  ← druhý znak je správně!

U lokálního útoku stačí nanosekundové rozdíly. U síťového útoku je potřeba více pokusů pro statistickou analysu, ale moderní techniky (např. analysa TCP timestampů) to umožňují i přes internet.

Řešení: constant-time porovnání

Bezpečné porovnání musí trvat stejně dlouho bez ohledu na to, kde se řetězce liší. V Node.js použijte crypto.timingSafeEqual:

import crypto from "crypto";

function bezpecnePorovnej(a: string, b: string): boolean {
  // Oba řetězce musí mít stejnou délku
  if (a.length !== b.length) {
    return false;
  }

  const bufferA = Buffer.from(a, "utf8");
  const bufferB = Buffer.from(b, "utf8");

  return crypto.timingSafeEqual(bufferA, bufferB);
}

Pozor: timingSafeEqual vyžaduje buffery stejné délky. Pokud délky nesouhlasí, musíte to ošetřit — ale tím prozradíte, že délka je špatně. V praxi to většinou nevadí, protože útočník délku klíče často zná (např. UUID má vždy 36 znaků).

Lepší varianta s hash

Elegantnější řešení je porovnávat hashe obou hodnot. Hash má vždy stejnou délku:

import crypto from "crypto";

function bezpecnePorovnejHash(a: string, b: string): boolean {
  const hashA = crypto.createHash("sha256").update(a).digest();
  const hashB = crypto.createHash("sha256").update(b).digest();

  return crypto.timingSafeEqual(hashA, hashB);
}

Tato varianta:

  • Funguje pro řetězce jakékoliv délky
  • Neprozrazuje délku tajného klíče
  • Je o něco pomalejší kvůli hashování

Co v prohlížeči

V prohlížeči není crypto.timingSafeEqual k disposici. Můžete použít Web Crypto API pro hashování:

async function bezpecnePorovnejProhlizec(
  a: string,
  b: string
): Promise<boolean> {
  const encoder = new TextEncoder();

  const hashA = await crypto.subtle.digest("SHA-256", encoder.encode(a));
  const hashB = await crypto.subtle.digest("SHA-256", encoder.encode(b));

  const viewA = new Uint8Array(hashA);
  const viewB = new Uint8Array(hashB);

  // Constant-time porovnání
  let result = 0;
  for (let i = 0; i < viewA.length; i++) {
    result |= viewA[i] ^ viewB[i];
  }

  return result === 0;
}

Poznámka: XOR porovnání je constant-time, protože projde vždy všechny bajty bez ohledu na to, kde se hodnoty liší.

Na frontendu ale timing attack prakticky nehrozí. Důvody jsou dva:

  1. Tajné hodnoty na frontend nepatří — pokud máte API klíč nebo heslo v JavaScriptu prohlížeče, útočník ho najde ve zdrojovém kódu nebo DevTools během sekund. Timing attack je zbytečně složitý.
  2. Útočník by útočil sám na sebe — frontend kód běží v prohlížeči uživatele. Útočník by měřil čas operací ve svém vlastním prohlížeči, kde už má plný přístup ke všemu.

Timing attack dává smysl pouze tam, kde:

  • Tajná hodnota je na serveru (útočník ji nezná)
  • Útočník může opakovaně posílat requesty a měřit čas odpovědi
// Backend (Node.js) — RISIKO
app.post("/api", (req, res) => {
  if (req.headers["x-api-key"] === process.env.SECRET) {
    // Útočník měří čas odpovědi a postupně uhodne SECRET
  }
});

// Frontend (prohlížeč) — BEZ RISIKA
if (userInput === "nějaká hodnota") {
  // Útočník vidí "nějaká hodnota" přímo ve zdrojovém kódu
}

Kód výše je tedy relevantní hlavně pro hypotetické situace, když frontend i backend sdílí stejnou logiku a chcete mít jednotný kód.

Kdy na tom záleží

Constant-time porovnání je důležité při:

  • Ověřování API klíčů
  • Porovnávání HMAC signatur (webhooky, JWT)
  • Ověřování CSRF tokenů
  • Porovnávání session ID
  • Jakékoliv tajné hodnoty, které útočník může zkoušet opakovaně

Naopak není potřeba u:

  • Veřejných dat (uživatelská jména, e-maily)
  • Hodnot, které útočník nemůže ovlivnit
  • Jednorázových tokenů s krátkým TTL a rate limitingem

Shrnutí

  • === a !== v JS nejsou constant-time
  • Při porovnávání tajných hodnot použijte crypto.timingSafeEqual
  • Alternativně porovnávejte hashe obou hodnot
  • V prohlížeči použijte XOR porovnání přes všechny bajty

Odkazy

Související články

Jak vkládat 3D objekty na web pomocí Three.js

Které formáty použít, jak vytvářet modely pomocí AI a kdy raději použít obrázek nebo video.

15 minut

Detekce otevření DevTools

Jak zjistit, že se na stránce otevřely vývojářské nástroje.

13 minut

JavaScript null a undefined

Rozdíly mezi null a undefined v JavaScriptu, kdy je používat a jak se vyhnout běžným chybám.

12 minut

Sleep v JavaScriptu

Jak implementovat sleep/delay funkcionalitu v JavaScriptu pomocí Promise a async/await

6 minut

Novinky e-mailem

Když budu mít něco opravdu zajímavého, můžu vám to poslat e-mailem

Přidej se k 500+ čtenářům
Jen kvalitní obsah
Žádný spam

Web jecas.cz píše Bohumil Jahoda, kontakt
Seznam všech článků
2013–2025