Sik.limited Logo

Seguridad en Electron: cómo limitar preload, contextBridge e IPC

Cómo diseñar el límite entre el renderer y los permisos del sistema operativo en Electron: preload, contextBridge e IPC como contratos mínimos, con código y listas de verificación para archivos, enlaces externos y contenido remoto.

Sik ·

Cuando conecte Electron por primera vez, la pantalla aparecerá rápidamente. Esto se debe a que puedes importar los paquetes React·Svelte·Vue y Node.js utilizados en la web. Pero cuando la aplicación abre un archivo, muestra una notificación OS, abre un enlace externo y comienza a recibir actualizaciones automáticas, el nombre del problema cambia. En lugar de preguntar "¿qué puedo permitir que haga el renderizador?" se convierte en "¿cuánto puedo permitir que haga un renderizador roto o inesperado?"

Para concluir este artículo, la seguridad de Electron no es cuestión de una línea de configuración. preload es la puerta de enlace que transfiere permisos, contextBridge es el contrato para el público API y IPC debe diseñarse como el servidor API. Reducir estos tres no le brinda menos funciones, sino que crea una aplicación que se puede revisar incluso con más funciones.

Si ya está preocupado por elegir un marco de escritorio, también vale la pena leer primero Tauri vs Electron: Criterios para seleccionar una aplicación de escritorio SvelteKit. Aquí, suponiendo que haya elegido Electron, solo cubriremos cómo operar esa opción de manera segura.

Un perímetro de seguridad es un flujo de permisos, no una ventana

Suele haber tres tipos de códigos en la aplicación Electron.

ubicacióntareaactitud basica
main processVentana, sistema de archivos, controladores OS API, IPCDónde se ejercen los permisos
preloadConectividad limitada entre el renderizador y el principal.Dónde traducir funciones
rendererPantallas, entrada de usuario, contenido web.Un lugar para tratar sin confianza.

El error más común es que, al abrir el paquete local como loadFile(), puedes confiar en el renderizador. Sin embargo, dentro de la aplicación, la entrada externa llega antes de lo esperado, como vistas previas de rebajas, HTML pegado, ventanas OAuth externas, vistas previas de enlaces y interfaz de usuario de complementos. Si tiene un XSS y el renderizador puede usar require('fs') o enviar libremente el nombre IPC, el error de pantalla se convierte en un problema de permisos del escritorio.

Por eso es mejor leer el modelo de seguridad de esta manera.

사용자 입력·웹 콘텐츠
          ↓
      renderer
          ↓  (의도와 데이터가 제한된 API)
       preload
          ↓  (채널·스키마·발신 창 검증)
         main
          ↓
  파일·키체인·OS·네트워크

Aquí preload no es un mini backend. No es un lugar para almacenar procesamiento pesado de archivos o decisiones de permisos. Es más bien un adaptador que declara una pequeña lista de funciones que el renderizador puede llamar. La determinación de la autoridad real debe realizarse una vez más en main.


Especifique los valores predeterminados de la ventana en el código.

Recientemente, Electron activa el aislamiento de contexto y la zona de pruebas del renderizador de forma predeterminada, pero es mejor no confiar únicamente en los valores predeterminados. Esto se debe a que la configuración puede variar a medida que el equipo actualiza, reemplaza el modelo estándar o agrega ventanas especiales. En particular, el código que crea nuevas ventanas suele ser el primer lugar donde buscar en las revisiones de seguridad.

// main/window.ts
import { BrowserWindow } from 'electron'
import path from 'node:path'

export function createMainWindow() {
  return new BrowserWindow({
    width: 1280,
    height: 840,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      sandbox: true,
      nodeIntegration: false,
      webSecurity: true,
      webviewTag: false,
      enableBlinkFeatures: '',
      allowRunningInsecureContent: false
    }
  })
}

