September 27, 2026

Electron: IPC Aman & Protokol asset:// Kustom.

Membedah process model Electron (main, preload, renderer), contextIsolation tanpa nodeIntegration, IPC bertipe lewat contextBridge, sampai membangun protokol asset:// sendiri dengan protocol.handle: anti path traversal, HTTP Range untuk streaming video, dan CSP.

Electron sering diremehkan sebagai “Chrome yang dibungkus”, padahal di balik itu ada batas keamanan yang sangat tegas: satu proses punya akses penuh ke sistem operasi, sementara proses lain menjalankan HTML dan JavaScript yang seharusnya diperlakukan seperti halaman web asing. Hampir semua bug keamanan di aplikasi Electron lahir dari batas ini yang dilubangi demi “biar cepat jalan”.

Catatan ini lahir dari membangun Pitek , asset manager desktop ala Eagle yang menyimpan gambar, video, audio, dan teks di folder biasa (thumbnail + JSON, tanpa database). Aplikasi seperti ini punya dua kebutuhan yang saling tarik: renderer harus bisa menampilkan ribuan file lokal (termasuk video berukuran gigabyte yang bisa di-seek), tetapi renderer tidak boleh bisa membaca file sembarangan. Jawabannya adalah renderer yang terisolasi penuh, IPC bertipe yang sempit, dan protokol asset:// buatan sendiri.

Kode di bawah adalah contoh ilustrasi yang mengikuti pendekatan arsitektur Pitek (renderer terisolasi, preload bertipe, protokol asset:// dengan dukungan Range), memakai API Electron modern (protocol.handle tersedia sejak Electron 25). Nama channel, nama API, dan skema URL di sini dibuat untuk contoh — bukan salinan kode Pitek, yang repo-nya privat.

Process Model: Tiga Dunia yang Berbeda

Aplikasi Electron terdiri dari beberapa proses, mewarisi arsitektur multi-proses Chromium:

ProsesJumlahRuntimeAkses
Main1Node.js penuhFilesystem, jendela, menu, dialog, protokol, native module (misalnya sharp)
Renderer1 per BrowserWindow/WebContentsViewChromium (web)Hanya API web: DOM, fetch, <video>. Tanpa Node.
Preload1 per rendererBerjalan di proses renderer, sebelum halaman dimuatSubset API Electron (ipcRenderer, contextBridge, webUtils)
UtilityOpsionalNode.jsPekerjaan berat terpisah dari main (utilityProcess)

Hal penting yang sering disalahpahami: preload bukan proses tersendiri. Ia adalah skrip yang dijalankan di dalam proses renderer, tetapi di “dunia” JavaScript yang berbeda.

Isolated world dan contextIsolation

Dengan contextIsolation: true (default sejak Electron 12), preload berjalan di isolated world: global object, prototype Array, Function, dan kawan-kawannya terpisah dari milik halaman. Konsepnya sama dengan content script di ekstensi browser (lihat Snippet Chrome Extension ).

Kenapa ini penting? Tanpa isolasi, halaman bisa melakukan prototype pollution yang memengaruhi kode preload:

// Di halaman (misalnya lewat XSS di nama file yang dirender tanpa escape)
Array.prototype.includes = () => true; // preload yang memvalidasi pakai includes() jadi lumpuh

Kalau preload yang punya akses ke ipcRenderer ikut terkena, penyerang praktis bisa memanggil apa pun yang ditawarkan main process.

nodeIntegration: false dan sandbox: true

nodeIntegration: true berarti require('fs') tersedia langsung di halaman. Untuk asset manager, satu XSS di field “notes” atau nama file sudah cukup untuk require('child_process').exec(...). Tidak ada alasan menyalakannya.

sandbox: true (default untuk renderer sejak Electron 20) melangkah lebih jauh: proses renderer dijalankan dengan sandbox OS milik Chromium, dan preload pun tidak bisa require modul Node sembarangan. Hanya subset kecil yang tersedia (modul electron versi renderer, events, timers, url, dan beberapa polyfill). Konsekuensinya jelas: semua kerja filesystem wajib lewat main process. Itu justru desain yang kita inginkan.

Konfigurasi jendela yang aman kurang lebih seperti ini:

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

export function createMainWindow() {
  const win = new BrowserWindow({
    width: 1400,
    height: 900,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      contextIsolation: true,   // default, tapi tulis eksplisit
      nodeIntegration: false,   // default, tapi tulis eksplisit
      sandbox: true,
      webSecurity: true,        // JANGAN dimatikan
    },
  })

  // Tidak boleh ada window baru atau navigasi ke luar aplikasi
  win.webContents.setWindowOpenHandler(({ url }) => {
    if (url.startsWith('https://')) shell.openExternal(url)
    return { action: 'deny' }
  })
  win.webContents.on('will-navigate', (event) => event.preventDefault())

  win.once('ready-to-show', () => win.show())
  return win
}

