GitHub Security Advisory: GHSA-3gr9-485j-v4xf
Package: github.com/enchant97/note-mark/backend (GO)
Severity: MODERATE (CVSS 5.3)
CWE: CWE-285: Improper Authorization
Vulnerable versions: < 0.0.0[PII_REDACTED]2843-d1bf845a2a2d
Patched in: 0.0.0[PII_REDACTED]2843-d1bf845a2a2d

Summary: Note Mark: Unauthenticated read of notes and assets in soft-deleted public books

Details:
## Summary

After a note-mark owner soft-deletes a public book, its notes and uploaded assets stay readable at `/api/notes/{id}`, `/api/notes/{id}/content`, the slug URL, and the asset endpoints. Unauthenticated callers who hold the note ID or the slug path retain access. GORM's soft-delete scope does not reach the raw `JOIN books ...` clauses used by the note and asset queries.

## Details

`DELETE /api/books/{bookID}` sets `books.deleted_at` to the current time. The book-level endpoint starts returning 404, which matches the owner's expectation that the book is gone. The note service and asset service query notes with a raw join that does not filter `books.deleted_at IS NULL`:

```go
// backend/services/notes.go:91-98 (GetNoteByID)
func (s NotesService) GetNoteByID(currentUserID *uuid.UUID, noteID uuid.UUID) (db.Note, error) {
    var note db.Note
    return note, dbErrorToServiceError(db.DB.
        Preload("Book").
        Joins("JOIN books ON books.id = notes.book_id").
        Where("owner_id = ? OR is_public = ?", currentUserID, true).
        First(&note, "notes.id = ?", noteID).Error)
}
```

GORM applies its soft-delete scope to the primary model of a query (here, `notes`) and to implicit `Joins("Book")` association clauses. It does not rewrite raw SQL passed to `Joins`. The soft-deleted book row keeps `is_public = true`, so the `WHERE owner_id = ? OR is_public = ?` clause still evaluates true for any caller on a book that was public at deletion time. For an unauthenticated caller (`currentUserID = nil`), `owner_id = NULL` fails but `is_public = true` passes, so the note query returns the row.

note-mark has a restore flow at `PUT /api/notes/{noteID}/restore` (`backend/services/notes.go:232-262`) that un-deletes the note and the parent book in one transaction. Owner access to soft-deleted notes is deliberate for that path; the comment at line 253 spells out the intent. The bug is that `is_public = true` survives the deletion, so unauthenticated callers keep access the owner chose to revoke.

The same raw-join pattern repeats at 9 more call sites in `backend/services/notes.go` (lines 79, 95, 107, 129, 143, 174, 206, 237, 276) and 4 call sites in `backend/services/assets.go` (lines 29, 73, 106, 143). Every public endpoint that reads a note or an asset inherits the bug.

## Proof of Concept

Tested against `note-mark` v0.19.2.

Step 1: Start note-mark.

```bash
docker run -d --name note-mark-poc -p 8088:8080 \
  ghcr.io/enchant97/note-mark-backend:0.19.2
```

Step 2: Alice signs up and logs in.

```bash
curl -X POST http://localhost:8088/api/users \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"Alicepass123!","name":"Alice"}'

curl -c alice.cookies -X POST http://localhost:8088/api/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"grant_type":"password","username":"alice","password":"Alicepass123!"}'
```

Step 3: Alice creates a public book and adds a note with content. Save the IDs from each respo