Hall of Fame image from https://openclipart.org/detail/120343/trophy
Back to Hall of Fame Contents Back to Wekan Website

Contents / RandomBleed

CVE Vulnerability name Date Responsible Security Disclosure by Vulnerabilities
-

RandomBleed

2026-07-17 GitHub CodeQL (code scanning alert #422) and xet7 (fix)

Automated code scanning flagged the biased randomness.
  • 1. RandomBleed — biased random ids from a cryptographically secure source: modulo on crypto.randomBytes skews generated document ids toward the first characters of the alphabet (CWE-1204 weak randomness)
  • GitHub CodeQL code scanning alert #422, rule js/biased-cryptographic-random, severity High
  • Affected the startup schema upgrade id generation (server/lib/schemaUpgradeSteps.js)
  • Fixed at upcoming WeKan release


Details

1. RandomBleed — biased random ids from a cryptographically secure source (CWE-1204)

WeKan's startup schema upgrade (server/lib/schemaUpgradeSteps.js) creates new documents — default swimlanes, rescued lists, extracted checklist items — for data migrated from old WeKan versions, and gives each a Meteor-style 17-character random id so upgraded documents look exactly like app-created ones. The id generator drew cryptographically secure random bytes and mapped each byte to a character with modulo:

// vulnerable — modulo over a non-power-of-two alphabet is biased
const ID_CHARS = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz'; // 55 chars
function randomId(len = 17) {
  const bytes = crypto.randomBytes(len);
  let out = '';
  for (let i = 0; i < len; i++) out += ID_CHARS[bytes[i] % ID_CHARS.length];
  return out;
}
    

A byte is a uniform value in 0..255, i.e. 256 possibilities. The alphabet has 55 characters, and 256 is not a multiple of 55: 256 = 4 × 55 + 36. So the values 0..219 map four-to-one onto the 55 characters, but the leftover values 220..255 map an extra time onto the first 36 characters. Those first 36 characters therefore appear with probability 5/256 while the remaining 19 appear with probability 4/256 — the first 36 are ~25% more likely per draw (~1.4% of the total distribution each vs ~1.1%). The ids are still hard to guess, but the reduced entropy is exactly what CodeQL's js/biased-cryptographic-random rule flags: a cryptographically secure source is being used in a way that throws away part of its uniformity.

Fix: rejection sampling. Only accept bytes below the largest multiple of the alphabet size (256 − (256 mod 55) = 220); discard and redraw anything in 220..255, so every character is exactly equally likely. The ids stay Meteor-style 17 characters (and exact-length for any requested length):

// fixed — rejection sampling removes the modulo bias
function randomId(len = 17) {
  const limit = 256 - (256 % ID_CHARS.length);   // 220
  let out = '';
  while (out.length < len) {
    const bytes = crypto.randomBytes(len - out.length);
    for (let i = 0; i < bytes.length && out.length < len; i++) {
      if (bytes[i] < limit) out += ID_CHARS[bytes[i] % ID_CHARS.length];
    }
  }
  return out;
}
    

A negative regression test pins that out-of-range bytes are resampled rather than wrapped, so the biased modulo cannot silently return. CodeQL rule js/biased-cryptographic-random was resolved by this change, and the whole codebase was swept for other randomBytes modulo uses — this was the only one.