Esta configuración no está certificada como "completamente segura". Sin embargo, evita que el renderizador use el tiempo de ejecución del nodo directamente, separa la precarga del objeto global JavaScript de la página y hace que el renderizador en espacio aislado pase por IPC para obtener altos privilegios. Lo importante es que no termine solo con nodeIntegration: false. Si desactiva el aislamiento de contexto, el fuerte API de la precarga puede mezclarse con los objetos globales del lado de la página y los beneficios del sandbox se debilitan.

Para aplicaciones con múltiples tipos de ventanas, bloquear solo una ventana principal no es suficiente. Las "ventanas breves", como la ventana de inicio de sesión, la ventana de ayuda, la vista previa de PDF y la ventana de informe de errores, deben tener la misma política. Si necesita mostrar contenido con diferentes niveles de confianza, es mejor separarlos en ventanas separadas con una precarga dedicada y una lista de permisos dedicada, en lugar de mezclarlos en el mismo BrowserWindow.

El movimiento de URL y las nuevas ventanas también se tratan como solicitudes de permiso.

En lugar de permitir que el renderizador abra automáticamente una URL externa en una nueva ventana, es más seguro que el servidor principal verifique el host y luego lo pase al navegador del sistema. Es fácil suponer que shell.openExternal() acepta una cadena URL, pero pasar una cadena que no es de confianza puede generar protocolos peligrosos o destinos no deseados.

import { shell } from 'electron'

const ALLOWED_ORIGINS = new Set([
  'https://accounts.example.com',
  'https://docs.example.com'
])

function isAllowedExternal(urlString: string) {
  try {
    const url = new URL(urlString)
    return url.protocol === 'https:' && ALLOWED_ORIGINS.has(url.origin)
  } catch {
    return false
  }
}

mainWindow.webContents.setWindowOpenHandler(({ url }) => {
  if (isAllowedExternal(url)) void shell.openExternal(url)
  return { action: 'deny' }
})

mainWindow.webContents.on('will-navigate', (event, url) => {
  if (url !== mainWindow.webContents.getURL()) {
    event.preventDefault()
    if (isAllowedExternal(url)) void shell.openExternal(url)
  }
})

La inclusión en la lista blanca es una política de producto. Permitir todas las URL https: de inmediato es similar a no realizar ninguna verificación. Incluso en casos determinados dinámicamente como OAuth, primero verifique si puede limitar explícitamente el URI de redireccionamiento, el host y el prefijo de ruta. Este código es a la vez una característica de seguridad y sirve para documentar el comportamiento del producto.

la precarga es una lista de características, no de plomería IPC

El siguiente código es conveniente, pero no seguro.

// 하지 말 것
contextBridge.exposeInMainWorld('electron', {
  send: ipcRenderer.send,
  invoke: ipcRenderer.invoke,
  on: ipcRenderer.on
})

Esta exposición permite que cualquier código en el renderizador envíe mensajes a canales arbitrarios o se suscriba a eventos que originalmente no estaban destinados a ser entregados en la pantalla. En el momento en que se rompe la premisa de que “el único renderizador de nuestra aplicación es nuestro código”, todo el IPC se convierte en el API público del renderizador. La guía oficial de Electron también recomienda exponer una función por mensaje en lugar de un objeto IPC sin formato.

Un buen puente sólo dice verbos que la interfaz de usuario realmente necesita. Los nombres de los canales no están expuestos a la interfaz de usuario y las entradas también están normalizadas entre los límites de las funciones.

// preload.ts
import { contextBridge, ipcRenderer } from 'electron'

type SaveTextInput = {
  suggestedName: string
  content: string
}

function asSaveTextInput(value: unknown): SaveTextInput {
  if (!value || typeof value !== 'object') throw new Error('잘못된 저장 요청입니다.')

  const input = value as Record<string, unknown>
  if (typeof input.suggestedName !== 'string' || typeof input.content !== 'string') {
    throw new Error('저장할 텍스트 형식이 아닙니다.')
  }

  return {
    suggestedName: input.suggestedName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80),
    content: input.content.slice(0, 1_000_000)
  }
}