Nilai default ditulis eksplisit supaya siapa pun yang membaca kode (termasuk diri sendiri enam bulan lagi) tahu bahwa itu keputusan sadar, bukan kebetulan.

IPC Bertipe Lewat contextBridge

Dengan renderer terisolasi, satu-satunya pintu ke main adalah API kecil yang kita ekspos lewat contextBridge.exposeInMainWorld. Kesalahan paling umum di sini adalah mengekspos terlalu banyak.

Anti-pattern: mengekspos ipcRenderer mentah

// JANGAN
contextBridge.exposeInMainWorld('electron', { ipcRenderer })
// atau
contextBridge.exposeInMainWorld('api', {
  invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args),
})

Keduanya membuat halaman bisa memanggil channel apa pun dengan argumen apa pun. Sekalian saja nyalakan nodeIntegration. Versi kedua juga mengabaikan fakta bahwa callback ipcRenderer.on menerima objek event yang ikut membawa referensi sender.

Kontrak bersama

Pola yang enak dipakai: satu file tipe di folder shared/ yang di-import oleh main, preload, dan renderer. Karena build dipecah electron-vite menjadi tiga bundle, file ini hanya berisi tipe (hilang saat kompilasi).

// src/shared/ipc.ts
export interface ItemQuery {
  folderId?: string
  tags?: string[]
  search?: string
  limit: number
  offset: number
}

export interface ItemMeta {
  id: string
  name: string
  ext: string
  size: number
  width?: number
  height?: number
  duration?: number
  tags: string[]
  thumbUrl: string   // asset://lib/...
  fileUrl: string    // asset://lib/... atau asset://ref/<id>
}

export interface ImportProgress { done: number; total: number; current: string }

/** Channel request/response: renderer -> main */
export interface IpcInvokeMap {
  'library:open': (libraryPath: string) => { name: string; itemCount: number }
  'items:list': (query: ItemQuery) => ItemMeta[]
  'items:import': (paths: string[]) => { imported: number; skipped: number }
  'items:setTags': (id: string, tags: string[]) => void
}

/** Channel event: main -> renderer */
export interface IpcEventMap {
  'import:progress': ImportProgress
}

export type InvokeChannel = keyof IpcInvokeMap

Sisi main: handler dengan validasi pengirim dan argumen

// src/main/ipc.ts
import { ipcMain, type IpcMainInvokeEvent } from 'electron'
import type { InvokeChannel, IpcInvokeMap } from '../shared/ipc'

type Handler<C extends InvokeChannel> = (
  event: IpcMainInvokeEvent,
  ...args: Parameters<IpcInvokeMap[C]>
) => ReturnType<IpcInvokeMap[C]> | Promise<ReturnType<IpcInvokeMap[C]>>

function isTrustedSender(event: IpcMainInvokeEvent) {
  const url = event.senderFrame?.url ?? ''
  // dev: server Vite; prod: file bundle aplikasi
  return url.startsWith(process.env.ELECTRON_RENDERER_URL ?? '\0') || url.startsWith('file://')
}

