fix(security): mask the Obsidian WebDAV password from non-management callers

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).
This commit is contained in:
Xiangzhe
2026-08-21 14:06:56 -03:00
parent 14078b2f2b
commit 60060a6dca
2 changed files with 51 additions and 2 deletions

View File

@@ -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) {

View File

@@ -114,7 +114,46 @@ test("POST with a valid temp dir → returns { username, password }, GET shows e
const getBody = (await getRes.json()) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 });
}