# 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/<url_token>.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/<url_token>/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/<url_token>.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/<token>/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/<url_token>.json`) is exercised directly; the HTML
  `/expire` route is included only as secondary evidence.