export function handle<C extends InvokeChannel>(channel: C, fn: Handler<C>) {
  ipcMain.handle(channel, (event, ...args) => {
    if (!isTrustedSender(event)) throw new Error(`Untrusted sender for ${channel}`)
    return fn(event, ...(args as Parameters<IpcInvokeMap[C]>))
  })
}
// pemakaian
handle('items:setTags', async (_e, id, tags) => {
  if (!ID_RE.test(id)) throw new Error('Invalid id')
  if (!Array.isArray(tags) || tags.some((t) => typeof t !== 'string' || t.length > 64)) {
    throw new Error('Invalid tags')
  }
  await store.setTags(id, tags)
})

Perhatikan validasi runtime di dalam handler. Tipe TypeScript hilang di runtime; tipe hanya melindungi kita dari salah ketik, bukan dari renderer yang sudah dikompromikan. Anggap setiap argumen IPC sebagai input dari internet. Untuk skema yang lebih kompleks, library validasi seperti Zod layak dipakai di sini.

Sisi preload: API sempit, bukan pipa umum

// src/preload/index.ts
import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron'
import type { InvokeChannel, IpcInvokeMap, IpcEventMap } from '../shared/ipc'

function invoke<C extends InvokeChannel>(channel: C, ...args: Parameters<IpcInvokeMap[C]>) {
  return ipcRenderer.invoke(channel, ...args) as Promise<Awaited<ReturnType<IpcInvokeMap[C]>>>
}

function subscribe<E extends keyof IpcEventMap>(channel: E, cb: (payload: IpcEventMap[E]) => void) {
  const listener = (_event: IpcRendererEvent, payload: IpcEventMap[E]) => cb(payload)
  ipcRenderer.on(channel, listener)
  return () => ipcRenderer.removeListener(channel, listener) // kembalikan fungsi unsubscribe
}

const api = {
  openLibrary: (p: string) => invoke('library:open', p),
  listItems: (q: Parameters<IpcInvokeMap['items:list']>[0]) => invoke('items:list', q),
  importFiles: (paths: string[]) => invoke('items:import', paths),
  setTags: (id: string, tags: string[]) => invoke('items:setTags', id, tags),
  onImportProgress: (cb: (p: IpcEventMap['import:progress']) => void) => subscribe('import:progress', cb),
  // File.path sudah dihapus di Electron 32; ini penggantinya untuk drag & drop
  pathForFile: (file: File) => webUtils.getPathForFile(file),
}

contextBridge.exposeInMainWorld('pitek', api)
export type PitekApi = typeof api
// src/renderer/env.d.ts
import type { PitekApi } from '../preload'
declare global {
  interface Window { pitek: PitekApi }
}

Hasilnya, di komponen Svelte kita menulis await window.pitek.listItems({ limit: 200, offset: 0 }) dengan autocomplete dan tipe return yang benar, tanpa satu pun string channel bocor ke renderer.

Beberapa detail contextBridge yang perlu diingat:

  • Nilai yang melewati bridge disalin (structured clone), bukan dibagi. Class instance kehilangan prototype-nya, Map/Set ikut tersalin, tetapi DOM node dan Symbol tidak bisa lewat.
  • Fungsi yang diekspos dibungkus proxy; memanggilnya dari halaman aman karena eksekusinya tetap terjadi di isolated world preload.
  • Promise tetap bekerja lintas bridge, sehingga pola invoke di atas terasa alami. Kalau belum nyaman dengan Promise dan async/await, baca dulu Event Loop, Callback, Promise, dan Async/Await .

Masalah Sebenarnya: Menampilkan File Lokal

Sekarang renderer sudah terkunci. Lalu bagaimana menampilkan ~/Pictures/Library.pitek/ABC/abc-lq3k9x-7f2/original.mp4 di tag <video>?

Opsi yang menggoda (dan kenapa ditolak)

