{"repro_id":"REPRO-2026-00328","version":6,"title":"PasswordPusher allows unauthenticated deletion of anonymous pushes due to a nil==nil ownership check that bypasses viewer-deletion restrictions.","repro_type":"security","status":"published","severity":"medium","cvss_score":6.9,"description":"PasswordPusher contains an improper authorization flaw in push deletion. For anonymously created pushes, both `@push.user` and `current_user` are `nil`, so the ownership comparison evaluates `nil == nil` as true and the `deletable_by_viewer` restriction is skipped. As a result, an attacker who only knows the secret URL can permanently delete an anonymous push even if viewer deletion was disabled and even if they do not know the passphrase.","root_cause":"# RCA Report — CVE-2026-62382: PasswordPusher Unauthenticated Deletion of Anonymous Pushes\n\n## Summary\nPasswordPusher versions v1.45.11 through v2.9.5 contain an improper authorization\nflaw (CWE-863) in the push-deletion paths. Both the JSON API\n(`Api::V1::PushesController#destroy`) and the HTML UI\n(`PushesController#expire`) authorize deletion with\n`(@push.user == current_user) || @push.deletable_by_viewer`. For an anonymous\npush `@push.user` is `nil`, and for an unauthenticated request `current_user`\nis also `nil`, so the ownership comparison evaluates `nil == nil` → `true`.\nThe `deletable_by_viewer` restriction is therefore bypassed, and anyone who\nknows only the secret URL can permanently delete (`expire!`) an anonymous push\n— clearing payload, passphrase, and attached files — even when viewer deletion\nwas explicitly disabled and a passphrase protects reads.\n\n## Impact\n- **Package/component:** PasswordPusher (self-hosted Ruby on Rails application),\n  `pglombardo/pwpush` Docker images.\n- **Affected versions:** v1.45.11 – v2.9.5 (fixed in v2.9.6). Only deployments\n  allowing anonymous pushes (the default) are affected.\n- **Risk:** Medium (CVSS 4.0: 6.9). Unauthenticated denial-of-service against\n  secrets in transit: an attacker who learns or guesses a secret URL token can\n  irreversibly destroy the push before the intended recipient retrieves it.\n\n## Impact Parity\n- **Disclosed/claimed maximum impact:** authorization bypass — unauthenticated\n  deletion of anonymous pushes (`authz_bypass`).\n- **Reproduced impact from this run:** identical. An unauthenticated\n  `DELETE /p/<url_token>.json` against pwpush 2.9.5 returned HTTP 200, set\n  `expired=true`/`deleted=true`, cleared the passphrase, and destroyed the\n  payload (subsequent authorized read with the correct passphrase returned\n  `\"payload\": null`). The HTML route `DELETE /p/<url_token>/expire` also\n  destroyed the push (HTTP 302 + push expired).\n- **Parity:** `full`.\n- Not demonstrated: nothing claimed beyond the authorization bypass / data\n  destruction (no code execution was claimed or attempted).\n\n## Root Cause\nIn v2.9.5 the deletion guards were:\n\n- `app/controllers/api/v1/pushes_controller.rb` (`destroy`):\n  `if (@push.user == current_user) || @push.deletable_by_viewer`\n- `app/controllers/pushes_controller.rb` (`expire`):\n  `unless @push.deletable_by_viewer || (@push.user == current_user)`\n\n`Push#user` is a nullable `belongs_to`. Anonymous pushes have `user_id = NULL`,\nso `@push.user` is `nil`. Devise's `current_user` is `nil` when the request is\nunauthenticated. Ruby evaluates `nil == nil` as `true`, so the \"owner\" branch\nsucceeds and the `deletable_by_viewer` check is never reached. `expire!` then\nclears `payload`, `passphrase`, and files and marks the push expired/deleted —\nirreversible.\n\n**Fix (v2.9.6, diff v2.9.5...v2.9.6):** both controllers now call a new model\nmethod `Push#deletable_by?(user)`:\n\n```ruby\ndef deletable_by?(user)\n  (user.present? && user_id == user.id) || deletable_by_viewer == true\nend\n```\n\nwhich requires an authenticated (`present?`) user whose id matches the owner,\nremoving the nil==nil equivalence.\nAdvisory: https://github.com/pglombardo/PasswordPusher/security/advisories/GHSA-jf2m-hpj9-4qx2\n\n## Reproduction Steps\n1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker).\n2. The script:\n   - Pulls and starts the real product images `pglombardo/pwpush:2.9.5`\n     (vulnerable, port 15100) and `pglombardo/pwpush:2.9.6` (fixed, port 15101).\n   - Waits for HTTP readiness, then completes the real first-run setup flow\n     (extracts the one-time boot code from container logs and creates the\n     admin account), mirroring a fresh deployment.\n   - As an **unauthenticated** client, creates an anonymous push with\n     `payload=SUPER-SECRET-CVE-2026-62382`, `passphrase=s3cr3t`,\n     `deletable_by_viewer=false`.\n   - Verifies the payload is unreadable without the passphrase (HTTP 401).\n   - Sends `DELETE /p/<url_token>.json` with no session and no passphrase.\n   - Re-reads the push with the correct passphrase and evaluates state.\n   - Repeats the identical flow against the fixed image as a negative control,\n     and additionally exercises the HTML `DELETE /p/<token>/expire` route on\n     the vulnerable instance as secondary evidence.\n3. Expected evidence: vulnerable → DELETE HTTP 200, push `expired=true`,\n   `deleted=true`, `payload=null`; fixed → DELETE HTTP 401\n   (`\"That push is not deletable by viewers.\"`), payload intact.\n\n## Evidence\n- `bundle/logs/reproduction_steps.log` — full run transcript. Key excerpts:\n\n  ```\n  [repro] [vuln] read without passphrase -> HTTP 401\n  [repro] [vuln] unauthenticated DELETE /p/cxlwytxdhjwx.json -> HTTP 200\n  [repro] [fixed] unauthenticated DELETE /p/5hpxxmf2x6_unhxtmq.json -> HTTP 401\n  [repro] vuln:  DELETE=200 expired=true deleted=true payload=null\n  [repro] fixed: DELETE=401 expired=false payload=SUPER-SECRET-CVE-2026-62382\n  [repro] RESULT: CVE-2026-62382 CONFIRMED (vuln exploited, fixed rejected).\n  ```\n\n- `bundle/artifacts/http/vuln_delete_response.json` — vulnerable DELETE\n  response body: `expired:true`, `deleted:true`, `passphrase:null`.\n- `bundle/artifacts/http/vuln_read_after_delete.json` — authorized read after\n  the attack returns `\"payload\": null` (secret destroyed).\n- `bundle/artifacts/http/vuln_read_without_passphrase.json` — pre-attack 401\n  proves the passphrase gate was active.\n- `bundle/artifacts/http/vuln_html_expire_response.txt` — HTML route also\n  expires the push unauthenticated.\n- `bundle/artifacts/http/fixed_delete_response.json` — `401` +\n  `{\"error\":\"That push is not deletable by viewers.\"}`.\n- `bundle/artifacts/http/fixed_read_after_delete.json` — payload intact on\n  fixed version.\n- `bundle/logs/pwpush_vuln_service.log` / `bundle/logs/pwpush_fixed_service.log`\n  — container logs (Puma boot, first-run, request handling).\n- `bundle/repro/runtime_manifest.json` — runtime manifest with image digests\n  and SHA-256 of every proof artifact.\n- Environment: Docker 29.1.3 on Linux x86_64; images\n  `pglombardo/pwpush:2.9.5` (sha256:ba5cf45b…) and\n  `pglombardo/pwpush:2.9.6` (sha256:c9662425…), Ruby 4.0.6 / Rails 8.1.3.1,\n  production environment with default settings (`allow_anonymous` enabled).\n\n## Recommendations / Next Steps\n- Upgrade to PasswordPusher ≥ v2.9.6 (or apply the `Push#deletable_by?` patch).\n- Never use `record.user == current_user` as an ownership test when either side\n  can be `nil`; require `current_user.present? && record.user_id == current_user.id`.\n- Add regression tests: unauthenticated DELETE/expire of an anonymous push with\n  `deletable_by_viewer=false` must be rejected (the fix release adds\n  `test/integration/password/password_json_deletion_test.rb` etc.).\n- Defense-in-depth: deployments that do not need anonymous pushes should set\n  `allow_anonymous: false`; secret URL tokens should be treated as bearer\n  secrets and rotated.\n\n## Additional Notes\n- **Idempotency:** the script removes/recreates its containers\n  (`pwpush-vuln-repro`, `pwpush-fixed-repro`) on each run, performs first-run\n  setup from scratch, and exits 0 only when the vulnerable instance is\n  exploited AND the fixed instance rejects the attack. Verified passing twice\n  consecutively in this run.\n- First-run admin setup is required by current PasswordPusher images before any\n  push can be created; the script automates it via the boot code printed to\n  container logs. This reflects real deployment behavior and does not affect\n  the unauthenticated attack surface.\n- Limitations: the boot-code extraction depends on the Docker log driver; a\n  non-Docker deployment would need the equivalent first-run step. The claimed\n  entrypoint (`DELETE /p/<url_token>.json`) is exercised directly; the HTML\n  `/expire` route is included only as secondary evidence.\n","cve_id":"CVE-2026-62382","cwe_id":"CWE-863 Incorrect Authorization","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-62382","package":{"name":"pglombardo/PasswordPusher","ecosystem":"Ruby on Rails self-hosted application, also shipped as Docker image pglombardo/pwpush","affected_versions":"v1.45.11 through v2.9.5","fixed_version":"v2.9.6"},"reproduced_at":"2026-08-23T15:38:42.908579+00:00","duration_secs":1293.0,"tool_calls":131,"handoffs":2,"total_cost_usd":2.320467,"agent_costs":{"claim_matcher":0.016044,"judge":0.313696,"learning_policy":0.014201,"repro":0.88107,"support":0.072825,"vuln_variant":1.022631},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.016044},"judge":{"gpt-5.5":0.313696},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.014201},"repro":{"accounts/fireworks/models/kimi-k3":0.88107},"support":{"accounts/fireworks/models/kimi-k3":0.072825},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.022631}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:38:43.479417+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7869,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":11261,"category":"reproduction_script"},{"path":"bundle/artifacts/http/fixed_create_response.json","filename":"fixed_create_response.json","size":466,"category":"other"},{"path":"bundle/artifacts/http/fixed_delete_response.json","filename":"fixed_delete_response.json","size":50,"category":"other"},{"path":"bundle/artifacts/http/fixed_read_after_delete.json","filename":"fixed_read_after_delete.json","size":497,"category":"other"},{"path":"bundle/artifacts/http/vuln_create_response.json","filename":"vuln_create_response.json","size":454,"category":"other"},{"path":"bundle/artifacts/http/vuln_delete_response.json","filename":"vuln_delete_response.json","size":450,"category":"other"},{"path":"bundle/artifacts/http/vuln_html_expire_response.txt","filename":"vuln_html_expire_response.txt","size":229,"category":"other"},{"path":"bundle/artifacts/http/vuln_read_after_delete.json","filename":"vuln_read_after_delete.json","size":476,"category":"other"},{"path":"bundle/artifacts/http/vuln_read_without_passphrase.json","filename":"vuln_read_without_passphrase.json","size":189,"category":"other"},{"path":"bundle/logs/pwpush_fixed_service.log","filename":"pwpush_fixed_service.log","size":5775,"category":"log"},{"path":"bundle/logs/pwpush_vuln_service.log","filename":"pwpush_vuln_service.log","size":6238,"category":"log"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":2727,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2607,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1227,"category":"other"}]}