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:
| Proses | Jumlah | Runtime | Akses |
|---|---|---|---|
| Main | 1 | Node.js penuh | Filesystem, jendela, menu, dialog, protokol, native module (misalnya sharp) |
| Renderer | 1 per BrowserWindow/WebContentsView | Chromium (web) | Hanya API web: DOM, fetch, <video>. Tanpa Node. |
| Preload | 1 per renderer | Berjalan di proses renderer, sebelum halaman dimuat | Subset API Electron (ipcRenderer, contextBridge, webUtils) |
| Utility | Opsional | Node.js | Pekerjaan 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/Setikut 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
invokedi atas terasa alami. Kalau belum nyaman dengan Promise danasync/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:
| Privilege | Efek | Kenapa dibutuhkan |
|---|---|---|
standard | URL diparse sesuai RFC 3986 (punya host, path, origin) | Relative URL bekerja, new URL() menormalkan path, skema punya origin sungguhan |
secure | Diperlakukan seperti https: | Tidak dianggap mixed content; halaman tetap secure context |
supportFetchAPI | Bisa dipanggil dengan fetch() | Misalnya mengambil isi file teks untuk editor CodeMirror |
stream | Respons 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 darimetadata.jsonitem 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.txtakan ikut ditolak. Bandingkan dengan'..' + path.sep.- Jangan pakai
target.startsWith(root): root/lib/Fooakan meloloskan/lib/FooBar/rahasia. path.isAbsolute(relative)menangkap kasus Windows ketikatargetberada di drive lain (D:\...), di manapath.relativemengembalikan path absolut.\0(null byte) ditolak eksplisit; Node memang melempar error, tetapi lebih baik gagal di validasi daripada di tengah I/O.- Symlink:
path.resolvetidak menyentuh disk, jadiroot/ABC/link -> /Users/melolos pemeriksaan pertama.fs.realpathpada 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:
| Kondisi | Status | Header penting |
|---|---|---|
Tanpa Range | 200 OK | Content-Length, Accept-Ranges: bytes |
Range valid | 206 Partial Content | Content-Range: bytes start-end/size, Content-Length |
Range di luar ukuran file | 416 Range Not Satisfiable | Content-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 diimg-src,media-src, danconnect-src. Skema itu tidak bisa dipakai untuk<script src="asset://...">. Jadi meskipun penyerang berhasil menaruh file.jsdi dalam library (dan itu mudah, cukup impor file), file itu tidak bisa dieksekusi.- Tanpa
'unsafe-eval'. Beberapa library (template engine lama, beberapa builder regex) butuheval; 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-srcperluws://localhost:*. electron-vite memudahkan ini dengan HTML terpisah per mode atau dengan menyuntikkan header CSP lewatsession.defaultSession.webRequest.onHeadersReceivedhanya ketikaapp.isPackagedbernilaitrue.
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
ipcRendereratauinvoke(channel)generik. - Setiap handler IPC memvalidasi pengirim (
senderFrame.url) dan argumen di runtime. - Listener event dari main tidak meneruskan objek
eventke halaman dan punya fungsi unsubscribe. -
registerSchemesAsPrivilegeddipanggil sekali, sebelumready, tanpabypassCSP. - Path dari URL di-decode, di-resolve, dicek dengan
path.relative, lalu dicek ulang setelahrealpath. - Renderer mengirim ID untuk file di luar library, bukan path absolut.
- Range: 200 / 206 / 416 dengan
Accept-Ranges,Content-Range,Content-Lengthyang benar, body berupa stream. - CSP ketat;
asset:hanya diimg-src,media-src,connect-src. -
setWindowOpenHandlermenolak window baru danwill-navigatedicegah.
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.