1. file:// langsung. Saat development, renderer dimuat dari http://localhost:5173 (server Vite), dan Chromium memblokir halaman http: yang memuat file:. Saat production, halaman sering dimuat dari file:// sehingga gambar lokal “kebetulan” jalan. Masalahnya, itu berarti renderer bisa merujuk path mana pun di disk hanya dengan menyusun URL: <img src="file:///Users/me/.ssh/..."> memang tidak menampilkan kunci SSH sebagai gambar, tetapi fetch('file:///...') dari origin file:// dan trik sejenis membuka pintu yang tidak perlu. Tidak ada titik kontrol.

2. webSecurity: false. Ini jawaban populer di Stack Overflow, dan ini yang paling berbahaya. Opsi ini mematikan same-origin policy untuk seluruh renderer, sekaligus mengizinkan konten campuran. Satu XSS berubah dari “bisa mengganti teks di layar” menjadi “bisa membaca respons dari origin mana pun”.

3. Kirim file lewat IPC sebagai base64/Buffer. Aman, tetapi boros: file 2 GB harus dibaca penuh ke memori, disalin lewat IPC, lalu dijadikan blob: URL. Tidak ada streaming, tidak ada seek sebelum semuanya selesai.

4. Server HTTP lokal di 127.0.0.1. Bisa, tetapi port itu terbuka untuk semua proses di mesin, termasuk halaman web di browser lain (fetch('http://127.0.0.1:PORT/...') dari situs mana pun). Kita perlu token, validasi Origin, dan menangani port bentrok. Terlalu banyak permukaan serangan untuk kebutuhan yang sebenarnya internal.

Opsi yang dipilih: protokol kustom

Electron mengizinkan kita mendaftarkan skema URL sendiri, dan main process menjadi satu-satunya gatekeeper. Renderer hanya tahu URL seperti asset://lib/ABC/abc-lq3k9x-7f2/thumb.webp; main process yang memutuskan apakah URL itu boleh dilayani, file mana yang dibaca, dan bagaimana mengirimkannya.

Mendaftarkan Skema: registerSchemesAsPrivileged

Skema kustom secara default diperlakukan sangat terbatas (mirip data:): tidak bisa di-fetch, tidak dianggap secure, dan tidak bisa di-stream dengan baik oleh elemen media. Hak istimewa harus diberikan sebelum event ready, dan fungsi ini hanya boleh dipanggil sekali:

// src/main/index.ts (di top-level, sebelum app.whenReady)
import { app, protocol } from 'electron'

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'asset',
    privileges: {
      standard: true,
      secure: true,
      supportFetchAPI: true,
      stream: true,
    },
  },
])

app.whenReady().then(() => {
  protocol.handle('asset', handleAssetRequest)
  createMainWindow()
})

Arti tiap privilege:

PrivilegeEfekKenapa dibutuhkan
standardURL diparse sesuai RFC 3986 (punya host, path, origin)Relative URL bekerja, new URL() menormalkan path, skema punya origin sungguhan
secureDiperlakukan seperti https:Tidak dianggap mixed content; halaman tetap secure context
supportFetchAPIBisa dipanggil dengan fetch()Misalnya mengambil isi file teks untuk editor CodeMirror
streamRespons boleh di-stream untuk <video>/<audio>Tanpa ini, media bisa gagal diputar atau tidak bisa di-seek

Yang sengaja tidak dinyalakan: bypassCSP. Kita justru ingin skema ini tunduk pada Content Security Policy. corsEnabled juga tidak dinyalakan karena dalam contoh ini renderer tidak membaca piksel gambar (ekstraksi palet warna dikerjakan main process dengan sharp). Kalau suatu saat perlu canvas.getImageData() pada gambar dari asset://, barulah corsEnabled plus header Access-Control-Allow-Origin diperlukan, karena tanpa itu canvas akan tainted.

Pitfall: host di skema standard dinormalisasi

Karena standard: true, bagian host ikut aturan hostname: di-lowercase dan divalidasi. URL asset://MyLibrary/Foo.png akan sampai ke handler sebagai host mylibrary. Jangan pernah menaruh data yang case-sensitive (nama file, ID) di host. Pola yang aman: pakai host hanya sebagai “namespace” tetap (misalnya lib, ref), sedangkan data ada di path.

Pitfall: session

