Security and quality

Restrict navigation and new windows

Prevent the notes window from navigating away or creating an unexpected BrowserWindow.

Desktop Notes loads one bundled page. It never needs to show another one. So any navigation is, by definition, a bug or an attack.

Here’s why this matters. Suppose some markup gets into the renderer, through a crafted note or a templating mistake. On its own it can’t do much. But if it can navigate the window to an attacker’s page, that page now runs inside your app, with your preload bridge attached and your application’s identity. Navigation is how a small injection becomes a big one.

Renderer-created windows are the same problem through a second door. window.open() and target="_blank" links create new pages you never planned for.

We need neither, so we deny both. Add this after creating the window:

win.webContents.on('will-navigate', event => {
  event.preventDefault()
})

win.webContents.setWindowOpenHandler(() => ({
  action: 'deny'
}))

will-navigate fires when the top frame is about to load a new URL: a clicked link, a form submission, an assignment to location. Calling event.preventDefault() cancels it. It does not fire for in-page anchor jumps, so nothing legitimate breaks.

setWindowOpenHandler runs before any new window exists. Returning { action: 'deny' } means the window is never created.

Later you might want a “Documentation” link. The right way is to open it in the user’s browser, and keep denying inside the app:

const { shell } = require('electron')

win.webContents.setWindowOpenHandler(({ url }) => {
  const target = new URL(url)

  if (
    target.protocol === 'https:' &&
    target.hostname === 'docs.desktop-notes.test'
  ) {
    void shell.openExternal(target.href)
  }

  return { action: 'deny' }
})

Two things to notice.

We parse the URL and compare the exact protocol and hostname. A startsWith('https://docs.desktop-notes.test') check looks fine but accepts https://docs.desktop-notes.test.attacker.invalid. Parse, then compare.

And we never pass an unchecked string to shell.openExternal(). Protocol handlers can do far more than open a web page. A file: URL or a custom scheme could launch something else entirely.

Keep the restrictive Content Security Policy in index.html too. Inline scripts need a weaker script-src, and that’s where these attacks start.

Verify the lockdown

Run the app, open DevTools, and try to escape:

window.open('https://attacker.invalid')
location.href = 'https://attacker.invalid'

Nothing should open. The notes interface should stay exactly where it is. Then test a normal link click, a target="_blank" link, and a form navigation. Throw in a confusing URL like the .attacker.invalid one above, and a non-HTTP protocol. Only the exact allowlisted link should leave the app, and it should leave through the system browser, never inside your window.

Lesson completed