Skip to content

Commit bd91b01

Browse files
authored
fix(desktop/ipc): allowlist URL scheme protocols in openURLScheme (#5056)
The 'integration.openURLScheme' IPC method invokes 'shell.openExternal' with a renderer-supplied string after only checking that it contains '://'. Electron's documentation explicitly warns that passing untrusted URLs to 'shell.openExternal' is unsafe: schemes such as 'file://', 'smb://', 'ms-msdt:', 'search-ms:', 'jar:', 'res:', 'javascript:', 'data:' and 'vbscript:' have well-known abuse chains (local file disclosure, NTLM credential theft over SMB on Windows, MSDT/Follina-style RCE, etc.). Because the renderer process can also reach this IPC via any XSS sink in untrusted RSS feed content, the previous validation was not sufficient. Replace the substring check with strict URL parsing plus an allowlist of protocols that match the integration use-cases documented in the UI (Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus generic http/https/mailto. All other protocols are rejected with a clear error. Adds vitest cases for representative dangerous schemes (verifying that 'shell.openExternal' is never invoked) and for every scheme shipped as a built-in example, so future regressions on either side are caught.
1 parent 2350884 commit bd91b01

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

apps/desktop/layer/main/src/ipc/services/integration.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import fsp from "node:fs/promises"
22
import os from "node:os"
33

4+
import { shell } from "electron"
45
import path from "pathe"
5-
import { afterEach, describe, expect, it, vi } from "vitest"
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
67

78
import { IntegrationService } from "./integration"
89

@@ -69,4 +70,72 @@ describe("IntegrationService", () => {
6970
fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1")),
7071
).rejects.toThrow()
7172
})
73+
74+
describe("openURLScheme", () => {
75+
const openExternalMock = vi.mocked(shell.openExternal)
76+
77+
beforeEach(() => {
78+
openExternalMock.mockReset()
79+
openExternalMock.mockResolvedValue()
80+
})
81+
82+
it("rejects input that cannot be parsed as a URL", async () => {
83+
const service = new IntegrationService()
84+
85+
await expect(service.openURLScheme("not-a-url")).rejects.toThrow(
86+
/Invalid URL scheme/i,
87+
)
88+
expect(openExternalMock).not.toHaveBeenCalled()
89+
})
90+
91+
// These are the dangerous protocols that previously slipped through the
92+
// "contains ://" guard and reached shell.openExternal verbatim.
93+
// shell.openExternal docs explicitly warn that passing untrusted URLs is
94+
// unsafe — file://, smb://, search-ms:, ms-msdt:, jar:, res:, etc. have
95+
// been used in real-world RCE / NTLM-credential-theft chains.
96+
it.each([
97+
["file:///etc/passwd"],
98+
["FILE:///etc/passwd"],
99+
["smb://attacker.example/share"],
100+
["jar:http://attacker.example/x.jar!/"],
101+
["res://shell32.dll/1"],
102+
["ms-msdt:/id PCWDiagnostic"],
103+
["search-ms:query=secret"],
104+
["javascript:alert(1)"],
105+
["data:text/html,<script>alert(1)</script>"],
106+
["vbscript:msgbox(1)"],
107+
])(
108+
"blocks dangerous scheme %s and does not invoke shell.openExternal",
109+
async (dangerousScheme) => {
110+
const service = new IntegrationService()
111+
112+
await expect(service.openURLScheme(dangerousScheme)).rejects.toThrow(
113+
/not allowed|disallowed|not permitted/i,
114+
)
115+
expect(openExternalMock).not.toHaveBeenCalled()
116+
},
117+
)
118+
119+
// The integration UI ships these schemes as built-in examples
120+
// (see url-scheme-handler.ts#getExamples) plus generic web/mail.
121+
// They must keep working after the fix.
122+
it.each([
123+
["https://example.com"],
124+
["http://example.com/path?q=1"],
125+
["mailto:user@example.com"],
126+
["obsidian://new?vault=MyVault&name=Test"],
127+
["bear://x-callback-url/create?title=Test"],
128+
["things:///add?title=Test"],
129+
["notion://new?title=Test"],
130+
["x-devonthink://createText?title=Test"],
131+
["drafts://x-callback-url/create?text=Test"],
132+
])("permits known integration scheme %s", async (allowedScheme) => {
133+
const service = new IntegrationService()
134+
135+
await expect(service.openURLScheme(allowedScheme)).resolves.toEqual({
136+
success: true,
137+
})
138+
expect(openExternalMock).toHaveBeenCalledWith(allowedScheme)
139+
})
140+
})
72141
})

apps/desktop/layer/main/src/ipc/services/integration.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,29 @@ export async function saveMediaToEagle(input: SaveToEagleInput): Promise<any> {
8181
}
8282
}
8383

84+
// Allowlist of URL scheme protocols that `openURLScheme` is permitted to hand
85+
// off to `shell.openExternal`. The list intentionally covers the integrations
86+
// shipped in the UI (Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus
87+
// generic web/mail schemes, while excluding dangerous protocols such as
88+
// `file:`, `smb:`, `ms-msdt:`, `search-ms:`, `jar:`, `res:`, `javascript:`,
89+
// `data:`, `vbscript:`, which have known abuse chains when invoked from
90+
// untrusted content.
91+
const ALLOWED_URL_SCHEME_PROTOCOLS = new Set<string>([
92+
"http",
93+
"https",
94+
"mailto",
95+
"obsidian",
96+
"bear",
97+
"drafts",
98+
"things",
99+
"notion",
100+
"x-devonthink",
101+
])
102+
103+
function isAllowedURLSchemeProtocol(protocol: string): boolean {
104+
return ALLOWED_URL_SCHEME_PROTOCOLS.has(protocol)
105+
}
106+
84107
export class IntegrationService extends IpcService {
85108
static override readonly groupName = "integration"
86109

@@ -382,11 +405,32 @@ ${content}
382405
const requestId = Math.random().toString(36).slice(2, 8)
383406

384407
try {
385-
// Validate URL scheme format
386-
if (!scheme.includes("://")) {
408+
// Parse and validate the protocol up-front. `shell.openExternal` will
409+
// happily dispatch any scheme the OS has registered a handler for,
410+
// including `file://`, `smb://`, `ms-msdt:`, `search-ms:`, `jar:`,
411+
// `res:`, etc. Several of those have well-documented exploit chains
412+
// (NTLM credential theft over SMB, MSDT/Follina RCE on Windows,
413+
// local-file disclosure via file://). The Electron docs explicitly
414+
// warn against passing untrusted URLs to `shell.openExternal`, so we
415+
// enforce a strict allowlist of schemes that the integrations UI is
416+
// intended to support.
417+
let protocol: string
418+
try {
419+
protocol = new URL(scheme).protocol.replace(/:$/, "").toLowerCase()
420+
} catch {
387421
throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')")
388422
}
389423

424+
if (!protocol) {
425+
throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')")
426+
}
427+
428+
if (!isAllowedURLSchemeProtocol(protocol)) {
429+
throw new Error(
430+
`URL scheme "${protocol}://" is not allowed. Allowed schemes: ${[...ALLOWED_URL_SCHEME_PROTOCOLS].sort().join(", ")}.`,
431+
)
432+
}
433+
390434
// Log URL scheme execution (mask sensitive data)
391435
const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>
392436
// Mask potential sensitive query parameters
@@ -399,7 +443,7 @@ ${content}
399443

400444
logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {
401445
scheme: safeScheme,
402-
protocol: scheme.split("://")[0],
446+
protocol,
403447
})
404448

405449
// Use Electron's shell.openExternal to open URL scheme

0 commit comments

Comments
 (0)