protocol.handle mendaftar ke session default. Jika jendela memakai partition tersendiri, handler harus didaftarkan lewat session.fromPartition('persist:xxx').protocol.handle(...). Gejalanya membingungkan: semua gambar gagal dimuat tanpa error yang jelas.

Handler: Dari URL ke File, dengan Aman

protocol.handle menerima fungsi yang mendapat objek Request (Fetch API standar) dan harus mengembalikan Response atau Promise<Response>. Ini jauh lebih enak daripada API lama (registerFileProtocol, registerStreamProtocol) yang kini sudah deprecated.

Contohnya, aplikasi seperti Pitek bisa memakai dua bentuk URL:

  • asset://lib/<path relatif terhadap root library>: thumbnail dan file yang disalin ke dalam library.
  • asset://ref/<itemId>: item hasil “Link/Reference import” yang filenya tetap di lokasi asal. Renderer tidak pernah mengirim path asli; main process mencarinya dari metadata.json item tersebut.
// src/main/asset-protocol.ts
import path from 'node:path'
import fs from 'node:fs/promises'
import { library } from './library'

const ID_RE = /^[a-z0-9-]{1,80}-[a-z0-9]+-[a-z0-9]+$/ // slug-base36time-random

export async function handleAssetRequest(request: Request): Promise<Response> {
  if (request.method !== 'GET' && request.method !== 'HEAD') {
    return new Response('Method not allowed', { status: 405 })
  }

  const url = new URL(request.url)
  const filePath = await resolveAssetPath(url)
  if (!filePath) return new Response('Not found', { status: 404 })

  return serveFile(filePath, request)
}

async function resolveAssetPath(url: URL): Promise<string | null> {
  const root = library.currentRoot()
  if (!root) return null

  switch (url.host) {
    case 'lib': {
      let rel: string
      try {
        rel = decodeURIComponent(url.pathname).replace(/^\/+/, '')
      } catch {
        return null // %E0%A4%A dan sejenisnya: URI rusak
      }
      return resolveInside(root, rel)
    }
    case 'ref': {
      const id = url.pathname.slice(1)
      if (!ID_RE.test(id)) return null
      const meta = await library.readMeta(id) // baca <bucket>/<id>/metadata.json
      return meta?.referencePath ?? null      // path yang dulu dipilih user sendiri
    }
    default:
      return null
  }
}

Path traversal: kenapa new URL() saja tidak cukup

Karena skemanya standard, new URL('asset://lib/../../etc/passwd').pathname sudah menjadi /etc/passwd: segmen .. literal (bahkan %2e%2e) dinormalisasi oleh parser URL. Kedengarannya aman, tetapi perhatikan ini:

new URL('asset://lib/..%2F..%2F..%2Fetc%2Fpasswd').pathname
// "/..%2F..%2F..%2Fetc%2Fpasswd"   -> parser tidak menganggap %2F sebagai pemisah
decodeURIComponent(...)
// "/../../../etc/passwd"           -> setelah decode, traversal muncul kembali

