
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.
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:
- Porovná první znak
- Pokud se liší → okamžitě vrátí
false - Pokud se shoduje → porovná druhý znak
- 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:
- 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ý.
- Ú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.
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.
Sleep v JavaScriptu
Jak implementovat sleep/delay funkcionalitu v JavaScriptu pomocí Promise a async/await