GitHub Security Advisory: GHSA-pxf8-6wqm-r6hh
Package: github.com/enchant97/note-mark/backend (GO)
Severity: CRITICAL (CVSS 9.4)
CWE: CWE-287: Improper Authentication
Vulnerable versions: < 0.0.0[PII_REDACTED]2909-dea5530cc989
Patched in: 0.0.0[PII_REDACTED]2909-dea5530cc989

Summary: Note Mark: OIDC-registered users authenticated by submitting password "null"

Details:
## Summary

`IsPasswordMatch` in `backend/db/models.go` falls back to a hard-coded `bcrypt("null")` placeholder whenever a user has no stored password. OIDC-registered users are created with an empty password, so anyone who submits `password: "null"` to the internal login endpoint receives a valid session for that user. The bypass is unauthenticated and requires no user interaction.

## Details

`backend/db/models.go:36` defines the placeholder hash used by the timing-attack mitigation inside `IsPasswordMatch`:

```go
var nullPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("null"), bcrypt.DefaultCost)
```

`IsPasswordMatch` (`backend/db/models.go:46-58`) substitutes that placeholder when the stored password is empty:

```go
func (u *User) IsPasswordMatch(plainPassword string) bool {
    var current []byte
    if len(u.Password) == 0 {
        // prevent CWE-208
        current = nullPasswordHash
    } else {
        current = u.Password
    }
    if err := bcrypt.CompareHashAndPassword(current, []byte(plainPassword)); err == nil {
        return true
    }
    return false
}
```

OIDC-registered users are stored with an empty password at `backend/services/auth.go:102-115`:

```go
return db.DB.Transaction(func(tx *gorm.DB) error {
    user := db.User{
        Username: username,
        Password: []byte(""),
    }
    // ...
})
```

The internal login endpoint (`POST /api/auth/token`, handled at `backend/services/auth.go:20-54`) calls `IsPasswordMatch` with the caller-supplied password. For any OIDC-only user, `bcrypt.CompareHashAndPassword(nullPasswordHash, []byte("null"))` returns nil, the function returns true, and the server issues a `Auth-Session-Token` cookie.

`EnableInternalLogin` defaults to `true`, and `GET /api/info` discloses both OIDC configuration and internal-login status. `enableAnonymousUserSearch` also defaults to `true`, so an unauthenticated caller enumerates usernames via `GET /api/users/search` before touching the login endpoint.

Once the session is issued, `PUT /api/users/me/password` accepts `existingPassword: "null"` because the same `IsPasswordMatch` routine verifies the existing password. The caller writes a new password onto the OIDC user's row, which locks the legitimate OIDC user out on the next internal-login path.

## Proof of Concept

Tested against `note-mark` v0.19.2.

Step 1: Start note-mark pointed at any OIDC provider and set `OIDC__ENABLE_USER_CREATION=true`. The defaults for `ENABLE_INTERNAL_LOGIN` and `ENABLE_ANONYMOUS_USER_SEARCH` do not need to be changed.

```bash
docker run -d --name note-mark-poc \
  -e OIDC__PROVIDER_NAME=example \
  -e OIDC__CLIENT_ID=note-mark \
  -e OIDC__CLIENT_SECRET=secret \
  -e OIDC__ISSUER_URL=https://your-oidc-provider/ \
  -e OIDC__ENABLE_USER_CREATION=true \
  -p 8088:8080 ghcr.io/enchant97/note-mark-backend:0.19.2
```

Step 2: Alice registers via the OIDC flow. `TryCreateNewOidcUser` stores her row with `Password = []byte("")`.

Step 3: Bob confirms the precondition