Karena kita harus men-decode (nama file bisa mengandung spasi, #, atau huruf non-ASCII), pemeriksaan wajib dilakukan setelah decode, pada path filesystem yang sudah di-resolve:

async function resolveInside(root: string, rel: string): Promise<string | null> {
  if (!rel || rel.includes('\0')) return null

  const target = path.resolve(root, rel)
  if (!isInside(root, target)) return null

  // Symlink di dalam library bisa menunjuk ke luar. Bandingkan path aslinya.
  try {
    const [realRoot, realTarget] = await Promise.all([fs.realpath(root), fs.realpath(target)])
    return isInside(realRoot, realTarget) ? realTarget : null
  } catch {
    return null // file tidak ada
  }
}

function isInside(root: string, target: string): boolean {
  const relative = path.relative(root, target)
  return (
    relative !== '' &&
    relative !== '..' &&
    !relative.startsWith('..' + path.sep) &&
    !path.isAbsolute(relative) // Windows: beda drive menghasilkan path absolut
  )
}

Beberapa detail yang sering terlewat:

  • relative.startsWith('..') saja keliru: file bernama ..catatan.txt akan ikut ditolak. Bandingkan dengan '..' + path.sep.
  • Jangan pakai target.startsWith(root): root /lib/Foo akan meloloskan /lib/FooBar/rahasia.
  • path.isAbsolute(relative) menangkap kasus Windows ketika target berada di drive lain (D:\...), di mana path.relative mengembalikan path absolut.
  • \0 (null byte) ditolak eksplisit; Node memang melempar error, tetapi lebih baik gagal di validasi daripada di tengah I/O.
  • Symlink: path.resolve tidak menyentuh disk, jadi root/ABC/link -> /Users/me lolos pemeriksaan pertama. fs.realpath pada kedua sisi menutup celah ini.

Untuk asset://ref/<id>, perlindungannya berbeda: URL hanya membawa ID yang divalidasi regex, dan path diambil dari metadata yang ditulis main process sendiri saat user memilih file lewat dialog atau drag & drop. Renderer tidak punya cara menyelipkan path.

Streaming Video dengan HTTP Range

Elemen <video> di Chromium tidak mengunduh file utuh. Ia mengirim request dengan header Range: bytes=0-, membaca metadata (untuk MP4, atom moov yang kadang ada di akhir file), lalu melompat ke posisi lain saat user menggeser timeline. Jika server selalu membalas 200 dengan seluruh isi file, hasilnya: video mungkin tetap diputar, tetapi seek tidak bekerja atau harus menunggu seluruh file terbaca.

Kita perlu mengimplementasikan tiga respons:

KondisiStatusHeader penting
Tanpa Range200 OKContent-Length, Accept-Ranges: bytes
Range valid206 Partial ContentContent-Range: bytes start-end/size, Content-Length
Range di luar ukuran file416 Range Not SatisfiableContent-Range: bytes */size

Parsing header Range

type ByteRange = { start: number; end: number }

/**
 * null       -> abaikan Range, kirim file utuh (RFC 9110 mengizinkan ini)
 * 'invalid'  -> 416
 */
function parseRange(header: string | null, size: number): ByteRange | 'invalid' | null {
  if (!header) return null
  const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
  if (!m) return null                     // multi-range "bytes=0-1,5-9" atau unit lain: abaikan
  const [, rawStart, rawEnd] = m
  if (rawStart === '' && rawEnd === '') return null

  let start: number
  let end: number
  if (rawStart === '') {
    // suffix range: "bytes=-500" = 500 byte terakhir
    const suffix = Number(rawEnd)
    if (suffix === 0) return 'invalid'
    start = Math.max(0, size - suffix)
    end = size - 1
  } else {
    start = Number(rawStart)
    end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1)
  }

  if (start >= size || start > end) return 'invalid'
  return { start, end }
}

Dua kasus tepi: suffix range (bytes=-500) dipakai beberapa decoder untuk membaca akhir file, dan end yang melebihi ukuran file harus dipotong, bukan ditolak.

Mengirim potongan file sebagai stream

import { createReadStream } from 'node:fs'
import { Readable } from 'node:stream'

async function serveFile(filePath: string, request: Request): Promise<Response> {
  let stat
  try {
    stat = await fs.stat(filePath)
  } catch {
    return new Response('Not found', { status: 404 })
  }
  if (!stat.isFile()) return new Response('Not found', { status: 404 })

  const size = stat.size
  const headers = new Headers({
    'Content-Type': mimeFor(filePath),
    'Accept-Ranges': 'bytes',
    'X-Content-Type-Options': 'nosniff',
    'Cache-Control': 'no-cache',
  })

  if (size === 0) {
    headers.set('Content-Length', '0')
    return new Response(null, { status: 200, headers })
  }

  const range = parseRange(request.headers.get('range'), size)
  if (range === 'invalid') {
    headers.set('Content-Range', `bytes */${size}`)
    return new Response(null, { status: 416, headers })
  }

  const { start, end } = range ?? { start: 0, end: size - 1 }
  const status = range ? 206 : 200
  headers.set('Content-Length', String(end - start + 1))
  if (range) headers.set('Content-Range', `bytes ${start}-${end}/${size}`)

  if (request.method === 'HEAD') return new Response(null, { status, headers })

  // `end` pada createReadStream bersifat inklusif, sama seperti Content-Range
  const nodeStream = createReadStream(filePath, { start, end })
  const body = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>
  return new Response(body, { status, headers })
}

Kenapa stream dan bukan fs.readFile? Karena Chromium sering meminta bytes=0- (sampai akhir file) lalu membatalkan request begitu sudah dapat cukup data. Dengan stream, pembatalan itu menjalar: web stream di-cancel, Readable.toWeb menghancurkan stream Node, dan file handle ditutup. Dengan readFile, kita sudah membaca 2 GB ke RAM untuk sesuatu yang dibuang.

mimeFor cukup berupa peta ekstensi sederhana (.mp4 ke video/mp4, .webm ke video/webm, .mov ke video/quicktime, .mp3 ke audio/mpeg, .webp ke image/webp, fallback application/octet-stream). Header nosniff mencegah Chromium menebak-nebak; file .txt berisi HTML tidak akan pernah dirender sebagai halaman.

Alternatif: net.fetch ke file://

Dokumentasi Electron mencontohkan handler yang meneruskan request ke net.fetch(pathToFileURL(filePath).toString()). Itu ringkas dan cocok untuk aset statis kecil. Untuk media besar, menulis Range sendiri memberi kontrol penuh atas status code, header, dan perilaku pembatalan, serta tidak bergantung pada bagaimana versi Electron tertentu meneruskan header Range ke loader file://. Apa pun pilihannya, pemeriksaan path tetap harus dilakukan sebelum net.fetch, karena net.fetch ke file:// akan membaca apa saja yang diminta.

Content Security Policy

Protokol kustom yang aman tidak ada gunanya jika renderer bisa menjalankan skrip dari mana saja. CSP adalah lapisan kedua jika suatu saat ada XSS lolos (misalnya dari nama file yang dirender dengan {@html} di Svelte). Topik ini beririsan dengan bahasan XSS di Autentikasi Sisi Klien .

Untuk build production:

<!-- src/renderer/index.html -->
<meta
  http-equiv="Content-Security-Policy"
  content="
    default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' asset: data: blob:;
    media-src 'self' asset: blob:;
    connect-src 'self' asset:;
    font-src 'self';
    object-src 'none';
    base-uri 'none';
    form-action 'none';
    frame-src 'none'
  "
/>

Poin-poinnya:

  • asset: hanya muncul di img-src, media-src, dan connect-src. Skema itu tidak bisa dipakai untuk <script src="asset://...">. Jadi meskipun penyerang berhasil menaruh file .js di dalam library (dan itu mudah, cukup impor file), file itu tidak bisa dieksekusi.
  • Tanpa 'unsafe-eval'. Beberapa library (template engine lama, beberapa builder regex) butuh eval; lebih baik ganti library-nya.
  • 'unsafe-inline' hanya untuk style, karena transisi dan style dinamis Svelte. Script inline tetap diblokir.
  • Development berbeda: Vite butuh WebSocket untuk HMR, sehingga connect-src perlu ws://localhost:*. electron-vite memudahkan ini dengan HTML terpisah per mode atau dengan menyuntikkan header CSP lewat session.defaultSession.webRequest.onHeadersReceived hanya ketika app.isPackaged bernilai true.

Jika CSP dilanggar, Electron mencetak peringatan di DevTools. Electron juga menampilkan peringatan keamanan otomatis di console saat development (misalnya “Insecure Content-Security-Policy”) selama aplikasi berjalan tanpa CSP. Jangan diabaikan.

Pitfall yang Pernah Terjadi

1. Memanggil registerSchemesAsPrivileged di dalam whenReady. Dokumentasi mewajibkan pemanggilan sebelum ready; kalau terlambat, gejalanya tidak selalu berupa error yang jelas, melainkan skema yang tidak punya privilege: fetch('asset://...') gagal dengan pesan generik dan video tidak bisa di-seek. Panggilan ini harus di top-level modul main.