const desktop = {
  chooseTextFile: () => ipcRenderer.invoke('file:choose-text'),
  saveText: (input: unknown) => ipcRenderer.invoke('file:save-text', asSaveTextInput(input)),
  getAppVersion: () => ipcRenderer.invoke('app:version'),
  openHelp: () => ipcRenderer.invoke('navigation:open-help')
}

contextBridge.exposeInMainWorld('desktop', desktop)

Este patrón parece un poco detallado. Pero a medida que su aplicación crece, esta verbosidad se vuelve beneficiosa. Busque desktop.saveText() para encontrar las características del producto solicitadas por el renderizador y busque file:save-text para encontrar el lugar donde se ejecutan los permisos. Las funciones de dominio múltiple son mucho mejores para auditar y eliminar que un único RPC de propósito general que envía y recibe cadenas aleatorias.

También se debe incluir el tipo TypeScript. Los tipos no reemplazan la validación en tiempo de ejecución, pero evitan que la interfaz de usuario adivine y llame a las funciones ocultas del puente.

// renderer-env.d.ts
export {}

declare global {
  interface Window {
    desktop: {
      chooseTextFile(): Promise<{ name: string; content: string } | null>
      saveText(input: { suggestedName: string; content: string }): Promise<void>
      getAppVersion(): Promise<string>
      openHelp(): Promise<void>
    }
  }
}

Contrato de eventos para baja, no devoluciones de llamada.

Al crear un evento API, es mejor no pasar el objeto event de Electron al renderizador. No hay ninguna razón para propagar innecesariamente objetos relacionados con el remitente y la interfaz de usuario solo necesita conocer la carga útil del mensaje. Devolver una función de cancelación de suscripción también evita el problema de que los oyentes se acumulen después de una transición de pantalla.

// preload.ts
const desktop = {
  onUpdateProgress(callback: (progress: number) => void) {
    const listener = (_event: Electron.IpcRendererEvent, progress: unknown) => {
      if (typeof progress === 'number' && Number.isFinite(progress)) {
        callback(Math.max(0, Math.min(progress, 100)))
      }
    }

    ipcRenderer.on('update:progress', listener)
    return () => ipcRenderer.removeListener('update:progress', listener)
  }
}
// renderer component lifecycle 예시
const unsubscribe = window.desktop.onUpdateProgress(setProgress)

// 컴포넌트가 사라질 때
unsubscribe()

Si es necesario transmitir un evento a través de la aplicación, indique claramente quién lo publica y quién lo recibe. invoke/handle es más fácil de leer para solicitudes-respuestas, como completar el guardado de archivos, y es mejor seleccionar el evento solo cuando es necesario decir primero la palabra principal, como cuando hay una actualización en progreso.


El controlador IPC de main es un punto final de servidor pequeño.

Dado que ha sido verificado por bridge, no debes pensar que puedes confiar en main. La precarga es parte de la distribución y se puede llamar al controlador desde otras ventanas o desde cambios de código posteriores. El controlador principal debe verificar las siguientes cuatro cosas, como al crear HTTP API:

  1. ¿Quién lo envió?
  2. ¿Qué acción se solicitó?
  3. ¿Son correctos la forma y el alcance de los datos?
  4. ¿En qué medida se divulgarán los resultados y errores?

Primero, verifique el remitente. Puede parecer engorroso en las primeras aplicaciones con una sola ventana, pero a medida que aumenta la cantidad de ventanas de configuración, ventanas de autenticación y ventanas de vista previa, se convierte en la línea de defensa más barata.

import { BrowserWindow, dialog, ipcMain } from 'electron'
import fs from 'node:fs/promises'

function assertMainWindow(event: Electron.IpcMainInvokeEvent) {
  if (event.sender.id !== mainWindow.webContents.id) {
    throw new Error('허용되지 않은 창의 요청입니다.')
  }
}

