CVE-2026-62382: Verified Reproduction
CVE-2026-62382: PasswordPusher allows unauthenticated deletion of anonymous pushes due to a nil==nil ownership check that bypasses viewer-deletion restrictions.
CVE-2026-62382 is verified against pglombardo/PasswordPusher · Ruby on Rails self-hosted application, also shipped as Docker image pglombardo/pwpush. Affected versions: v1.45.11 through v2.9.5. Fixed in v2.9.6. This medium reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00328.
What Is CVE-2026-62382?
CVE-2026-62382 is a medium-severity vulnerability affecting pglombardo/PasswordPusher v1.45.11 through v2.9.5. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00328).
CVE-2026-62382 Severity & CVSS Score
CVE-2026-62382 is rated medium severity, with a CVSS base score of 6.9 out of 10.
Medium — meaningful risk under specific conditions. Schedule a fix in the normal cycle.
Affected pglombardo/PasswordPusher Versions
pglombardo/PasswordPusher · Ruby on Rails self-hosted application, also shipped as Docker image pglombardo/pwpush versions v1.45.11 through v2.9.5 are affected.
How to Reproduce CVE-2026-62382
pruva-verify REPRO-2026-00328 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00328/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-62382
- reached the target end-to-end
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
url_token path parameter of an unauthenticated DELETE /p/<url_token>.json request
- Api::V1::PushesController#destroy (and PushesController#expire for HTML) via (@push.user == current_user) || @push.deletable_by_viewer where nil == nil
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2026-62382
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.
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/<url_token>.jsonagainst pwpush 2.9.5 returned HTTP 200, setexpired=true/deleted=true, cleared the passphrase, and destroyed the payload (subsequent authorized read with the correct passphrase returned"payload": null). The HTML routeDELETE /p/<url_token>/expirealso 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_viewerapp/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):
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
bundle/repro/reproduction_steps.sh(self-contained; requires Docker).- The script:
- Pulls and starts the real product images
pglombardo/pwpush:2.9.5(vulnerable, port 15100) andpglombardo/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/<url_token>.jsonwith 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/<token>/expireroute on the vulnerable instance as secondary evidence.
- Pulls and starts the real product images
- 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…) andpglombardo/pwpush:2.9.6(sha256:c9662425…), Ruby 4.0.6 / Rails 8.1.3.1, production environment with default settings (allow_anonymousenabled).
Recommendations / Next Steps
- Upgrade to PasswordPusher ≥ v2.9.6 (or apply the
Push#deletable_by?patch). - Never use
record.user == current_useras an ownership test when either side can benil; requirecurrent_user.present? && record.user_id == current_user.id. - Add regression tests: unauthenticated DELETE/expire of an anonymous push with
deletable_by_viewer=falsemust be rejected (the fix release addstest/integration/password/password_json_deletion_test.rbetc.). - 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/<url_token>.json) is exercised directly; the HTML/expireroute is included only as secondary evidence.
CVE-2026-62382 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
docker --version 2>&1; docker ps 2>&1 | head -3; which ruby python3 curl jq 2>&1; cat /workspace/bundle/.pruva/run_attempt.jsonDocker version 29.1.3, build 29.1.3-0ubuntu4.1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
/usr/bin/python3
/usr/bin/curl
/usr/bin/jq
{
"schema_version": 1,
"run_id": "b8267de6-dfe1-4257-b9ae-b6d58800dfb5",cd /tmp && curl -sL https://api.github.com/repos/pglombardo/PasswordPusher/compare/v2.9.5...v2.9.6 -o diff.json && jq -r '.files[].filename' diff.json | head -30VERSION app/controllers/api/v1/pushes_controller.rb app/controllers/pushes_controller.rb app/models/push.rb test/integration/api/api_v2_pushes_test.rb test/integration/password/password_deletion_test.rb test/integration/password/password_json_deletion_test.rb test/models/push_edit_test.rb
cd /tmp && jq -r '.files[] | select(.filename | test("controllers")) | "=== " + .filename + " ===\n" + .patch' diff.json | head -120=== app/controllers/api/v1/pushes_controller.rb ===
@@ -308,7 +308,7 @@ def audit
https://docs.pwpush.com/docs/json-api/
EOS
def destroy
- if (@push.user == current_user) || @push.deletable_by_viewer
+ if @push.deletable_by?(current_user)
unless @push.expired?cd /tmp && jq -r '.files[] | select(.filename == "app/models/push.rb") | .patch' diff.json@@ -193,6 +193,13 @@ def expire!
save!
end
+ # True when +user+ is the authenticated owner, or when viewer deletion is
+ # explicitly enabled. Anonymous pushes have a nil owner; comparing two nils
+ # must not count as ownership.
+ def deletable_by?(user)Artifacts and Evidence for CVE-2026-62382
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-62382
Upgrade pglombardo/PasswordPusher · Ruby on Rails self-hosted application, also shipped as Docker image pglombardo/pwpush to v2.9.6 or later.
FAQ: CVE-2026-62382
Is CVE-2026-62382 exploitable?
How severe is CVE-2026-62382?
What type of vulnerability is CVE-2026-62382?
Which versions of pglombardo/PasswordPusher are affected by CVE-2026-62382?
Is there a fix for CVE-2026-62382?
How can I reproduce CVE-2026-62382?
Is the CVE-2026-62382 reproduction verified?
References for CVE-2026-62382
Authoritative sources for CVE-2026-62382 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.