2. Memanggilnya dua kali. Fungsi ini hanya boleh dipanggil sekali. Jika ada dua skema (misalnya asset dan app), daftarkan keduanya dalam satu array di satu panggilan, idealnya di satu modul yang jelas.

3. Nama file dengan # atau ?. Jika URL disusun dengan string concatenation ('asset://lib/' + relPath), karakter # dianggap fragment dan ? dianggap query, sehingga file tidak ditemukan. Susun URL dengan meng-encode setiap segmen:

export function toAssetUrl(relPath: string) {
  const encoded = relPath.split(path.sep).map(encodeURIComponent).join('/')
  return `asset://lib/${encoded}`
}

Fungsi ini hidup di main process; renderer menerima thumbUrl/fileUrl yang sudah jadi di ItemMeta.

4. Cache thumbnail yang basi. Setelah user melakukan crop atau rotate, thumbnail di disk berubah tetapi <img> tetap menampilkan versi lama karena URL-nya sama. Solusinya menambahkan versi ke URL (?v=<mtimeMs>) saat metadata diperbarui, bukan mematikan cache sepenuhnya.

5. Library berganti, request lama masih jalan. Saat user pindah library, handler memakai library.currentRoot() pada saat request datang. Grid yang belum selesai memuat bisa meminta path relatif yang ternyata juga ada di library baru. Memasukkan ID library ke path (asset://lib/<libId>/...) dan menolak ID yang tidak cocok menghindari tampilan campur aduk.

6. File.path hilang. Kode drag & drop lama memakai event.dataTransfer.files[0].path. Properti ini dihapus di Electron 32; penggantinya webUtils.getPathForFile(file) yang dipanggil di preload, seperti pada contoh API di atas.

7. Mengekspos fungsi generik “readFile” untuk editor teks. Godaan besar saat membuat editor CodeMirror: readText(path). Lebih aman mengambil konten lewat fetch(item.fileUrl) (yang melewati validasi protokol yang sama) dan menyimpan lewat IPC items:saveText(id, content) yang menerima ID, bukan path.

Checklist

  • contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, ditulis eksplisit.
  • Preload mengekspos fungsi bernama, bukan ipcRenderer atau invoke(channel) generik.
  • Setiap handler IPC memvalidasi pengirim (senderFrame.url) dan argumen di runtime.
  • Listener event dari main tidak meneruskan objek event ke halaman dan punya fungsi unsubscribe.
  • registerSchemesAsPrivileged dipanggil sekali, sebelum ready, tanpa bypassCSP.
  • Path dari URL di-decode, di-resolve, dicek dengan path.relative, lalu dicek ulang setelah realpath.
  • Renderer mengirim ID untuk file di luar library, bukan path absolut.
  • Range: 200 / 206 / 416 dengan Accept-Ranges, Content-Range, Content-Length yang benar, body berupa stream.
  • CSP ketat; asset: hanya di img-src, media-src, connect-src.
  • setWindowOpenHandler menolak window baru dan will-navigate dicegah.

Kesimpulan

Pelajaran terbesar dari membangun Pitek: keamanan Electron bukan soal satu opsi ajaib, melainkan soal di mana keputusan dibuat. Renderer boleh meminta apa saja, tetapi hanya main process yang memutuskan. contextBridge mempersempit apa yang bisa diminta, validasi runtime memastikan permintaannya masuk akal, dan protokol asset:// memastikan bahkan sesuatu sesederhana <img src> pun melewati gerbang yang sama.

Bonusnya, pendekatan ini tidak mengorbankan performa: <video> tetap bisa di-seek di file berukuran gigabyte, ribuan thumbnail dimuat langsung oleh Chromium tanpa lewat IPC, dan tidak ada port lokal yang terbuka.

Cerita di balik proyek ini (dan kenapa asset manager tanpa database terasa perlu dibuat) ada di Proyek-Proyek Kegabutan .

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.

Comments