# REPRO-2026-00328: PasswordPusher allows unauthenticated deletion of anonymous pushes due to a nil==nil ownership check that bypasses viewer-deletion restrictions. ## Summary Status: published Severity: medium CVSS: 6.9 / 10 CWE: CWE-863 Incorrect Authorization (Incorrect Authorization) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00328 CVE: CVE-2026-62382 ## Package Name: pglombardo/PasswordPusher Ecosystem: Ruby on Rails self-hosted application, also shipped as Docker image pglombardo/pwpush Affected: v1.45.11 through v2.9.5 Fixed: v2.9.6 ## Root Cause # RCA Report — CVE-2026-62382: PasswordPusher Unauthenticated Deletion of Anonymous Pushes ## Summary PasswordPusher versions v1.45.11 through v2.9.5 contain an improper authorization flaw (CWE-863) in the push-deletion paths. Both the JSON API (`Api::V1::PushesController#destroy`) and the HTML UI (`PushesController#expire`) authorize deletion with `(@push.user == current_user) || @push.deletable_by_viewer`. For an anonymous push `@push.user` is `nil`, and for an unauthenticated request `current_user` is also `nil`, so the ownership comparison evaluates `nil == nil` → `true`. The `deletable_by_viewer` restriction is therefore bypassed, and anyone who knows only the secret URL can permanently delete (`expire!`) an anonymous push — clearing payload, passphrase, and attached files — even when viewer deletion was explicitly disabled and a passphrase protects reads. ## Impact - **Package/component:** PasswordPusher (self-hosted Ruby on Rails application), `pglombardo/pwpush` Docker images. - **Affected versions:** v1.45.11 – v2.9.5 (fixed in v2.9.6). Only deployments allowing anonymous pushes (the default) are affected. - **Risk:** Medium (CVSS 4.0: 6.9). Unauthenticated denial-of-service against secrets in transit: an attacker who learns or guesses a secret URL token can irreversibly destroy the push before the intended recipient retrieves it. ## Impact Parity - **Disclosed/claimed maximum impact:** authorization bypass — unauthenticated deletion of anonymous pushes (`authz_bypass`). - **Reproduced impact from this run:** identical. An unauthenticated `DELETE /p/.json` against pwpush 2.9.5 returned HTTP 200, set `expired=true`/`deleted=true`, cleared the passphrase, and destroyed the payload (subsequent authorized read with the correct passphrase returned `"payload": null`). The HTML route `DELETE /p//expire` also destroyed the push (HTTP 302 + push expired). - **Parity:** `full`. - Not demonstrated: nothing claimed beyond the authorization bypass / data destruction (no code execution was claimed or attempted). ## Root Cause In v2.9.5 the deletion guards were: - `app/controllers/api/v1/pushes_controller.rb` (`destroy`): `if (@push.user == current_user) || @push.deletable_by_viewer` - `app/controllers/pushes_controller.rb` (`expire`): `unless @push.deletable_by_viewer || (@push.user == current_user)` `Push#user` is a nullable `belongs_to`. Anonymous pushes have `user_id = NULL`, so `@push.user` is `nil`. Devise's `current_user` is `nil` when the request is unauthenticated. Ruby evaluates `nil == nil` as `true`, so the "owner" branch succeeds and the `deletable_by_viewer` check is never reached. `expire!` then clears `payload`, `passphrase`, and files and marks the push expired/deleted — irreversible. **Fix (v2.9.6, diff v2.9.5...v2.9.6):** both controllers now call a new model method `Push#deletable_by?(user)`: ```ruby def deletable_by?(user) (user.present? && user_id == user.id) || deletable_by_viewer == true end ``` which requires an authenticated (`present?`) user whose id matches the owner, removing the nil==nil equivalence. Advisory: https://github.com/pglombardo/PasswordPusher/security/advisories/GHSA-jf2m-hpj9-4qx2 ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker). 2. The script: - Pulls and starts the real product images `pglombardo/pwpush:2.9.5` (vulnerable, port 15100) and `pglombardo/pwpush:2.9.6` (fixed, port 15101). - Waits for HTTP readiness, then completes the real first-run setup flow (extracts the one-time boot code from container logs and creates the admin account), mirroring a fresh deployment. - As an **unauthenticated** client, creates an anonymous push with `payload=SUPER-SECRET-CVE-2026-62382`, `passphrase=s3cr3t`, `deletable_by_viewer=false`. - Verifies the payload is unreadable without the passphrase (HTTP 401). - Sends `DELETE /p/.json` with no session and no passphrase. - Re-reads the push with the correct passphrase and evaluates state. - Repeats the identical flow against the fixed image as a negative control, and additionally exercises the HTML `DELETE /p//expire` route on the vulnerable instance as secondary evidence. 3. Expected evidence: vulnerable → DELETE HTTP 200, push `expired=true`, `deleted=true`, `payload=null`; fixed → DELETE HTTP 401 (`"That push is not deletable by viewers."`), payload intact. ## Evidence - `bundle/logs/reproduction_steps.log` — full run transcript. Key excerpts: ``` [repro] [vuln] read without passphrase -> HTTP 401 [repro] [vuln] unauthenticated DELETE /p/cxlwytxdhjwx.json -> HTTP 200 [repro] [fixed] unauthenticated DELETE /p/5hpxxmf2x6_unhxtmq.json -> HTTP 401 [repro] vuln: DELETE=200 expired=true deleted=true payload=null [repro] fixed: DELETE=401 expired=false payload=SUPER-SECRET-CVE-2026-62382 [repro] RESULT: CVE-2026-62382 CONFIRMED (vuln exploited, fixed rejected). ``` - `bundle/artifacts/http/vuln_delete_response.json` — vulnerable DELETE response body: `expired:true`, `deleted:true`, `passphrase:null`. - `bundle/artifacts/http/vuln_read_after_delete.json` — authorized read after the attack returns `"payload": null` (secret destroyed). - `bundle/artifacts/http/vuln_read_without_passphrase.json` — pre-attack 401 proves the passphrase gate was active. - `bundle/artifacts/http/vuln_html_expire_response.txt` — HTML route also expires the push unauthenticated. - `bundle/artifacts/http/fixed_delete_response.json` — `401` + `{"error":"That push is not deletable by viewers."}`. - `bundle/artifacts/http/fixed_read_after_delete.json` — payload intact on fixed version. - `bundle/logs/pwpush_vuln_service.log` / `bundle/logs/pwpush_fixed_service.log` — container logs (Puma boot, first-run, request handling). - `bundle/repro/runtime_manifest.json` — runtime manifest with image digests and SHA-256 of every proof artifact. - Environment: Docker 29.1.3 on Linux x86_64; images `pglombardo/pwpush:2.9.5` (sha256:ba5cf45b…) and `pglombardo/pwpush:2.9.6` (sha256:c9662425…), Ruby 4.0.6 / Rails 8.1.3.1, production environment with default settings (`allow_anonymous` enabled). ## Recommendations / Next Steps - Upgrade to PasswordPusher ≥ v2.9.6 (or apply the `Push#deletable_by?` patch). - Never use `record.user == current_user` as an ownership test when either side can be `nil`; require `current_user.present? && record.user_id == current_user.id`. - Add regression tests: unauthenticated DELETE/expire of an anonymous push with `deletable_by_viewer=false` must be rejected (the fix release adds `test/integration/password/password_json_deletion_test.rb` etc.). - Defense-in-depth: deployments that do not need anonymous pushes should set `allow_anonymous: false`; secret URL tokens should be treated as bearer secrets and rotated. ## Additional Notes - **Idempotency:** the script removes/recreates its containers (`pwpush-vuln-repro`, `pwpush-fixed-repro`) on each run, performs first-run setup from scratch, and exits 0 only when the vulnerable instance is exploited AND the fixed instance rejects the attack. Verified passing twice consecutively in this run. - First-run admin setup is required by current PasswordPusher images before any push can be created; the script automates it via the boot code printed to container logs. This reflects real deployment behavior and does not affect the unauthenticated attack surface. - Limitations: the boot-code extraction depends on the Docker log driver; a non-Docker deployment would need the equivalent first-run step. The claimed entrypoint (`DELETE /p/.json`) is exercised directly; the HTML `/expire` route is included only as secondary evidence. ## Reproduction Details Reproduced: 2026-08-23T15:38:42.908Z Duration: 1293 seconds Tool calls: 131 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00328 pruva-verify CVE-2026-62382 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00328&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00328/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-62382 - Source: https://nvd.nist.gov/vuln/detail/CVE-2026-62382 ## Artifacts - bundle/repro/rca_report.md (analysis, 7869 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 11261 bytes) - bundle/artifacts/http/fixed_create_response.json (other, 466 bytes) - bundle/artifacts/http/fixed_delete_response.json (other, 50 bytes) - bundle/artifacts/http/fixed_read_after_delete.json (other, 497 bytes) - bundle/artifacts/http/vuln_create_response.json (other, 454 bytes) - bundle/artifacts/http/vuln_delete_response.json (other, 450 bytes) - bundle/artifacts/http/vuln_html_expire_response.txt (other, 229 bytes) - bundle/artifacts/http/vuln_read_after_delete.json (other, 476 bytes) - bundle/artifacts/http/vuln_read_without_passphrase.json (other, 189 bytes) - bundle/logs/pwpush_fixed_service.log (log, 5775 bytes) - bundle/logs/pwpush_vuln_service.log (log, 6238 bytes) - bundle/logs/reproduction_steps.log (log, 2727 bytes) - bundle/repro/runtime_manifest.json (other, 2607 bytes) - bundle/repro/validation_verdict.json (other, 1227 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00328 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00328/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00328 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev