Preload and IPC

Validate IPC input

Check the sender and every note field in the main process before writing renderer data to disk.

Values that cross IPC from the renderer are untrusted. A TypeScript type does not exist at runtime. The required attribute on the form runs in the page, and the page is what we don’t trust. So the main process checks everything itself, right before it does anything privileged.

Validate the data

Add this function to src/index.js. It checks the whole notes array and every field of every note:

function validateNotes(value) {
  if (!Array.isArray(value)) throw new Error('Notes must be an array')
  if (value.length > 1000) throw new Error('Too many notes')

  return value.map(note => {
    if (!note || typeof note !== 'object') throw new Error('Invalid note')
    if (typeof note.id !== 'string' || note.id.length > 100) {
      throw new Error('Invalid note id')
    }
    if (typeof note.title !== 'string' || note.title.length > 200) {
      throw new Error('Invalid note title')
    }
    if (typeof note.body !== 'string' || note.body.length > 100000) {
      throw new Error('Invalid note body')
    }

    return { id: note.id, title: note.title, body: note.body }
  })
}

Three kinds of checks. The value must be an array, and not a huge one. Each note must be an object. Each field must be a string with a sane maximum length.

Notice the last line. The validator does not return the object it received. It builds a new one with exactly three properties. Anything else the renderer sent, an extra isAdmin: true, a weird prototype, a nested function, is dropped. Only what we allow reaches the disk.

Validate the sender

Data validation answers “is this value well-formed?”. It does not answer “who sent it?”. For that we compare the sending frame with our one trusted window:

function assertTrustedSender(frame) {
  if (!mainWindow || frame !== mainWindow.webContents.mainFrame) {
    throw new Error('Untrusted IPC sender')
  }
}

Every handler calls this first with event.senderFrame. If the request comes from anywhere other than the main frame of our window, it is rejected.

You’ll see tutorials that check frame.url against a string instead. That’s easy to get wrong: a typo, a trailing slash, a file:// versus dev-server difference. Comparing the actual frame object is stronger, and it fits this app because we have exactly one trusted local window. If your app later grows several windows, build an explicit allowlist of trusted frames and what each one may do.

Return plain data

Electron serializes IPC values with the structured clone algorithm, the same one postMessage uses in the browser. Plain objects, arrays, and strings cross. DOM elements, Electron objects, and functions do not. Our validator returns plain objects, so we’re fine.

Test the validator directly

Because validateNotes is a plain function, you can test it without starting Electron. Call it with valid notes and confirm you get them back. Then throw bad input at it: an object instead of an array, 1,001 notes, a 300-character title, a note with extra properties, an object created with Object.create(null). Each bad case should throw, and the extra properties should vanish from the result.

Then test the sender check from inside the app. Add an <iframe> to the page, send a valid value from it, and confirm the handler rejects it with “Untrusted IPC sender”.

One limit to keep in mind. Validation controls shape and volume. It does not decide which user may save which notes. Desktop Notes has one local user, so that’s enough. With several users, add authorization as a separate step.

Lesson completed