Skip to content

Commit 171c7a9

Browse files
authored
fix(security): sandbox vikingdb:// protocol to userData directory (#363)
The renderer-loadable `vikingdb://` protocol read any local file the main-process had access to: the handler ran `path.resolve(filePath)` and passed the result straight to `fs.readFileSync` with no allow-list. A markdown image such as `![](vikingdb:///Users/<u>/.ssh/id_rsa)` rendered in any vault note, chat reply, or LLM response would have caused the main process to read that file and stream the bytes back into the renderer. This is amplified by two adjacent settings: * `webPreferences.webSecurity` is `false`, so the renderer can `fetch()` a custom-protocol URL and read the response body in JS. * `renderer/index.html`'s CSP allows `connect-src *` and `script-src 'unsafe-inline' 'unsafe-eval' *`, so any future renderer XSS — including a stored one in vault content — has unrestricted network egress. Combined, the original handler turned even a minor renderer XSS into a full local-file exfiltration primitive. Fix: constrain the resolved path to the directory the backend writes its data into. In production this is `app.getPath('userData')`, which mirrors `CONTEXT_PATH` in `backend.ts` (the env var the backend uses for its own storage root). In dev the backend is launched with `CONTEXT_PATH='.'`, so we mirror that and allow the project working directory. We also `fs.realpathSync` the resolved path before the under-root check, so a symlink planted inside userData (e.g. by another local process) can't be used to escape the sandbox. The MIME-type table, error codes, and callback shape are unchanged, so legitimate image loads under userData continue to work.
1 parent e782408 commit 171c7a9

1 file changed

Lines changed: 40 additions & 18 deletions

File tree

frontend/src/main/index.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -198,28 +198,50 @@ app.whenReady().then(() => {
198198
let filePath = request.url.replace('vikingdb://', '')
199199
filePath = decodeURIComponent(filePath)
200200

201-
const fullPath = path.resolve(filePath)
201+
const resolved = path.resolve(filePath)
202202

203-
console.log('Reading file:', fullPath)
203+
if (!fs.existsSync(resolved)) {
204+
callback({ error: -6 /* net::ERR_FILE_NOT_FOUND */ })
205+
return
206+
}
204207

205-
if (fs.existsSync(fullPath)) {
206-
const data = fs.readFileSync(fullPath)
207-
const extension = path.extname(fullPath).toLowerCase()
208+
// Constrain reads to the directory the backend writes its data into
209+
// (mirrors `CONTEXT_PATH` in backend.ts). Without this, the renderer
210+
// can read arbitrary local files via e.g. `vikingdb:///Users/<u>/.ssh/id_rsa`,
211+
// which combined with `webSecurity: false` and the permissive CSP
212+
// (`connect-src *`) makes any future renderer XSS a full local-file
213+
// exfiltration primitive. We also realpath() the resolved path so a
214+
// symlink planted inside userData cannot be used to escape the sandbox.
215+
const allowedRoot =
216+
!app.isPackaged && is.dev
217+
? path.resolve('.')
218+
: path.resolve(app.getPath('userData'))
219+
const realPath = fs.realpathSync(resolved)
220+
const isUnderRoot =
221+
realPath === allowedRoot || realPath.startsWith(allowedRoot + path.sep)
222+
223+
if (!isUnderRoot) {
224+
console.error(
225+
`vikingdb:// blocked path outside allowed root: ${realPath} (root: ${allowedRoot})`
226+
)
227+
callback({ error: -10 /* net::ERR_ACCESS_DENIED */ })
228+
return
229+
}
208230

209-
// Set MIME type based on file extension
210-
let mimeType = 'application/octet-stream'
211-
if (extension === '.png') mimeType = 'image/png'
212-
else if (extension === '.jpg' || extension === '.jpeg') mimeType = 'image/jpeg'
213-
else if (extension === '.gif') mimeType = 'image/gif'
214-
else if (extension === '.svg') mimeType = 'image/svg+xml'
231+
const data = fs.readFileSync(realPath)
232+
const extension = path.extname(realPath).toLowerCase()
215233

216-
callback({
217-
mimeType: mimeType,
218-
data: data
219-
})
220-
} else {
221-
callback({ error: -6 })
222-
}
234+
// Set MIME type based on file extension
235+
let mimeType = 'application/octet-stream'
236+
if (extension === '.png') mimeType = 'image/png'
237+
else if (extension === '.jpg' || extension === '.jpeg') mimeType = 'image/jpeg'
238+
else if (extension === '.gif') mimeType = 'image/gif'
239+
else if (extension === '.svg') mimeType = 'image/svg+xml'
240+
241+
callback({
242+
mimeType: mimeType,
243+
data: data
244+
})
223245
} catch (error) {
224246
console.error('Error reading file:', error)
225247
callback({ error: -2 })

0 commit comments

Comments
 (0)