From 60060a6dca98e0fdb4455a11831d4fac1a740cc4 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Fri, 21 Aug 2026 14:06:56 -0300 Subject: [PATCH] fix(security): mask the Obsidian WebDAV password from non-management callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/settings/obsidian/webdav returned the plaintext webdavPassword to any caller the handler admitted — including an anonymous caller reaching it through the requireLogin=false open mode (the default management pipeline already blocks non-manage keys). The plaintext is now returned only to a genuine management principal (dashboard session or manage-scope key); everyone else gets a `webdavPasswordSet` flag instead. The dashboard's authenticated reveal view is unchanged. Reported by @0raN9ewww via GHSA-62vw-4m6w-cqqq (and the credential-exposure portion of GHSA-p855-p6fm-76r3). --- src/app/api/settings/obsidian/webdav/route.ts | 12 +++++- tests/unit/obsidian-webdav-route.test.ts | 41 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/app/api/settings/obsidian/webdav/route.ts b/src/app/api/settings/obsidian/webdav/route.ts index 1465b1dafd..4b098cfdb0 100644 --- a/src/app/api/settings/obsidian/webdav/route.ts +++ b/src/app/api/settings/obsidian/webdav/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; import { getObsidianSyncStatus, @@ -21,10 +22,19 @@ export async function GET(request: NextRequest) { try { const status = await getObsidianSyncStatus(); + // GHSA-62vw: the WebDAV password is reusable authentication material. Return + // the plaintext only to a genuine management principal (dashboard session or + // manage-scope key), never to an anonymous caller that reached this handler + // through the requireLogin=false open mode. The dashboard's authenticated + // reveal-password view is unaffected; anonymous callers get a set/unset flag. + const hasManagement = + (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null; return NextResponse.json({ webdavEnabled: status.webdavEnabled, webdavUsername: status.webdavEnabled ? status.webdavUsername : null, - webdavPassword: status.webdavEnabled ? status.webdavPassword : null, + webdavPassword: + status.webdavEnabled && hasManagement ? status.webdavPassword : null, + webdavPasswordSet: status.webdavEnabled && Boolean(status.webdavPassword), vaultPath: status.vaultPath, }); } catch (error) { diff --git a/tests/unit/obsidian-webdav-route.test.ts b/tests/unit/obsidian-webdav-route.test.ts index cfc839a607..cfb9363492 100644 --- a/tests/unit/obsidian-webdav-route.test.ts +++ b/tests/unit/obsidian-webdav-route.test.ts @@ -114,7 +114,46 @@ test("POST with a valid temp dir → returns { username, password }, GET shows e const getBody = (await getRes.json()) as Record; assert.equal(getBody.webdavEnabled, true); assert.ok(typeof getBody.webdavUsername === "string" && (getBody.webdavUsername as string).length > 0); - assert.ok(typeof getBody.webdavPassword === "string" && (getBody.webdavPassword as string).length > 0); + // Anonymous GET (this request carries no management credential): the plaintext + // password is masked (GHSA-62vw), but the set/unset flag still reflects state. + assert.equal(getBody.webdavPassword, null); + assert.equal(getBody.webdavPasswordSet, true); + } finally { + fs.rmSync(vaultDir, { recursive: true, force: true }); + } +}); + +test("GET masks the WebDAV password for anonymous callers but reveals it to a management session (GHSA-62vw)", async () => { + const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-62vw-")); + try { + // Enable WebDAV so there is a stored password to leak. + const enableRes = await route.POST( + makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath: vaultDir }), + }) + ); + assert.equal(enableRes.status, 200); + + // Anonymous (open-mode) caller: password masked, flag still set. + const anonBody = (await (await route.GET( + makeRequest("http://localhost/api/settings/obsidian/webdav") + )).json()) as Record; + assert.equal(anonBody.webdavEnabled, true); + assert.equal(anonBody.webdavPassword, null, "anonymous caller must not receive the plaintext password"); + assert.equal(anonBody.webdavPasswordSet, true); + + // Genuine management session: the operator's reveal-password view still works. + const sessionReq = (await makeManagementSessionRequest( + "http://localhost/api/settings/obsidian/webdav" + )) as unknown as NextRequest; + const sessionBody = (await (await route.GET(sessionReq)).json()) as Record; + assert.ok( + typeof sessionBody.webdavPassword === "string" && + (sessionBody.webdavPassword as string).length > 0, + "a management session must still receive the plaintext password" + ); } finally { fs.rmSync(vaultDir, { recursive: true, force: true }); }