Files
OmniRoute/src/lib/obsidianSync.ts
Diego Rodrigues de Sa e Souza df87e9363b fix(auth): close the JWT_SECRET bootstrap chain — real-peer loopback, obsidian always-protected, DATA_DIR vault refusal (#13791)
GHSA-7pq4-8pvv-rx7r (critical). Every link of the reported chain held on the
release tip:

1. First boot without JWT_SECRET generates one and writes it in cleartext to
   $DATA_DIR/server.env.
2. With no password configured, isAuthRequired() returned false for
   POST /api/settings/require-login unconditionally — before the loopback
   check — so any network peer could switch requireLogin off.
3. With requireLogin off, POST /api/settings/obsidian/webdav accepted an
   arbitrary vault root and echoed freshly minted Basic credentials.
4. The WebDAV file service is served by the custom Node layer before Next.js,
   outside the authz pipeline.
5. Pointing it at DATA_DIR reads server.env, and JWT_SECRET forges an
   `{"authenticated":true}` admin session.

A second, worse problem surfaced while verifying: isLoopbackRequest() decided
"loopback" from nextUrl.hostname / the Host header, which the client controls.
`Host: localhost` from a remote address made the whole fresh-install bootstrap
reachable, not just the write path.

Three cuts, plus the root cause:

- isLoopbackRequest() now reads the trusted peer: the token-stamped real TCP
  peer the custom server writes (peerStamp), then the pipeline's own locality
  verdict once a stamp token exists, then a real socket peer. The bootstrap
  write path honours the same constraint instead of returning false, and
  managementPolicy hands down the peerContext verdict explicitly, because at
  policy time the original request still carries client-supplied headers.
- Host is consulted only when the process has no stamp token at all — no
  stamping server in front, which in practice means route handlers invoked
  directly by the unit-test harness. Every supported runtime (run-next dev and
  start, standalone-server-ws for Docker, the npm CLI and Electron) calls
  ensurePeerStampToken() at boot, so there a signal-less request fails closed.
  Without this fallback ~340 route tests that call handlers with
  `new Request("http://localhost/…")` turned into 401s.
- /api/settings/obsidian joins ALWAYS_PROTECTED_API_PATHS: issuing and rotating
  reusable WebDAV credentials is credential export, the same rationale as the
  GHSA-62vw entry for the password reveal.
- enableObsidianVaultSync() refuses a vault that is, sits inside, or contains
  DATA_DIR, comparing realpath-resolved paths so a symlink cannot dodge it.

Tests are red-first: remote stamped peer → auth required on the bootstrap
write; Host: localhost plus a forged locality header from a non-loopback
stamped peer → 401 through the full pipeline; the local operator keeps the
first-password flow; obsidian inventory and DATA_DIR overlap cases.
2026-09-15 16:58:24 -03:00

150 lines
4.7 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { resolveDataDir } from "./dataPaths";
import {
getObsidianVaultPath,
setObsidianVaultPath,
clearObsidianVaultPath,
getWebdavUsername,
setWebdavUsername,
getWebdavPassword,
setWebdavPassword,
clearWebdavUsername,
clearWebdavPassword,
getWebdavEnabled,
setWebdavEnabled,
clearWebdavEnabled,
} from "./db/obsidian";
export type ObsidianSyncStatus = {
vaultPath: string | null;
webdavEnabled: boolean;
webdavUsername: string | null;
webdavPassword: string | null;
};
export type ObsidianSyncEnableResult =
| { success: true; vaultPath: string; username: string; password: string }
| { success: false; error: string };
export async function getObsidianSyncStatus(): Promise<ObsidianSyncStatus> {
const vaultPath = getObsidianVaultPath();
const webdavEnabled = getWebdavEnabled();
const webdavUsername = getWebdavUsername();
const webdavPassword = getWebdavPassword();
return { vaultPath, webdavEnabled, webdavUsername, webdavPassword };
}
/** Canonical (symlink-resolved) form of a path; falls back to the lexical resolve. */
function canonicalPath(target: string): string {
try {
return fs.realpathSync.native(target);
} catch {
return path.resolve(target);
}
}
/** True when `child` is `parent` itself or lives anywhere below it. */
function isSameOrInside(parent: string, child: string): boolean {
const rel = path.relative(parent, child);
if (rel === "") return true;
if (path.isAbsolute(rel)) return false; // different drive (win32)
return rel !== ".." && !rel.startsWith(`..${path.sep}`);
}
/**
* GHSA-7pq4-8pvv-rx7r: the WebDAV file service (scripts/dev/webdav-handler.mjs)
* serves the vault root to anyone holding the Basic credentials, before Next.js
* and outside the authz pipeline. A vault that IS the data directory, sits
* inside it, or CONTAINS it turns that service into a reader for server.env
* (JWT_SECRET / STORAGE_ENCRYPTION_KEY / API_KEY_SECRET) and storage.sqlite.
* Both sides are realpath-resolved so a symlink cannot dodge the comparison.
*/
export function vaultPathOverlapsDataDir(resolvedVaultPath: string): boolean {
const vault = canonicalPath(resolvedVaultPath);
const dataDir = canonicalPath(resolveDataDir());
return isSameOrInside(dataDir, vault) || isSameOrInside(vault, dataDir);
}
export const VAULT_OVERLAPS_DATA_DIR_ERROR =
"Vault path must not be the OmniRoute data directory, a directory inside it, or a directory that contains it";
export async function enableObsidianVaultSync(
vaultPath: string
): Promise<ObsidianSyncEnableResult> {
const resolvedPath = path.resolve(vaultPath);
if (!fs.existsSync(resolvedPath)) {
return { success: false, error: `Vault directory not found: ${resolvedPath}` };
}
const stat = fs.statSync(resolvedPath);
if (!stat.isDirectory()) {
return { success: false, error: `Path is not a directory: ${resolvedPath}` };
}
if (vaultPathOverlapsDataDir(resolvedPath)) {
return { success: false, error: VAULT_OVERLAPS_DATA_DIR_ERROR };
}
try {
setObsidianVaultPath(resolvedPath);
const username = generateRandomString(12);
const password = generateRandomString(24);
setWebdavUsername(username);
setWebdavPassword(password);
setWebdavEnabled(true);
return { success: true, vaultPath: resolvedPath, username, password };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return { success: false, error: msg };
}
}
export async function disableObsidianVaultSync(): Promise<{ success: boolean; error?: string }> {
try {
const vaultPath = getObsidianVaultPath();
if (vaultPath) {
removeStignore(vaultPath);
}
clearObsidianVaultPath();
clearWebdavUsername();
clearWebdavPassword();
clearWebdavEnabled();
return { success: true };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return { success: false, error: msg };
}
}
function removeStignore(vaultPath: string): void {
try {
const stignorePath = path.join(vaultPath, ".stignore");
if (fs.existsSync(stignorePath)) {
const content = fs.readFileSync(stignorePath, "utf-8");
const marker = "# Managed by OmniRoute";
if (content.includes(marker)) {
fs.unlinkSync(stignorePath);
}
}
} catch {
// Non-critical
}
}
function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const buf = new Uint8Array(length);
crypto.getRandomValues(buf);
for (let i = 0; i < length; i++) {
result += chars[buf[i] % chars.length];
}
return result;
}