ipcMain.handle('file:save-text', async (event, input: unknown) => {
  assertMainWindow(event)

  if (!input || typeof input !== 'object') {
    throw new Error('저장 요청이 올바르지 않습니다.')
  }

  const { suggestedName, content } = input as Record<string, unknown>
  if (typeof suggestedName !== 'string' || typeof content !== 'string') {
    throw new Error('저장 요청이 올바르지 않습니다.')
  }

  const result = await dialog.showSaveDialog(BrowserWindow.fromWebContents(event.sender)!, {
    defaultPath: `${suggestedName}.txt`,
    filters: [{ name: 'Text', extensions: ['txt', 'md'] }]
  })

  if (result.canceled || !result.filePath) return { saved: false }
  await fs.writeFile(result.filePath, content, 'utf8')
  return { saved: true }
})

La elección importante aquí es que el renderizador no pase por una ruta absoluta. El renderizador solo dice "Quiero guardar este contenido" en lugar de "Guardar mi documento en esta ruta", y el cuadro de diálogo del archivo OS y la ruta final son manejados por main. Si su producto realmente requiere una carpeta de trabajo específica, entonces path.resolve() desde una raíz permitida y asegúrese de no salir de la raíz.

import path from 'node:path'

function resolveInside(root: string, requested: string) {
  const absoluteRoot = path.resolve(root)
  const target = path.resolve(absoluteRoot, requested)

  if (target !== absoluteRoot && !target.startsWith(`${absoluteRoot}${path.sep}`)) {
    throw new Error('허용된 작업 폴더 밖의 경로입니다.')
  }

  return target
}

El error de agregar una cadena que contiene ../../ sin esta verificación ocurre a menudo en las funciones de acceso a archivos. Limite el nombre del archivo, la extensión, el tamaño y la codificación para adaptarlos a las necesidades de su producto. “Permítelo todo por si lo necesitas más adelante” es la deuda técnica más cara en IPC.

Diseñar el modelo de comando antes del nombre del canal.

Si crea un canal con un nombre amplio como fs, system o data, los objetos de opción que contiene seguirán creciendo. Después de unos meses, el renderizador se convertirá efectivamente en un mini shell. Por el contrario, los comandos de definición de roles afinan los datos que deben aceptarse.

Amplio y peligroso APIEstrecho y revisable API
ipc.invoke('fs:write', path, contents)desktop.saveExport({ format, content })
ipc.invoke('shell:open', url)desktop.openDocumentation()
ipc.invoke('process:exec', command)desktop.revealExportFolder()
ipc.send('config:set', key, value)settings.updateTheme(theme)

La segunda columna puede parecer menos funcional. De hecho, los permisos están definidos en el idioma del producto. Por ejemplo, revealExportFolder() proporciona el resultado que el usuario necesita sin exponer el comando OS al mundo exterior. En las revisiones de cambios, "¿Por qué necesita acceso al shell?" En cambio, terminamos discutiendo "¿Por qué necesito abrir la carpeta de exportación?"

La política cambia en el momento en que carga contenido remoto.

Las ventanas que abren solo paquetes de aplicaciones y las ventanas que abren URL externas no deben compartir el mismo perfil de seguridad. Esto es especialmente cierto si muestra ayuda remota, páginas de pago, OAuth, HTML generado por el usuario, anuncios o vistas previas de documentos.

  • Evite en la medida de lo posible precargar ventanas de contenido remoto.
  • Si debes adjuntarlo, crea un puente dedicado a páginas externas y no incluyas las funciones de archivo, llavero o actualización de la aplicación.
  • Las solicitudes de permiso se procesan por sesión y los permisos como notificaciones, medios y cámara no se aprueban automáticamente.
  • Configure CSP y especifique la navegación de la página, la nueva ventana y las políticas de descarga.
  • El usuario HTML no inyecta directamente en el DOM del renderizador de la aplicación como innerHTML. Elija el enfoque que se adapte a su producto: un iframe en espacio aislado, un renderizador Markdown seguro o un desinfectante probado.
import { session } from 'electron'

session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
  const origin = new URL(webContents.getURL()).origin
  const allowed = origin === 'https://accounts.example.com'
    && permission === 'notifications'

  callback(allowed)
})

Este ejemplo solo permite notificaciones a un origen específico. En una aplicación real, es más sencillo escribir primero "¿Por qué esta ventana necesita este permiso?" como una solicitud de producto y no crear un controlador si no hay ninguna solicitud. Las políticas de permisos deben dejarse como una sola línea en la tabla de funciones para evitar que permisos similares se amplíen inadvertidamente en el futuro.


Las comprobaciones de seguridad se realizan en el momento de agregar la función, no inmediatamente antes del lanzamiento.

Las configuraciones seguras se descomponen poco a poco con el tiempo. Una nueva biblioteca requiere webview, una corrección de error urgente sugiere contextIsolation: false, un comando de diagnóstico temporal se convierte en el genérico IPC, y así sucesivamente. Por lo tanto, en la práctica, es más realista incluir la “inspección de seguridad Electron” en la lista de verificación de relaciones públicas en lugar de colocarla como un gran proyecto separado.

cambiar situaciónPreguntas para hacer
Nueva ventana del navegador¿Esta ventana solo abre paquetes locales o contenido externo? ¿Es necesaria una precarga dedicada?
Nuevo IPC¿El renderizador seleccionó manualmente los permisos OS? ¿La entrada también se valida en main?
Nuevas funciones de archivos¿Cómo se manejan rutas absolutas, directorios principales móviles y enlaces simbólicos?
Nuevo enlace externo¿Exactamente qué esquema·origen·ruta puedo abrir?
Nuevos complementos/vistas previas¿Puede JS que no sea de confianza ver el puente de la aplicación?
Electron Actualización¿Ha comprobado los valores predeterminados de seguridad, los cambios importantes y las vulnerabilidades de los paquetes?

Las pruebas sencillas también pueden ser de gran ayuda. Automatice si falla naturalmente cuando llama al método inexistente window.desktop en el renderizador, si main lo rechaza cuando llama a IPC desde una ventana que no está permitida, si falla cuando ingresa una ruta como ../../secret o si no hay ningún archivo guardado API en la página remota.

// 의사 코드: E2E에서 확인할 경계
expect(await mainWindow.evaluate(() => typeof window.desktop.saveText)).toBe('function')
expect(await remoteWindow.evaluate(() => 'desktop' in window)).toBe(false)

await expect(
  invokeFromSettingsWindow('file:save-text', { suggestedName: 'x', content: 'x' })
).rejects.toThrow('허용되지 않은 창')

La seguridad no es una “función avanzada que bloquea completamente a los intrusos”, sino más bien un diseño que reduce el radio de daño en caso de accidente. El aislamiento del contexto reduce el camino para que los scripts infiltrados se precarguen, los puentes estrechos reducen las funciones que el script puede solicitar y la verificación principal toma los derechos de ejecución finales. Debido a que las tres capas repiten el mismo principio, si uno comete un error, la aplicación completa no se abrirá de inmediato.

Nota final: no oculte la funcionalidad, nombre el permiso

Las aplicaciones Electron tienen permisos de interfaz de usuario web y de escritorio. Por lo tanto, integrar todas las funciones de conveniencia en preload es rápido al principio, pero a medida que el producto crece, resulta difícil para cualquiera decir con confianza qué es posible.

Los buenos estándares son simples. Al renderizador solo se le asignan funciones que pueden explicarse por las acciones del usuario. La precarga convierte la función en una solicitud IPC explícita. main comprueba nuevamente qué ventana solicitó la acción con qué datos y luego la ejecuta. Mantener este flujo garantiza que sus experiencias de seguridad y desarrollo no entren en conflicto. Más bien, los límites entre las funciones se vuelven más claros, lo que hace que la velocidad a la que se pueden crear, eliminar y probar nuevas funciones sea más estable.

Documentación oficial

Últimas publicaciones